Flow ID
Passkey-first identity for Flow applications. One passkey bound to id.flow.industries, usable across Flow apps with audience-bound JWTs and optional Tempo signing.
Develop
cp .env.example .env
bun install
bun run dev
bun run dev is the only supported local application launcher. It starts the
API, dialog, playground, and landing page on the first free block of
four consecutive ports. Configure DATABASE_URL and BETTER_AUTH_SECRET in
.env first.
The launcher validates locally stored JWT signing keys before starting Auth.
If BETTER_AUTH_SECRET changed, only keys that can no longer be decrypted are
rotated so local session creation keeps working after environment changes.
It also refuses to start when migrations fail instead of serving against a
partially migrated schema.
Security headers
@flow-industries/id/start exports securityHeaders() and
withSecurityHeaders(response, options). Both generate CSP, HSTS, nosniff,
Referrer-Policy and Permissions-Policy. The default CSP permits same-origin
resources and denies framing; each app supplies the directives its resources
and embeds need.
import { withSecurityHeaders } from "@flow-industries/id/start";
const secured = withSecurityHeaders(response, {
csp: {
"connect-src": ["'self'", "https://id.flow.industries"],
"frame-src": ["https://id.flow.industries"],
},
});
Directive overrides replace one directive; null omits it. headers overrides
generated defaults, and reportOnly: true emits the generated CSP under
Content-Security-Policy-Report-Only. The response wrapper fills missing
headers while preserving existing route policies, cookies, status and body
stream. Compose a route's full CSP in csp before wrapping: an existing CSP
is retained as-is. Explicit Permissions-Policy overrides can retain features
such as microphone delegation on voice-enabled surfaces.
AT Protocol identity
Consumers discover the signed-in user's current PDS through
await flow.atproto.resolve({ freshness: "cached" }). Use "refresh" after an
endpoint or authorization failure, and call flow.atproto.invalidate() after
identity or migration events. Flow account switches, logout and the first-party
linking helpers invalidate automatically. Invalidation forces the next request
to refresh. Discovery uses the configured Auth host, cookie or Flow bearer
session, and canonical user ID; it never provisions an account.
The result includes the DID, verified display handle (or null), current PDS
origin, hosting ownership, lifecycle and authorization status. DID documents
are authoritative for the endpoint. No OAuth tokens or private keys are
returned. connected indicates an unexpired stored grant whose audience and
issuer match public resource metadata; it does not prove that a remote grant
has not since been revoked. Writes must obtain fresh AT OAuth authorization,
and must not forward old credentials to a migrated host or replay mutations
with unknown outcomes. AtprotoRequestError.code distinguishes sign-in,
missing identity, pending or failed provisioning, account changes, unsupported
DID methods, invalid identity and unavailable resolution.
ATPROTO_MANAGED_PDS_ORIGINS is an optional comma-separated list of public
HTTPS PDS origins owned by Flow, used only to classify current hosting.
ATPROTO_RESOLUTION_TTL_MS enables a server metadata cache keyed by DID and
mapping version, bounded to five minutes; unset or invalid values disable
caching. Authorization status is checked separately on each request. A cached
authorization failure forces one fresh DID resolution. Public discovery works
without OAuth credential configuration and reports reconnect_required.
Bluesky and other AT Protocol accounts can sign in to Flow ID or connect to an existing account. Flow user IDs remain stable; the provider DID identifies the connection. New accounts do not need an email, passkey, or wallet to enroll.
Set ATPROTO_PUBLIC_URL, ATPROTO_PRIVATE_KEY (an ES256 private JWK with a stable
kid), and ATPROTO_STORAGE_KEY (a base64-encoded 32-byte encryption key) to
enable the integration. Leave all three unset to disable it. Production uses
HTTPS client metadata at /api/auth/atproto/client-metadata.json and public keys
at /api/auth/atproto/jwks.json. Provider tokens and DPoP keys are encrypted in
Postgres and stay on the server. Back up the storage key with the database;
changing it requires re-encrypting stored credentials.
For local OAuth, set ATPROTO_PUBLIC_URL=http://127.0.0.1:8183 and launch
./dev.sh 8180. The hosted sign-in page uses http://localhost:8183 and the
launcher sets the WebAuthn relying party to localhost. Use the printed URLs;
existing passkeys remain bound to their original relying party.
Without an email provider configured, the local launcher also prints sign-in
codes in the browser console. This requires development mode, the launcher's
FLOW_DEV_OTP_CONSOLE=1 opt-in, and a loopback issuer; the API then binds only to
127.0.0.1. Production responses never include these codes.
AT Protocol requires an IP loopback callback URI. The development landing server
redirects loopback GET requests to its fixed localhost origin before handling
them, retaining the callback query so OAuth resumes with the same browser cookies
and session storage as sign-in. The SDK still exchanges the code using the
registered IP callback URI. If that port block is occupied, update
ATPROTO_PUBLIC_URL to the selected landing port and restart. Production clients
use HTTPS metadata and authenticate with the configured signing key.
Provider sign-in resumes the bound /authorize transaction. Flow installs a
first-party session, then returns the existing one-use PKCE code to the app.
Account linking returns to /account; no provider or Flow tokens travel in
redirect URLs or window messages.
The account connection view reads saved identities and credentials locally. It does not refresh provider tokens or resolve handles on page load; reconnect performs a new provider authorization when access needs to be restored.
Connected accounts
Better Auth owns the shared account model. External identities are keyed by
providerId and accountId, and link to the canonical Flow userId. For AT
Protocol these values are atproto and the verified DID. Handles are display
metadata, never an identity key. A Flow account can connect different providers
without adding provider-specific identity tables.
Provider IDs identify a trusted provider configuration: separate OIDC issuers
must use separate IDs, even if both run the same identity-server software.
Provider credentials and temporary authorization state live in the encrypted,
provider-scoped oauth_session and oauth_state stores. The AT SDK uses an
adapter over those stores; its DPoP and discovery behavior stays within the AT
integration. Provider tokens never become Flow session tokens.
Better Auth supports built-in social providers and its Generic OAuth plugin. Flow currently uses version 1.4.17, whose default OAuth enrollment requires an email and creates its own sessions. A new provider must supply a verified stable subject to Flow's shared account enrollment, then use Flow's audience-bound session issuance. Register usable sign-in providers explicitly; arbitrary account rows do not count as recovery methods. No new identity table is needed.
Automatic email-based account linking is disabled. Disconnect through Flow's account controls so credential cleanup and last-sign-in-method checks run in one transaction; the stock Better Auth unlink endpoint is blocked. Its account listing remains available and excludes credentials.
Migration 0040 preserves existing AT links and encrypted sessions, then removes
the earlier AT-only tables. It invalidates in-flight authorization state, so
unfinished attempts must restart. Deploy the complete revised Auth stack before
enabling AT production configuration. After this migration, the intermediate
AT-only builds from PRs #170–#172 are not rollback targets; use the pre-AT main
build or a compatible forward fix. Existing pre-AT Flow user/session writes
remain supported.
npm: @flow-industries/id
Installation
bun add @flow-industries/id viem
For Wagmi apps:
bun add @flow-industries/id wagmi viem @tanstack/react-query
Documentation
- Integration guide — server setup, canonical sign-in, React, and account navigation.
- Guest sessions — silent guest creation and preserving identity during signup.
- Action sessions — activity timing and the inline action widget.
The Auth pages in the separate documentation repository are archived. Use the guides here for the current SDK integration.
Sign-in and account pages
The SDK starts sign-in through your app's first-party /flow/session handler
and navigates to Flow ID's /authorize page. Completion returns to the saved
app path; read the new session after navigation instead of continuing after
await flow.login().
For a signed-in account that needs a passkey, call
flow.connectPasskey({ returnTo: "/", requirePrf: true }). It uses the same
first-party redirect, stays bound to the app’s current account, and creates its
first passkey on Flow ID. An existing registered passkey is reused. Set
requirePrf for encrypted storage so unsupported new credentials are refused
before attachment. After returning, the SDK refreshes the connected credential;
the app must still evaluate actual PRF with a new user gesture before opening an
encrypted vault. The method never resolves in the document that started it.
ProfileButton opens the shared account dropdown, with saved accounts, account
switching, add-account, management, and sign-out actions. flow.openAccount()
opens /account in a normal new tab.
Both menus let signed-in users choose Online, Idle, Busy, or Invisible. The
selected option is the saved preference; the avatar indicator shows effective
availability, including automatic idle and derived offline. Busy does not alter
notifications, and Invisible appears offline to other users. A failed save keeps
the last confirmed preference and shows an inline retry. The profile button uses
its existing Flow presence controller; the account page uses the same controller
with its issuer cookie session.
Practice can include a currently occupied Game room alongside its opt-in subject.
Room visibility is projected independently; the combined activity expires at the
earlier claim/room deadline and the next refresh can retain practice without a stale room.
The source app stays loaded and refreshes its session on refocus. Sign-in and
own-account management no longer use an embedded dialog. The remaining dialog supports signing, public profiles, and widgets. SDK ConnectResponse remains a deprecated type alias for source compatibility; sign-in itself returns through the first-party callback. See the
integration guide for server wiring and migration.
Public profile overlays
import { closeProfile, openProfile } from "@flow-industries/id";
import { isValidUsername } from "@flow-industries/id/usernames";
if (isValidUsername(username)) {
openProfile(username, { onClose: () => resumeInput() });
}
closeProfile();
@flow-industries/id/usernames also exports USERNAME_REGEX and
MAX_USERNAME_LENGTH. It has no browser or server dependencies.
OpenProfileOptions.onClose?: () => void fires once after the overlay stops
accepting input, before its exit animation finishes. It covers the close button,
backdrop, Escape, closeProfile(), and sign-in/sign-out handoffs. The latest
successful openProfile call owns the callback, even when switching profiles
while open; an omitted callback clears the previous one. Invalid usernames and
SSR return false without replacing it. Closing an already closed overlay is a
no-op. A callback may open another profile.
React callers pass the same option to
useOpenProfile({ onClose: () => resumeInput() }) from
@flow-industries/id/react; the callback is captured when its returned opener
is called. The overlay belongs to the page and survives component unmounts.
Open from the document that owns the full viewport. Canvas hosts must release
pointer lock before opening, then use onClose to restore their input state.
License
MIT
Migration compatibility checks
bun run db:check regenerates into a temporary directory and refuses schema drift or edits
to previously committed SQL. CI compares against the PR base or previous main commit.
bun run test:migrations exercises the committed chain, the supported previous schema,
current projections, older writes after upgrade, and deliberate missing-migration failures.
MIGRATION_TEST_DATABASE_URL=postgres://... bun scripts/postgres-acceptance.ts additionally
creates a unique disposable database on a test PostgreSQL server, runs the real migration
entrypoint, rejects a changed applied hash, and proves a failing final migration rolls back
both its schema changes and ledger entry. The test deletes only its own generated database.
The initial compatibility baseline is source 1bda372b69e2f66a3dc0cbb244bcbe7cb858f493 (schema through 0035).
The tests freeze representative older SQL reads/writes; they do not boot every historical
application version. Advance this baseline deliberately when retiring support, and extend
the fixtures when changing a persisted contract. Migration failures block the image build;
production's PreSync job also refuses an applied ledger that is not an exact committed prefix.
Application rollback keeps the expanded schema. Pin the latest verified migration image via
Mesh's migrate.image while restoring the previous compatible application image. Do not run
down migrations or point an older migration image at a newer ledger: that is refused. A
destructive contract migration needs an explicit support-boundary change after older writers
and browser clients have drained.
Versioned consumer contracts
bun run contracts:generate produces the published
@flow-industries/id/contracts/v1/reports.json and openapi.json artifacts from
the same schemas used by the XP, action, presence and position handlers.
bun run contracts:check rejects stale output and incompatible changes to the
existing v1 request schema against CONTRACT_BASE_REF (default origin/main).
Keep existing v1 fields compatible; a breaking boundary needs a new version and
an overlap plan for deployed consumers.
Trusted service consumers can call createReportsApi(host, serviceToken) from
@flow-industries/id. The returned function accepts the operation name and its
typed request, validates both the outgoing request and successful response, and
throws ReportRequestError for non-success responses. Use the XP reporting
credential for action/XP operations and the room credential for presence/position;
never ship either service credential to a browser.
bun run test:sdk-package builds, packs and installs the actual SDK artifact in
a disposable consumer, checks public imports and declarations, and proves a
removed contract export fails. Producer tests execute the typed client against
real report handlers and validate minted guest/full session and JWT shapes.
CI gates both container and package publication on these checks.
The current OpenAPI artifact describes the service report boundary. The remaining account, room management, study, settings, session and passkey HTTP operations still need complete request/response descriptions and generated client coverage before the full HTTP contract work is complete.
Server deployments may set FLOW_ID_API_URL to a trusted internal HTTP(S) origin for API and JWKS requests, or pass apiUrl to server session/verification helpers. issuerUrl remains the public JWT issuer and browser navigation origin. The transport URL must contain no credentials, path, query, or fragment.
Server tracing
Register a NodeTracerProvider with W3CTraceContextPropagator before serving
requests, and set OTEL_EXPORTER_OTLP_ENDPOINT. The helpers from
@flow-industries/id/start then continue W3C trace context across HTTP calls:
import { traceRequest, tracedFetch } from "@flow-industries/id/start";
const fetch = (request: Request) => traceRequest(request, () =>
tracedFetch("https://service.example/api/status"),
);
traceRequest starts a SERVER span from the inbound headers, excluding
GET/HEAD /health probes. tracedFetch
starts a CLIENT span and injects its traceparent and tracestate, preserving
request bodies, credentials, signals and header overrides. Pass a custom
transport as its third argument to retain transport-specific restrictions.
Both helpers record only HTTP method and status; they never capture URLs,
headers, bodies or exception messages. Without the endpoint environment
variable they delegate without adding spans or headers.
The server session/JWKS helpers and typed JSON clients use this transport
automatically. withTraceContext(headers, callback) continues inbound context
when a nested session helper has no enclosing span; extractTraceContext(headers)
provides an isolated parent for an application's existing request span. Browser
clients retain their native fetch behavior and do not establish RUM trace roots.
PDS discovery requires explicit nonnegative integer budgets in
ATPROTO_RESOLUTION_USER_HOURLY_CAP and
ATPROTO_RESOLUTION_GLOBAL_HOURLY_CAP. Unset, invalid or zero budgets refuse
outbound discovery with HTTP 429 and retryable RESOLUTION_UNAVAILABLE. Every discovery
request, including cached reads and forced refreshes, consumes durable
per-user and fleet-wide hourly admission before any provider request. Failed
outbound attempts retain admission consumption; counters use the existing
bounded verification retention and canonical account erasure mechanism.
These caps bound request admission across replicas; operators must choose
budgets appropriate to replica count, outbound latency and capacity.
Daily check-in study activities
Study subjects default to trackingMode: "duration"; existing rows and clients keep that default. Create and update accept "duration" | "check-in". The subjects response advertises supported trackingModes, including on empty accounts. Switching keeps the subject ID, finished durations and daily history. A running timer refuses a mode change with 409 active_timer; check-in subjects cannot start duration timers or become the game's duration fallback.
GET /api/study/check-ins?subject=<id>&from=YYYY-MM-DD&to=YYYY-MM-DD reads an inclusive UTC window of at most 366 days, including archived subjects. days contains only recorded { date, completed, revision } rows; an absent day is unchecked at revision zero. PUT /api/study/subjects/:id/check-ins/:date takes { completed, expectedRevision } and returns { subjectId, date, completed, revision }. Writes allow backfill before subject creation but refuse invalid or future UTC dates, archived subjects and duration mode. Stale revisions answer 409 revision_conflict; the immediately previous revision can replay the same completion state without advancing the revision. Every recorded row, including unchecked rows, counts as history for archive-on-delete and is included in account export. Ownership always comes from the authenticated caller. Check-ins award no duration or XP; finished duration summaries continue to include historical time across mode switches.
Release in dependency order: apply the additive migration and deploy Auth API first, publish the SDK second, then deploy Time's capability-gated UI. Generate and validate migrations locally; production migration follows the existing release approval procedure. Rollback may hide the UI or disable further check-in writes, while preserving tracking_mode, check-in rows and all action history. Do not drop or convert those records during rollback.
New daily rows, including initial unchecked days, use persisted hourly admission ceilings shared across activities and Auth replicas: STUDY_CHECK_IN_USER_HOURLY_CAP defaults to 600 per account and STUDY_CHECK_IN_GLOBAL_HOURLY_CAP to 20,000 fleet-wide. Zero disables new rows; exhausted admission returns 429 rate_limited. Existing-day edits and idempotent retries remain available at the ceiling. Buckets use server request time, so choosing older dates cannot bypass the quota; historical backfill remains allowed. These limits bound growth rate, not lifetime storage.
The PostgreSQL race acceptance check targets a disposable local database only: DATABASE_URL=<local-test-url> MIGRATION_TEST_DATABASE_URL=<local-test-url> bun scripts/time-checkins-postgres-acceptance.ts. It applies committed migrations, verifies old-client writes and races timers, switches, toggles and deletion. SDK version 0.28.0 prepares publication through the existing merge workflow; the migration-containing release remains gated before production rollout.
Bun SSR integration
@flow-industries/id/start/bun provides server-only static responses and a small
request harness. It has no React or TanStack runtime imports. Applications choose
which paths are files, their cache/security headers, and their SSR handler:
import {
createSsrServer,
installGracefulShutdown,
safeRelativePath,
} from "@flow-industries/id/start/bun";
const app = createSsrServer({
health: { readiness: async () => true },
staticAsset: (_request, url) => {
if (!url.pathname.startsWith("/assets/")) return null;
return {
directory: "./dist/client",
path: safeRelativePath(url.pathname) ?? "",
headers: { "Cache-Control": "public, max-age=31536000, immutable" },
};
},
ssrHandler: (request) => handler.fetch(request),
});
const server = Bun.serve({
port: 3000,
async fetch(request, server) {
const response = await app.fetch(request, server);
if (!response) throw new Error("Expected an HTTP response");
return response;
},
});
installGracefulShutdown(server, { onShutdown: () => app.drain() });
Routing checks drain, optional health, before, selected static files, then SSR.
before returns null to continue, a response to stop, or undefined after a
WebSocket upgrade. Apps with different probe/origin ordering can omit health
and handle it in before. transformResponse applies app policy to every HTTP
response; observe wraps handling for the app's tracing and access logging.
Neither hook receives implicit cache or security policy.
serveStaticFile(request, { directory, path, headers, contentType, conditional })
is available separately. It serves only GET/HEAD regular files confined to the
selected directory, preserves caller headers and supports Last-Modified/304.
Set conditional: false for unconditional responses. A missing selected file
returns 404 from the harness; fallthrough: true instead continues to SSR.
safeRelativePath decodes a URL path exactly once; pass its decoded result to
serveStaticFile, which also refuses relative traversal and symlink escapes.
drain() is idempotent, rejects new requests and waits for admitted handlers and
onDrain. Bun's server.stop(false) remains responsible for active response
streams, and installGracefulShutdown supplies the shutdown deadline. The app
continues to own WebSocket and worker cleanup.
For multi-origin session routes, pass audience: (request) => selectedOrigin to
flowSessionHandlers and resolveFlowSessionRequest from start/server.
Keep the request-host allowlist in the app, and wrap the resolver in an app-source
server function when passing options. readSessionCookies(header, audience)
from /server extracts the exact cookie names without verifying their values.
createFocusApi returns CompleteFocusApi, whose checkIns and setCheckIn
methods are required. FocusApi retains optional methods for custom adapters.
createMeditationApi(host, getToken) from the root or /focus reads finished
Meditation summary(range?) and sessions({ before?, limit? }?) through the same
JSON transport and StudyRequestError as study. Its cursor stays opaque, and it
does not change study-only summaries or create a study subject.
rooms.mine() returns MyRoomSummary entries with the caller's effective
viewerRole, including current global staff roles. The field is optional to
represent older issuers; absence does not grant management permission. Mutations
continue to check current server-side authorization. Public room and room-event
wire types are exported from the package root.
User identity in request traces
Successful caller resolution, JWT verification, refresh and Better Auth session hooks record an
auth.user.verified child span with only user.id. Separate identity spans preserve account switches
and multiple verified users in one request. The ID comes from verified session/token state, never
X-Flow-User-Id, a profile target, request body, or decoded unsigned JWT. Profile display metadata is
resolved separately at read time; credentials and profile fields are not copied into these spans.
No identity span is emitted without an active recording request span. Old traces and requests that do
not resolve a user remain unrecorded. No SDK release or database migration is needed.