Package, API, and runtime reference for contributors and operators. For setup, start with the README and the self-hosting guides.
Use Bun 1.3.14. From a standalone checkout:
bun install --frozen-lockfile
bun run check
bun run test
bun run format:checkTests run locally in Cloudflare's workerd runtime with real D1 storage. They require no Cloudflare account, email provider, or private repository. The test-only delivery capture is confined to tests/.
@flared/contracts: link input validation, stable error codes, API token scopes and responses (@flared/contracts/tokens), the shared reserved-path list, the passkey summary shown to account pages, and the click event and analytics response contracts (@flared/contracts/analytics).@flared/data: identity, tenancy, routing and analytics tables, the D1/Drizzle adapter and versioned migrations.@flared/server: absolute session defaults, safe principal extraction, structured Cloudflare email delivery, trusted service forwarding, the/v1links and analytics API, the short-link redirect handler, the click Queue consumer (@flared/server/analytics) and the analytics shard map (@flared/server/shards).@flared/client: the typed/v1client for the CLI and the MCP endpoint. It takes an API URL and a token, checks each response's shape, and throwsFlaredApiErrorwith the API error code. It retries only reads and keyed link creation, after 429, 503, or a network error.@flaredlink/cli(packages/cli): the publishedflaredcommand for Node.js 20 or later.bun run build:clibundles it intopackages/cli/dist/flared.js;bun run test:cliruns its tests. See its README.@flared/ui: the link create form and link list, the link analytics view, the usage warning banner and usage meters, the API token list (@flared/ui/tokens/TokenList.svelte), the passkey list, and the browser passkey ceremonies (@flared/ui/passkeys/client) against the shared/api/auth/passkey/*routes. It also has the connected-app list with its setup guide, the workspace rename form (@flared/ui/workspace/WorkspaceName.svelte), and the Light, Dark, and System control (@flared/ui/theme/ThemeControl.svelte).@flared/ui/stylesholds the Kumo tokens (MIT), the Flared design roles in light and dark values, and element defaults; each app imports it once. The Claude, ChatGPT, Cursor, Claude Code, and Grok marks inpackages/ui/src/oauth/AssistantMark.sveltecome from LobeHub icons (MIT,LOBEHUB-LICENSE.txtbeside it). A connected app shows one only when its redirect URIs belong to that assistant; the setup guide also shows them. The analytics view shows country flags from flag-icons 7.5.0 (MIT,packages/ui/src/analytics/flags/LICENSE), browser logos from alrra/browser-logos (MIT for the project; the logos are their owners' trademarks;browsers/LICENSE.txt), and device and OS icons from Phosphor (MIT,PHOSPHOR-LICENSE.txt). Flags and logos are separate files that load after the page mounts. The account avatar draws a face with DiceBear 9.4.3 (MIT) in the Notionists style by Zoish (CC0 1.0); the browser seeds it from a SHA-256 hash of the name and loads it only on pages that show an avatar.
Import declared subpath exports. Apply packages/data/migrations/identity to the identity database, packages/data/migrations/routing to the routing database, and packages/data/migrations/analytics to every analytics shard through a migration runner; never modify a released migration.
The redirect handler (@flared/server/redirect) accepts only GET and HEAD. It sends reserved paths to the configured app origin, answers unknown hosts and links with a generic 404, and keeps each resolved link in a Workers Cache entry for at most 60 seconds from the start of its database lookup. A database failure without a valid entry returns 503.
A link can also have an iosDestination and an androidDestination (routing migration 0007_device_destinations.sql). The redirect sends a visitor whose OS family is iOS or Android to that destination, and everyone else, including automated clients such as link previews, to destination; such a response carries Vary: User-Agent. A device destination is an http(s) URL with the destination rules, or an app link in the scheme://... form; intent:// is accepted for Android only. Schemes that run code, read local data, install apps outside a store, open browser or OS internals, or send network credentials are refused: javascript, data, file, blob, itms-services, content, smb, and others by name, plus whole families (browser vendor prefixes such as chrome-*, every *-extension scheme, and Windows ms-* handlers); the list is in @flared/contracts/links. An operator block covers the device destinations too. withUtm and utmOf in @flared/contracts/links set and read utm_source, utm_medium, utm_campaign, utm_term, and utm_content on a destination: the dashboard forms (UtmFields.svelte), the CLI --utm-* options, and the MCP utm field write them into destination, so the API needs no separate field.
Given a clicks sink (a Queue producer), the redirect handler sends one event after each GET redirect of a link. HEAD requests, reserved paths, errors, and user agents that the versioned classifier in @flared/server/clicks marks as automated (crawlers, link-preview fetchers, HTTP libraries, empty user agents) send nothing. The event carries only the tenant, link, shard, time, a two-letter country, a device category, a browser family (Chrome, Safari, Firefox, Edge, Samsung Internet, Opera, other, or unknown), an OS family (iOS, Android, Windows, macOS, Linux, ChromeOS, other, or unknown), and the referrer host name. No browser or OS version and no user agent leave the redirect; in-app browsers count as other, and iPads that send a macOS user agent count as macOS. A failed send never changes the redirect.
An edition can forward a workspace's clicks to another service. setClickForwarding sets the routing flag tenant_policy.click_forwarding (routing migration 0011), which the policy projection never writes; listClickForwardingTenants lists the tenants that have it. The redirect reads the flag in its one lookup and uses it only while the policy's webhookEndpointLimit is above 0. For such a link, a counted GET (the conditions above) reads the host-only cookie flared_vid (128 random bits as 22 base64url characters; any other value is ignored) or sets a new one with Max-Age=31536000; Path=/; Secure; HttpOnly; SameSite=Lax and Cache-Control: private, no-store. A request with Sec-GPC: 1 gets no cookie, and an existing one is neither read nor renewed. The event then also carries forward: the cookie value (none with GPC), the user agent (printable ASCII, at most 512 characters), the short URL, and the utm_source, utm_medium, utm_campaign, utm_term, utm_content, and utm_id tags of the location sent (the device destination when one is used; at most 100 characters each). The IP address and the destination never leave the redirect. Without the flag, no cookie is read or set and no event carries forward; the standalone app never sets it.
createClickConsumer({ shards }) is the Queue consumer. Each event is one D1 batch on the tenant's shard: a receipt per event ID with a fresh attempt token, admission against the calendar-month allowance in UTC, and the daily totals and the country, device, referrer, browser family, and OS family counts, each guarded in SQL by the admitted receipt of this attempt. A replay after a committed attempt changes nothing, and a failed batch rolls back completely. Invalid, test, and expired events (older than 48 hours) are acknowledged without counting. An event for an unbound shard or a tenant without a policy on its shard is retried; it never goes to another database. A link and day keep at most 50 referrer names; later ones count as other. An event without browser and OS fields (sent before analytics migration 0005_browser_os.sql) counts with no browser or OS row, so those breakdowns can sum to less than the total.
The consumer never reads or stores forward. With forward (a function) on createClickConsumer, it passes the admitted events of a batch that carry the field once, after their messages are acknowledged: skipped, duplicate, dropped, and retried events are never passed, and a failure is logged as click_forward_failed with a count and never retries a click. The dead-letter consumer stores events without forward, so a requeued click counts but is not forwarded. Before rolling back to a version without this field, clear the flag (UPDATE tenant_policy SET click_forwarding = 0) and wait 60 seconds: an older consumer drops events that carry it.
A tenant becomes active when routing and its analytics shard both hold its policy (projectPolicy, retryProjections). purgeExpired removes aggregates past the tenant's retention and receipts after 72 hours. The API adds GET /v1/links/:id/analytics (UTC days, clamped to retention), GET /v1/usage, and clicksLast30Days on listed links.
@flared/server/operations measures the installation for its operator; each edition sets its own thresholds and screens.
- The click consumer adds its counts for each UTC minute to
ingestion_minuteson the event's shard (analytics migration0004_ingestion_minutes.sql): counted, duplicate, dropped, and retried events, the largest lag from click to count, and the newest click time. The counts are written after the events commit, so a failed write loses counts, never clicks.readIngestionsums a window for each shard;purgeExpiredremoves minutes after 14 days. A shard without clicks has no recent minute, which alone does not mean the Queue is stuck. measureShardsrecords the size of each bound shard and its workspace count inanalytics_shard_samples(identity migration0010_operations.sql). D1 returns the database size with each query result, so no account credentials are needed.readShardProjectionsprojects each shard's size from its growth over a window; the edition chooses the window, the horizon, and the limits. Run it about once an hour.createDeadLetterConsumerconsumes the click dead-letter queue. It stores the validated event, or only the message ID for an invalid body, indead_letters.requeueDeadLettersends one stored event back to the click Queue; event deduplication keeps a counted click from counting twice, and an event older than 48 hours is not sent.purgeOperationsremoves samples after 90 days and dead letters after 7 days.
GET /v1/export/links (links:read), GET /v1/export/daily-totals, and GET /v1/export/daily-dimensions (analytics:read) return a workspace in pages: every link, active and disabled, and the daily totals and breakdowns within the retention window, in key order. GET /v1/export/links takes limit (1–1000, default 1000). Follow nextCursor until it is null. writeExport (@flared/client/export) writes the pages as one JSON document with the format flared.export/1, and linksCsv writes the links as CSV. flared export --out FILE uses them. No export file is stored on the server.
requestAccountDeletion (@flared/server/deletion) starts deleting the account: in one batch it records a tenant_deletions job for each workspace the user owns and deletes the user's sessions, API tokens, and connected-app grants. requestWorkspaceDeletion deletes one workspace and its bound tokens and grants; the user keeps the account and the other workspaces, so it refuses the user's last workspace. The application checks the session, a recent sign-in, and the typed confirmation first. From then on the API answers 409 ACCOUNT_DELETING for the workspace, no store receives the tenant's policy again, and @flared/ui/account/DeleteAccount.svelte and @flared/ui/workspace/DeleteWorkspace.svelte provide the confirmation forms. Identity migration 0013_deletion_scope.sql records the scope of each job and the workspace name for its emails. An account deletion sends its emails from its first job, and the completion email waits until the last job deletes the user.
runDeletions runs the job in resumable steps: the acceptance email, the end of the routing policy and every workspace domain (redirects stop), a 70-second wait past the redirect snapshots, an analytics tombstone and the shard rows, the links and request records, an optional extension for an edition's own records, and the identity records with the user. A completion email ends the job, and the contact address goes with it. Each email is sent at most once. Run runDeletions every few minutes and right after a request; a failed step waits 1, 2, 4, ... minutes. Late clicks for a tombstoned tenant are dropped. Slug reservations, hostname namespaces, and disabled domain rows stay, so no address is reused. cleanupDeletions removes the completed ledger after 90 days, and purgeExpired removes tombstones after 90 days. Deleting the workspace of a single-workspace installation closes it for good.
@flared/server/operator holds the operator's abuse actions. They act across tenants, so only an operator composition calls them; the caller authenticates the operator and keeps the audit record. Each action takes an OperatorPrincipal for its log line and is safe to repeat.
blockLinkandunblockLinksetlinks.blocked_atand an abuse category (routing migration0004_operator_blocks.sql). A blocked link answers 410 with a static blocked page, which links toreportUrlwhen the redirect handler has one. A block reaches every visitor within the 60-second snapshot bound; a cleared block takes effect on the next request. A block never changes the owner's on/off status: the owner can turn a blocked link off, but cannot edit it or turn it on (409LINK_BLOCKED). The API returns the block asblocked: { reason }on each link.suspendTenantandreinstateTenantsettenant_policy.suspended_atin identity (migration0009_tenant_suspension.sql) as the next policy revision. The routing projection carries it, so every link of the tenant stops; if the projection fails,retryProjectionscompletes it. A plan change keeps the suspension, and a reinstatement keeps each link block. While suspended, the API answers 403WORKSPACE_SUSPENDEDto every change; a session can still read, and end a token or connected app, and a token or connected app can only call/v1/export/*and/v1/me. The MCP endpoint refuses calls, and no new app can connect.requestDeletionanswerssuspendedand changes nothing until the tenant is reinstated; the job insert checks the suspension again in its batch, and a running deletion cannot be suspended.revokeCustomerAccessdeletes the sessions, API tokens, and connected-app grants of the tenant's members.
updatePolicy (@flared/server/tenancy) stores new limits as the next policy revision and projects them to routing and the tenant's analytics shard. A failed projection returns projected: false, and retryProjections finishes it later. Lower limits never disable existing links or domains; they block new ones. A self-hosted operator changes limits this way.
A click that arrives after the monthly allowance is full still redirects and is not recorded. The shard counts it as missing, with the time of the first one. GET /v1/usage returns the workspace ID and name, recorded clicks, missing clicks and since when, active links, custom domains, days of history, and a warning for each limit at 80% or 100%. flared usage, the MCP get_usage tool, @flared/ui/usage/UsageBanner.svelte, and @flared/ui/usage/UsageMeters.svelte show the same data.
Notices to the workspace owner use the identity notices outbox (@flared/server/notices). reconcileClickNotices records click warnings from each shard; run it every few minutes. The API records link and domain warnings after a creation, and reconcileLimitNotices catches the rest once a day, including after a lowered limit. Each notice has a dedupe key, so it happens once per threshold and month, or per threshold and policy revision. dispatchNotices emails pending notices to the owner's verified address, with the workspace name at sending time, through an EmailTransport and a template such as usageNoticeEmail. A failed send becomes delivery_unknown and is never resent. Without email, notices stay pending and the dashboard warning still shows. cleanupNotices deletes notices after 62 days.
@flared/server/auth/api-tokens wraps the Better Auth API-key plugin (createApiTokenPlugin). Tokens start with flr_, are stored only as a SHA-256 hash, are bound to one workspace, and allow 60 requests per minute. A user has at most 25 unexpired tokens (identity migration 0005). Never mount the plugin's /api/auth/api-key/* endpoints; create tokens only through the API.
createApi takes an authenticate function that returns a session or token principal. authenticateBearer reads Authorization: Bearer <token> and ignores cookies. A session holds every scope; a token needs the route's scope (links:read, links:write, analytics:read, domains:read, domains:write, usage:read), else 403 INSUFFICIENT_SCOPE. Each request also needs a workspace role that allows the scope: a member may use every scope except domains:write, and only the owner renames the workspace, else 403 OWNER_REQUIRED. A member's new token cannot hold domains:write. GET /v1/members lists the workspace's members and, for the owner, its open invites. With invites (InviteSettings: an email transport, the accept page URL, and the members page URL), the owner invites by email (POST /v1/invites), sends again with a new link (POST /v1/invites/:id/resend), and cancels (DELETE /v1/invites/:id). The token is stored only as a hash and travels in the link's fragment; an invite works once, for its address only, for 7 days. The member limit (memberLimit in the policy, 2 by default) counts members and open invites in the same statement that writes an invite. POST /v1/invites/lookup and POST /v1/invites/accept take the token from any workspace. The owner removes a member (DELETE /v1/members/:userId) and a member leaves (POST /v1/workspace/leave, with the shown workspace in X-Flared-Workspace); either ends that member's tokens and connected apps for the workspace only. The owner offers the workspace to a member (POST /v1/workspace/transfer with userId, a sign-in from the last 10 minutes, and the shown workspace); a new offer replaces the open one, and with invites the member gets an email. The member accepts (POST /v1/workspace/transfer/accept); one batch makes the member the owner and the owner a member, so the workspace always has one owner. The owner withdraws or the member declines with DELETE /v1/workspace/transfer. An offer ends after 7 days or when the member leaves. An edition adds its own rules to the same batch with transferOwnership and ownershipTransferStatements. The batch of each member change also writes a row to tenant_member_events (identity migration 0016): invites sent, sent again, cancelled, and accepted; removals, leaving, and account deletions; offers made, withdrawn, and declined; owner changes; and password reset links issued and used. A row holds the actor's and the member's user IDs and the invite ID, never an address or a token. listMemberEvents reads them for an operator, and deleteExpiredMemberEvents removes rows after 90 days; deleting the workspace removes its rows. An account deletion is refused while a workspace the person owns has other members (WORKSPACE_HAS_MEMBERS); a person who owns no workspace is deleted at once. Session writes need the exact app origin; token requests do not. GET /v1/me describes the calling credential and the workspace it acts in (ID, name, and role), and needs no scope. With publicApiUrl, GET /v1/openapi.json serves the OpenAPI 3.1 description (@flared/contracts/openapi) without a credential; tests check it against the routes and every response of the client tests. GET, POST /v1/tokens and DELETE /v1/tokens/:id accept sessions only. Creation needs a sign-in from the last 10 minutes (403 REAUTH_REQUIRED) and returns the secret once.
A link change or a click milestone stages an event in the same batch as the change (routing migration 0008, analytics migration 0006). The events are link.created, link.updated, link.disabled, link.enabled, and link.click_milestone; webhook.test goes to one endpoint only. With webhooks (WebhookSettings: the deployment secret, and background, such as ExecutionContext.waitUntil) on createApi, the owner manages endpoints through the session-only /v1/webhooks routes and @flared/ui/webhooks/WebhookList.svelte; without it, the routes answer 404. The policy's webhookEndpointLimit (identity migration 0020, 0 by default) caps the endpoints in the insert statement; 0 turns webhooks off. Endpoint secrets are derived from the deployment secret and shown once; a new deployment secret fails deliveries with secret_changed until each secret is rotated. @flared/server/webhooks sends signed requests (Standard Webhooks headers, HTTPS on port 443 to public hostnames only, no redirects, 10 seconds) at least once: runWebhookJob moves milestones from the shards (unless transferMilestones is false, when the automation job moves them) and sends retries with backoff, and cleanupWebhooks expires deliveries after 72 hours and deletes rows after 30 days. An edition can add its own transports (routing migration 0010): an endpoint row with another transport value and a target_key unique in the workspace gets the same events, retries, and plan rule, and WebhookSettings.transports (a WebhookTransport sender for each transport) sends its deliveries in one call a round. The /v1/webhooks routes and the endpoint limit see only http rows. A transport without a sender fails its deliveries with transport_missing; a sender that answers unauthorized turns off the workspace's endpoints on that transport, and target_unavailable turns off the one endpoint. integration.message is a targeted event that goes only to a transport endpoint.
REST hooks let a client such as Zapier subscribe to one event through the API (routing migration 0012). With WebhookSettings.hooks (allowedHosts, the hostnames a hook may target) and hookSender from @flared/server/webhooks/hooks under the resthook transport, a token, connected app, or session with links:read calls POST /v1/hooks with event and targetUrl and gets renewAt (7 days) and expiresAt (14 days); DELETE /v1/hooks/{id} ends one, a member's own or any for the owner, also while the workspace is suspended. Without hooks, both routes answer 404; the standalone app sets none. A hook needs webhookEndpointLimit above 0 but does not count toward it; a workspace holds at most 50 live hooks, and a subscribe by the same person with the same event and URL replaces the earlier hook. Each event goes to the target as one JSON object: the link fields, then clicks or previous, then event, eventId, and occurredAt, with a webhook-id header and no signature. A 410 answer turns the hook off. A hook gets no event at or after expiresAt, and cleanupWebhooks deletes ended hooks. Revoking the token or app that made a hook, removing its maker, or the maker leaving deletes it.
A template is one trigger (an end date set when it is applied, days after the link was created, a number of counted clicks in steps of 100, or a destination that stops loading), then up to five actions in a fixed order: change the destination, turn the link off, send an automation.triggered event to one webhook endpoint, post the same event to one transport endpoint, and email the workspace owner (routing migrations 0009 and 0010). The post action needs messages on AutomationFeatures; without it, templates cannot use it. The policy's automationLimit (identity migration 0021, 0 by default) caps the templates; 0 turns automations off, and due applications are then skipped. With automations (AutomationFeatures: email, destinationChecks, messages) on createApi, members list templates and apply them to links through GET /v1/automations, GET, PUT, and DELETE /v1/links/{id}/automations/{automationId}, and POST /v1/automations/{id}/links (up to 100 links); the owner creates, edits, and deletes templates through session-only routes. Without it, the routes answer 404. A link takes at most 5 templates. Each application runs once; applying it again re-arms it. @flared/server/automations has runAutomationJob, which moves click milestones, claims due applications, and runs them in one batch that changes the links and stages their webhook events (run the webhook job after it in the same invocation), and cleanupAutomations, which deletes run records after 90 days. With checks (DestinationCheckSettings: a per-run limit, the requests at once, the user agent, and the installation's hostnames), the job first checks the destinations of destination_down applications: each distinct destination once per run and at most once every 6 hours for each application, with GET, at most 3 requests through redirects, 10 seconds each, and at most 1 KB read. A failure is no answer, a timeout, 404, 410, or a 5xx other than a 503 Cloudflare challenge; two failures in a row of the same destination make the application due in the same run. Each request goes only to http or https on the default port, on a public hostname that is not one of the installation's hostnames or an active domain; otherwise the check records not_checkable, and applying such a template to a link with that destination answers INVALID_INPUT (field destination for one link, a failed result for each such link in a bulk apply). @flared/server/automations/notices has automationNoticeEmail for the email action.
GET /v1/icons/:hostname returns the icon of a link destination or referrer for the dashboard. It accepts a session only and stays out of the OpenAPI document. @flared/server/icons fetches /favicon.ico, then the icon links of the home page, over HTTPS to public host names only, with at most three redirects (each checked again), 3 seconds, and 100 KB, and keeps only bytes that are an ICO, PNG, GIF, JPEG, WebP, or SVG image. One store serves every workspace: a found icon is fetched again after 30 days, a site without one after 7 days, and a found icon stays when a later attempt finds nothing. Fetches from sites count against a per-workspace budget (icons.fetchesPerMinute, default 60); stored icons are always served. SVG is sent with Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox. r2IconStore keeps icons in an R2 bucket and cacheIconStore in the Workers Cache API. Without icons in createApi, every icon answers 404 and the dashboard shows a letter tile. The standalone app uses workersCacheIconStore() with no extra binding. LinkList and LinkAnalytics take an iconHref function for link rows and referrers.
GET /v1/links/:id/qr returns an SVG or PNG QR code of the short URL (format, size 128–2048, download=1), encoded by @flared/client/qr with lean-qr; the CLI uses the same encoder.
createApi takes optional domains: { provider, reservedHostnames }. A workspace adds a subdomain with POST /v1/domains within its domain_limit; GET /v1/domains lists active platform domains and the workspace's own. A DomainProvider (@flared/server/domains) attaches hostnames and returns evidence (waiting, verifying, ready, or failed with a code); only that evidence changes a domain's state, and only an active domain serves links. Without a provider, domains are listed and adding one answers 503 DOMAINS_UNAVAILABLE. An unverified claim expires after 7 days; removal stops the domain's links and keeps their slug reservations. recordDomainEvidence stores evidence from an edition's scheduled checks.
AI assistants such as ChatGPT, Claude, and Grok connect to a remote MCP endpoint, for example https://api.flared.page/mcp. The endpoint is an OAuth 2.1 resource server; it accepts no API tokens and no cookies.
@flared/server/oauth/provider:createOAuthServer(db, config)composes@better-auth/oauth-provider1.7.7 with@better-auth/mcpand@better-auth/cimdon a separate Better Auth instance. The sign-in proof guards refuse these plugins, so never add them to the sign-in instance. The issuer is the app origin and the endpoints live at its root. It supports the authorization code grant with PKCES256only, RFC 8707 resource binding, RFC 9207issin every authorization response, refresh tokens rotated on each use (a retry within 30 seconds receives the same response; reuse after that revokes the client's tokens for the user), and RFC 7009 revocation. Access tokens (flo_at_) live 1 hour and refresh tokens (flo_rt_) 30 days; both are opaque and stored as SHA-256 hashes. The grant records the signed-in user's workspace. PasscreateMetadataFetch()asfetchClientMetadata: it fetches client ID metadata documents over HTTPS and refuses redirects. On Cloudflare, also set theglobal_fetch_strictly_publiccompatibility flag.@flared/server/oauth:createOAuthRoutesserves the only routes that reach the provider:/.well-known/oauth-authorization-server,/oauth2/authorize,/oauth2/token,/oauth2/register,/oauth2/revoke,/oauth2/consent(POST, session cookie, exact app origin), and/oauth2/consent/request(what the consent page shows, with the person's active workspaces). An approval names the workspace (workspace_id) that the grant and its tokens are bound to. Every authorization shows the consent page, also for an app approved before.prompt,max_age, request objects, and DPoP are not supported. Open client registration needs asourceKeyfor the request and allows 10 registrations per source and 500 in total per hour. The application serves the sign-in page (loginPath) and the consent page (consentPath). After sign-in, send the browser toresumeAuthorizationPath(query)from@flared/contracts/oauth. The consent page also shows errors from the provider aserroranderror_descriptionquery parameters.@flared/server/mcp:createMcpEndpointchecks a presentOriginagainst the allowed list, the bearer token, its audience (the endpoint URL), the consent, and the workspace membership on every request. It answers 401 withWWW-Authenticate: Bearer resource_metadata="…"and 403insufficient_scopenaming the scopes that a tool call needs. It allows 60 calls per minute for each app and user. The tools arelist_links,get_link,get_link_qr,get_link_analytics,get_usage,list_domains,export_links,list_automations,create_link,import_links,update_link,disable_link,enable_link,apply_automation, andremove_automation; they callcreateApiin process with anoauthprincipal.import_linkstakesdryRunand up todefaultImportItemsPerRequest(3) items in one call, so assistants preview an import before they create the links.export_linkspages every link fromGET /v1/export/links, 50 by default and up to 100, and asks again with half as many links until the result is withinmcpExportResultBytes(48 KB); each link has its status and anitemthatimport_linkstakes as is.mcpServerCardbuilds a draft SEP-1649 server card for/.well-known/mcp/server-card.json.protectedResourceMetadatabuilds the RFC 9728 document to serve at/.well-known/oauth-protected-resourceand/.well-known/oauth-protected-resource/mcpon the endpoint host. The endpoint serves protocol 2026-07-28 in JSON mode and 2025-era clients statelessly.GET /v1/connected-appsandDELETE /v1/connected-apps/:clientIdlist and revoke apps for a session. Revocation deletes the consent and every token of the app in the workspace; the next MCP call fails.deleteExpiredOAuthRecordsremoves expired tokens and clients without a consent a week after registration.
Identity migration 0006_oauth.sql adds the provider tables and the request counters. tests/oauth.test.ts covers the flow under workerd and D1, including concurrent code redemption and refresh, refresh reuse, PKCE, redirect and resource checks, client ID metadata documents, revocation during a refresh, membership loss, scope challenges, and every tool.
The current sign-in proof storage integration is pinned to Better Auth 1.7.7 with the Email OTP and Magic Link plugins. Run bun run test:auth-probe before changing its library, schema or runtime dependencies.
The unmodified library fails three tested D1 invariants: wrong-attempt restoration overlapping resend, expiry deletion overlapping resend, and rejection exactly at expiry. installD1ProofGuards fixes only the verification storage layer, using atomic D1 claims and generation-bound restoration. The library retains hashing, code/token verification, attempt policy, accounts and sessions.
Callers must:
- Use the shared identity migration and adapter, without secondary storage, custom verification schema/identifier settings or verification hooks.
- Construct only the trusted pinned sign-in OTP, magic-link, username, and passkey plugins; build the passkey plugin with
createPasskeyPluginfrom@flared/server/auth/passkey. Plugin initialization hooks are a trusted composer concern; they cannot all be introspected by the guard. - Await the auth instance's
$context, then install guards before exposing any API. - Wrap each sign-in issuance/verification call in its own
withIdentityProofScope('issue' | 'verify', ...). Passkey options calls issue a challenge; passkey verification calls consume it. Scope state is isolated across simultaneous calls, including calls sharing an instance. - Expose only intended sign-in server methods through validated routes. Do not mount the generic library HTTP handler or expose password reset, email change, other OTP types or unscoped proof operations.
- Clean expired verification rows, including consumed tombstones, in bounded batches using an expiry predicate.
Each edition composes these shared modules in its own Worker and keeps only its sign-in method and policy.
@flared/server/auth/routes:createAuthRoutesserves/api/auth/session, sign-out, passkey sign-in, passkey registration, listing, renaming, deletion, and passkey reauthentication. The edition passes its sign-in routes and their methods, its auth instance, the app origin, and the table of passkey attempts (@flared/server/auth/limits). Every other path answers 404. Every POST needs the exact appOrigin, JSON, and at most 4 KB.@flared/server/web/authand@flared/server/web/api: the application side.forwardAuthRouteforwards a browser request with a fixed set of headers and the trusted client address;getAuthState,getPasskeys, and the API readers serve server-rendered pages.@flared/server/api/compose:apiAuthenticatorreads only the session cookie for the app's same-origin API, and onlyAuthorization: Bearerfor the token API.@flared/server/oauth/handler: the authorization server routes, the MCP endpoint, the protected resource metadata, the MCP server card, and the AI catalog, with the MCP resource URL from deployment configuration.@flared/server/account:POST /api/account/deleteandPOST /api/workspace/delete. The edition names the confirmation value of an account deletion and may refuse the deletion of a workspace for its own reasons. A workspace deletion needs the owner, the workspace name, and theX-Flared-Workspaceheader of the page.@flared/server/jobs:createJobLedgerrecords the outcome of each scheduled job in the edition's table.
A table name that an edition passes goes into SQL text, so it must match ^[a-z][a-z0-9_]{0,62}$. tests/shared-routes.test.ts covers these modules under workerd and D1.
createPasskeyPlugin(origin, rpName) pins @better-auth/passkey 1.7.7 to the configured app origin and its host as the relying party. It requires a discoverable credential and user verification. The library itself skips the user-verification check, so the plugin's hooks refuse a ceremony without it before anything is stored. Registration needs a session created in the last 10 minutes (freshAge). Library deletion needs only a session, so the composing application must check freshness before it deletes a passkey. confirmWithPasskey in @flared/ui/passkeys/client posts to /api/auth/reauth/passkey/options and /verify. The library signs in whoever owns the passkey, so createAuthRoutes refuses a passkey that does not belong to the signed-in user before verification. Identity migration 0003_passkey.sql adds the passkey table; deleting a user deletes their passkeys.
tests/passkey-probe.test.ts proves the plugin under workerd and D1 with a software authenticator: single-use challenges under concurrent verification, replay refusal, wrong origin and relying party, missing user verification, ownership on rename and delete, immediate effect of deletion, and the seven-day session. The library answers a registration with a wrong origin or relying party with 500, not 400.
The harness uses 2026-08-22 and nodejs_compat; this is the latest compatibility date supported by the pinned test runtime. The guarded probe passes 16 tests. FLARED_PROBE_BASELINE=1 bun run test:auth-probe deliberately reproduces the three failures against unmodified storage and exits nonzero; it is diagnostic, not the passing CI command.
The cloud email-code/magic-link composition is maintained separately. No email setup is required to run the core tests.
A standalone installation has one owner and can have members; each signs in with a username and password. No email, OAuth application, or Flared service is involved. apps/standalone composes these modules (see Standalone app).
@flared/server/auth/owner:createOwnerAuthbuilds Better Auth 1.7.7 with the username plugin and the shared passkey plugin. Email sign-up, sign-in, and reset stay off. The owner's email is the internal address<user id>@owner.invalid; it is never verified, shown, or sent to.createOwnerAuthRoutesaddsPOST /api/auth/sign-in/password,/api/auth/reauth/password, and/api/auth/password/changeto the shared routes. Passwords have 12 to 128 characters. Every credential failure answers 401INVALID_CREDENTIALS.- Members: with
invitesset to{ delivery: 'link', inviteUrl, resetUrl }oncreateApi,POST /v1/inviteswith{}returns the invite and its link once (url); the invite has no address and takes a place within the member limit.POST /api/auth/invite/lookupshows the workspace of an open link, andPOST /api/auth/invite/acceptwith the token, a username, and a password creates the account, adds the membership, records the event, and deletes the invite in one batch, then signs in. A link works once (404INVITE_NOT_FOUND); a taken username answers 409USERNAME_TAKEN. The owner creates a password reset link for a member withPOST /v1/members/:userId/password-reset(a sign-in from the last 10 minutes); it works once for 24 hours, and a new one replaces it.POST /api/auth/password/reset/lookupshows it, andPOST /api/auth/password/resetwith the token andnewPasswordsets the password, ends every session of the member, and signs in; API tokens and passkeys stay. A link counts only while the person is a member, so a removal or a change of owner ends it. These four routes spend the sign-in budget of the request source. Invite and reset links share the limit of 20 a workspace a day. Identity migration0017_member_links.sqlmakes the invite address optional and addsmember_password_resets. @flared/server/auth/attempts: each password route and setup reserves its budget inauth_attemptswith one conditional insert before any hash runs. Sign-in allows 10 attempts per source in 15 minutes and 100 in total per hour; reauthentication and password change 5 per user in 15 minutes; setup 5 per source in 15 minutes and 30 in total per hour.- Reauthentication takes only the password; the username comes from the session, so it cannot switch users. A password change needs a fresh sign-in and the current password, checked before the new one is hashed. It then ends every session of the owner and signs in again.
IdentityRule: every session reader passes its edition's rule. The cloud needs a verified email (verified-email). Standalone needs a username with a password account (username), the owner's or a member's.@flared/server/setup:POST /api/setuptakes theSETUP_SECRET, a username, a password, and a workspace name. It needs the exact app origin, JSON of at most 4 KB, the setup budget, and a secret of at least 16 bytes that differs from the auth secret; it compares the secret in constant time. The first proven request claims the installation in one batch with every value fixed. A retry with the secret, the same username, and the same password resumes the claim; anything else answers 409. The steps create the owner, the workspace, the app host as the default platform domain, and the policy projections; each step advances in the same batch as its records. Activation clears the stored hash. Setup never opens again after activation or deletion, whether or not the secret remains configured.fixedTenantIdoncreateApi,createRedirectHandler, andcreateClickConsumerrefuses any other tenant in a single-workspace installation, in addition to the database's one-tenant guard.
Identity migration 0011_standalone.sql adds the username columns, a unique password account per user, installation_setup, auth_attempts, passkey_attempts, job_runs, and owner_audit. The shared Drizzle schema writes the username columns on every new user, so apply this migration before running code that includes it. tests/standalone.test.ts covers setup claims and resumes under concurrency, the closed states, username sign-in outside any proof scope, the budgets, reauthentication, password change with a failed later step, the identity rules, OAuth consent, and the single-workspace binding.
apps/standalone is one Worker for a self-hosted installation: the SvelteKit app, the API, auth, OAuth, MCP, short-link redirects, the click Queue consumer, and the scheduled jobs. The root wrangler.jsonc configures it, so the repository root is the deployable unit. Its D1 bindings are IDENTITY, ROUTING, and ANALYTICS_1; the Queue is flared-clicks with the dead-letter queue flared-clicks-dlq. The secrets are in .dev.vars.example: SETUP_SECRET, and the optional APP_ORIGIN and AUTH_SECRET. SOURCE_URL sets the source code link in the footer; it defaults to this repository.
- Configuration.
APP_ORIGIN(anhttps:origin with no path) andAUTH_SECRET(32 to 256 characters) are optional. WithoutAPP_ORIGIN, the app origin is the one stored at setup; before setup, each request's own origin serves setup. WithoutAUTH_SECRET, the first request makes a random secret and stores it ininstallation_key(identity migration 0019). A value that is set but invalid gets an operator page that names the secret. The page suggests the address it was reached on and stores nothing. - Hosts. The app host serves the reserved paths:
/app,/setup,/api/*,/v1/*,/oauth2/*,/mcp,/healthz, and/.well-known/*. Every other path on it, and every path on any other host, is a short link. Another host never serves an auth route, a cookie, or an app page. - Credentials.
/api/v1/*takes only the session cookie and the exactOrigin;/v1/*takes only a bearer token;/mcptakes only an OAuth access token. A client address comes only fromcf-connecting-ip. - Setup gate. Until setup finishes, the app host serves only
/setup,POST /api/setup, their assets, and/healthz. After the owner deletes the workspace, the installation is closed: the API answers 410, pages say so, and no link opens. - Moving the app host. After
APP_ORIGINis set to another origin, the new host serves only sign-in until the owner signs in with the password there. That sign-in ends every session, OAuth grant, and passkey of the old origin in one batch and keeps API tokens. The old host keeps its short links. - Limits. The owner changes the workspace limits in Settings → Limits and usage with a fresh sign-in through
/api/settings/limits; a member reads them (403OWNER_REQUIREDon a change). The first limits are 10,000 active links, 50,000 recorded clicks a month, 30 days of history, 5 own domains, 10 people with open invites included, 10 webhook endpoints, and 25 automations; they are provisional until measured. - Members. The owner creates invite links and password reset links in Settings → Members.
/app/invitecreates the member's account from an invite link, and/app/resetsets a member's new password; both read the token from the fragment and remove it from the address bar. Members create and edit links, see analytics, and use tokens and connected apps; only the owner changes domains, the name, the limits, and members. See docs/self-hosting/members.md. - Webhooks. The owner manages endpoints in Settings → Webhooks. Endpoint secrets come from the auth secret. A change sends its first deliveries after the response with
ctx.waitUntil, also over MCP. The click consumer takes 3 clicks a batch, so one run stays within 50 D1 queries. See docs/self-hosting/webhooks.md. - Automations. The owner manages templates in Automations; members apply them from the Links page and a link's page. The API runs with
automations: { email: false, destinationChecks: true }, so a template has no email action and can wait for a destination that stops loading; the run at :30 checks up to 6 destinations. See docs/self-hosting/automations.md. - Jobs. The 10-minute cron retries policy projections, runs deletions, records click notices, samples the analytics database size, and sends webhook deliveries (25 a run, 5 at once). With k as the scheduled UTC minute divided by 10, it checks up to 3 waiting domains when k is even and runs up to 25 due automations when k is odd; at k = 3 (:30) the automation run first checks up to 6 destinations. The webhook job then waits for the automation run, so its events go out in the same run. Both turns stay within 50 D1 queries and 50 subrequests. The daily cron cleans up auth records, link creation keys, expired analytics, limit notices, deletions, operations records, webhook records, and automation runs. Each job records its outcome in
job_runs;/healthzanswers 200 while the 10-minute run finished in the last 30 minutes.
Scripts: bun run build builds the app, bun run build:dry-run also checks the Worker bundle, bun run test:standalone runs the Worker tests under workerd and D1, and bun run dev runs it locally with .dev.vars. bun run deploy applies the three migration sets to the remote databases, then deploys.
bun run owner:reset-password resets a forgotten owner password through the operator's Wrangler login; see docs/self-hosting/recovery.md. The self-hosting guides are in docs/self-hosting.
Not yet available: a labeled test click (the setup wizard counts the owner's first real click instead). Deploy on Cloudflare is not verified on a clean account yet.