@volter/twin-bitly
v0.1.37
Published
Local Bitly twin — a faithful, stateful local Bitly v4 API your link integration talks to unmodified. Mirror, simulate, and fork. Built on @volter/world-core.
Readme
@volter/twin-bitly
The Bitly twin — a local, stateful, vendor-faithful replica of the Bitly
v4 API on the shared @volter/world-core kernel. An unmodified integration
(plain fetch against api-ssl.bitly.com/v4, which is how every real Bitly consumer talks to it —
Dub's link importer included) points at the twin and gets vendor-correct responses.
State lives in the kernel's append-only action/event log — a write appends an action that projects into visible state; reads use the kernel’s tree. No real Bitly is ever called (the only network path is the connector, over an injected client). Auth is a faked-locally Bearer access token — Bitly's API auth is a plain bearer token, so there is NO JWT, NO JWKS, NO request signing.
Usage
bun run src/cli.ts serve # the Bitly v4 API twin (writable)
bun run src/cli.ts serve --read-only
bun run src/cli.ts mirror # the React link dashboard mirror
bun run src/cli.ts conformance # offline, failable endpoint-probe checkResponses are the vendor's own shapes, taken from Bitly's first-party OpenAPI document. Errors use
Bitly's Error envelope — { message, resource, description, errors?[{ field, error_code }] } —
where message is the status name the spec itself declares (NOT_FOUND, BAD_REQUEST,
FORBIDDEN, GONE, UNPROCESSABLE_ENTITY, CONFLICT).
Coverage
The capability manifest (src/bitly-capabilities.ts) is the real Bitly v4 surface as the
denominator, enumerated top-down from Bitly's own published OpenAPI 3.0.0 document
(https://dev.bitly.com/v4/v4.json, fetched 2026-09-02: 113 paths / 132 route operations) plus the
OAuth token exchange that lives outside /v4. Coverage is honest and partial. Run
bun scripts/manifest-baseline-one.ts bitly for the live count. As of writing:
done=86 / total=163 (regressions=0). The spec census
(bitly-spec-census.json) reads 47 capability / 85 unmapped (one ruled) of the 132 spec
operations.
Modeled (proven done, each with a failable offline verify):
- OAuth —
POST /oauth/access_token, the authorization-code exchange, answering form-encodedtext/plain(not JSON) exactly as the vendor does; a token is derived from the code, so the same code always exchanges to the same token. Missing code →400 error=invalid_request. - Bitlinks —
POST /v4/shorten(deduplicating per group+domain+URL,201new /200existing,force_new_linkto override),POST /v4/bitlinks(title, tags, keyword back-half),GET/PATCH/DELETE /v4/bitlinks/{domain}/{hash},POST /v4/expand. Negatives are modeled: a non-http(s)long_urlis a field-level400, a foreign domain403, an unknown group404, a deleted link410 GONE, a keyword collision409, an empty patch422. - Click analytics — a deterministic modelled surface, not a stub:
/clicks,/clicks/summary,/countries,/cities,/devices,/referrers,/referring_domainsand the group-level/clicksall FOLD stored click observations, honouringunit/units/unit_reference. See the note below on how clicks get into the twin. - Groups — list (with
organization_guidfilter), get, rename; the group link list with Bitly'ssize+ opaquesearch_afterpagination,tags,archivedandcreated_beforefilters;tags,clicks,shorten_countsand the group QR list. - User + organizations —
GET/PATCH /v4/user,GET /v4/user/platform_limits(whosecountmoves as links are minted, which is what Dub's importer polls),GET /v4/organizations,GET /v4/organizations/{guid},/plan_limits, andGET /v4/bsds. - Webhooks — registration CRUD (
201create with per-field400s, get, patch,204-with-empty-body delete) and the org hook list. Registration only — no delivery. - QR codes — create over a bitlink or a raw long URL, get/patch/delete, group listing.
- Custom bitlinks — attach a keyword back-half, resolve it,
409on reuse. - Campaigns + channels — create/list/get/update.
- Protocol — unmodeled ops fail like the vendor (
404with theErrorenvelope, never a fabricated success); read-only rejects writes (405); a re-read of the same state serves the same bytes. - Connector — pull the account (the token owner and its organization roster), groups, every
group's bitlinks (walking
search_after) and their QR codes into the observed log (idempotent re-pull; a refused pull throws rather than folding an empty account over real state, and so does a200that carries nologin); push local creates/updates to the real API and confirm them. A local delete is never translated into a realDELETE— a twin-minted id is not a link the account owns. A pulled owner is served WHOLESALE (no seeded field survives underneath it) and a pulled organization roster REPLACES the seeded constant for that root, so a pulled group'sorganization_guidresolves. A pull observes: a localPATCH /v4/useris folded on top of the observation and survives every later re-pull. - UI mirror — the Links, analytics-panel and QR Codes screens are data-coupled (seed
through the twin's own write path → fetch the same
/v4endpoint the screen reads → render the dashboard's own exported component over the served rows → assert the seeded values in the markup).
On the clock. Bitly's metrics endpoints take unit / units / unit_reference.
unit_reference is what the vendor says it is — "the most recent time for which to pull
metrics", a ceiling, always — and it defaults to the request's world instant, exactly as the
vendor's "now" does. units: -1 (all units of time, and the default) removes the floor and
nothing else. Coherence comes from the other end instead: the twin-only seed route refuses to
record a click dated after the instant recording it, so stored state can never hold an
observation an honest ceiling would have to hide. bitly.clicks.unit_reference_is_a_real_ceiling
pins all three halves — floor removed, ceiling honoured, future observation refused.
An earlier revision of this pack removed the ceiling for units: -1 in the name of determinism.
That made a declared parameter a silent no-op on the default request — a future-dated click
leaked into every answer while the ignored ceiling was echoed back verbatim — and adversarial
review caught it. The lesson is recorded here because the fix looked like a determinism win: when
a twin and its vendor disagree about a declared parameter, the twin is wrong.
How clicks get into the twin. A Bitly click is created by a human following a bit.ly redirect
on Bitly-operated edge infrastructure — no vendor endpoint creates one, and a local twin cannot
originate real traffic. So the twin exposes ONE clearly-namespaced twin-only control route,
POST /v4/_twin/clicks, which a test or a world seed uses to record click observations; every
metrics endpoint is then a deterministic fold over them. That route is deliberately outside the
capability manifest and outside the spec census — it is scaffolding, not vendor surface, and
counting it would pad the denominator (the same convention as figma's and openweather's seed
routes).
Todo (real Bitly surface, not yet modeled — 84 of the 132 spec operations): the whole Bitly
Sites product (link-in-bio pages, blocks, appearance, templates, images, and both the per-site
and /aggregate/sites analytics families — 37 operations); the engagement family combining
clicks with QR scans; the group-level metric rollups (/groups/{guid}/{countries,cities,devices,
referrers,referring_networks}, /links/clicks/*, /codes/scans/*, agentic traffic); group
administration (preferences, exports, feature and historical usage); QR scan metrics and the
customization surface (render customizations, static codes, redirect, to-bitlink, bulk update);
bitlink bulk update and sorted listings; the custom-bitlink update and per-destination click split;
deeplinks, dynamic routing and expiration; the OAuth app lookup; and, on the connector side, pulls
for webhooks, campaigns and real click metrics.
Also todo, because "we did not build it" is not "a local twin cannot":
- Serving
bitly.com/oauth/authorizeas a protocol participant (the human consent leg — the recipe's "vendor UI is protocol" class, withgoogleoauthas the exemplar; a second host is a wiring decision, not an impossibility). Without it, Dub's OAuth flow cannot run end to end against the twin. - Local webhook delivery over an injected offline deliverer, which is exactly what
resenddoes. - The shortlink redirect —
GET /<hash>on the twin answering with a 301 to the bitlink destination, so the short URLs the twin mints resolve against it. - QR image bytes — deterministic PNG/SVG for a QR resource (today only the QR record is modeled).
Fidelity
src/bitly-sdk.integration.test.ts drives the twin with unmodified real-transport fetch.
Bitly ships no first-party npm SDK — every real integration calls /v4 with plain fetch, so
that is the unmodified client. The test issues the exact request shapes Dub's Bitly importer
sends (apps/web/app/(ee)/api/cron/import/bitly/, apps/web/lib/middleware/utils/crawl-bitly.ts,
apps/web/lib/integrations/bitly/oauth.ts) and asserts the vendor's own response shapes come back
— including that the OAuth exchange parses with new URLSearchParams(await res.text()), which is
literally what Dub does with it.
Files
src/bitly-twin.ts— the v4 request handler (routes → kernel writes/reads).src/bitly-server.ts—createBitlyTwinFetch/createBitlyTwinServer.src/bitly-mirror-ui.ts+client/bitly-mirror.tsx— the React link dashboard mirror.src/bitly-connector.ts— pull/push over an injected executor (liveBitlyExecutefor real I/O).src/bitly-conformance.ts— offline, failable endpoint-probe check (dev-only; lazy-imported).src/bitly-capabilities.ts— the capability manifest (the real vendor surface).src/bitly-journey.uitest.ts— the two registered Playwright UI journeys.
Rate budget — the fail-closed backstop on live calls
liveBitlyExecute is the one place this pack issues a live request, so every call it makes is
charged against a persistent, fail-closed spend ledger before the request goes out. Past the
ceiling, or while a Retry-After/429 cooldown is armed, it throws instead of calling. The
ledger is keyed by vendor and a hash of the credential (limits are per credential, so it is
deliberately not cwd-scoped) and persists across processes, so a fresh process does not get a
fresh allowance; a corrupt ledger counts as a full window rather than zero spend. There is no
option to disable it, and no value you can pass for budget that yields an unguarded client — an
injected budget is validated by method identity, so a subclass or a Proxy that replaces
checkBudget is refused.
The declared numbers: 60 weighted units / 60s at defaultWeight 2 — 30 calls a minute, EXACTLY
the kernel's undeclared fallback. Bitly publishes no scalar rate limit: its rate-limits page
(https://dev.bitly.com/docs/getting-started/rate-limits, read 2026-09-02) states only that the
per-minute limit is one-tenth of the hourly limit, illustrates it with a hypothetical, and directs
callers to read their own plan-specific numbers from GET /v4/user/platform_limits and
GET /v4/organizations/{guid}/plan_limits. With no first-party number to justify being more
permissive, this declaration is more permissive than the fallback nowhere. POST /v4/shorten and
POST /v4/bitlinks cost 5 because each spends an encode from the account's monthly quota — a
finite resource a throttle does not give back; that weighting is a judgement call, not a published
cost.
The mechanism is shared and vendor-agnostic — it lives in the kernel (@volter/world-core →
packages/world-core/src/rateBudget.ts); what lives here in src/bitly-budget.ts
is this vendor's declaration (window, ceiling, per-endpoint weights, and a reason citing the
source above) plus the vendor-bound BitlyBudget. The rule is ratified as
../../../docs/contributing/architecture.md D8, and the kernel module's header documents what
the guard does not guarantee — read that before trusting it.
