Skip to content

Relay Auth, Invites, Store & Admin API

dazeb edited this page Sep 17, 2026 · 2 revisions

Relay Auth, Invites, Store & Admin API

The relay control plane in this slice is split across three cooperating modules under relay/src/:

  • github-auth.mjs performs GitHub device-flow OAuth, resolves the authenticated GitHub login, and converts raw access tokens into persisted token hashes.
  • store.mjs owns the JSON store file format, atomic persistence, and the invite state machine: creation, quota checks, redemption, expiry, revocation, and exhaust accounting.
  • admin.mjs exposes the HTTP admin surface: health, stats, invite revocation, and user removal. It is the place where store mutations are combined with the injected persist callback.

The central architectural rule is that store mutations are in-memory object operations. createInvite, redeemInvite, revokeInvite, upsertUser, and admin user deletion mutate arrays and return or throw. They do not write the store file themselves. Callers own durability by calling saveStore or by providing the admin handler’s persist function. A missing or default persist means mutations survive only until the process restarts.

GitHub device-flow authentication

github-auth.mjs implements the three GitHub OAuth steps plus local user upsert:

  1. startDeviceFlow({ clientId, fetchFn, clientSecret }) posts to GitHub’s device-code endpoint with client_id and optional client_secret. It returns camel-cased deviceCode, userCode, verificationUri, interval, and expiresInSeconds. GitHub or transport errors become GithubAuthError.
  2. pollForToken({ clientId, deviceCode, interval, fetchFn, sleepFn, maxAttempts, clientSecret }) polls the token endpoint. It treats authorization_pending as “sleep and retry”; slow_down sleeps at the current delay and then adds 5 seconds to every subsequent poll. A real token returns { accessToken }; other errors throw; exhausting maxAttempts throws GithubAuthError('TIMEOUT').
  3. fetchLogin({ accessToken, fetchFn }) calls GET https://api.github.com/user with the bearer token and returns data.login.
  4. hashToken(raw) returns the SHA-256 hex digest. This is the only token form that is persisted.
  5. upsertUser(store, login, rawToken, { now }) finds a user by login. If present, it replaces tokenHash and preserves the existing record. If absent, it pushes { login, tokenHash, createdAt: now }. It does not persist the store.

Both network and sleep are injectable on the GitHub functions, which is what makes the device flow testable without real GitHub calls or real timers.

sequenceDiagram
  participant Caller
  participant Auth as github-auth.mjs
  participant GitHub
  participant Store as in-memory store

  Caller->>Auth: startDeviceFlow({ clientId, clientSecret? })
  Auth->>GitHub: POST /login/device/code
  GitHub-->>Auth: device_code / user_code / interval
  Auth-->>Caller: deviceCode / userCode / verificationUri

  Caller->>Auth: pollForToken({ deviceCode, interval })
  loop until access_token or maxAttempts
    Auth->>GitHub: POST /login/oauth/access_token
    GitHub-->>Auth: authorization_pending / slow_down / access_token / error
    Auth->>Auth: sleep(delay); slow_down adds 5000ms
  end
  Auth-->>Caller: accessToken or GithubAuthError

  Caller->>Auth: fetchLogin({ accessToken })
  Auth->>GitHub: GET /user
  GitHub-->>Auth: login
  Auth-->>Caller: login

  Caller->>Auth: upsertUser(store, login, accessToken)
  Auth->>Auth: hashToken(accessToken)
  Auth->>Store: update or push { login, tokenHash, createdAt }
Loading

The key boundary is at upsertUser: raw access tokens are used only long enough to compute a hash. The store never receives the raw token. Durability is one level up, so the caller must persist after upsertUser if it wants the user record to survive restart.

Invite lifecycle, quotas and state

store.mjs models invites as plain objects with this state:

{
  code: string,
  hostLogin: string,
  expiresAt: number,
  maxUses: number,
  uses: number,
  revoked: boolean
}

Default policy is defined by constants:

  • DEFAULT_ACTIVE_INVITE_QUOTA = 5
  • DEFAULT_INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000
  • DEFAULT_INVITE_MAX_USES = 1

Because the default maxUses is 1, a fresh invite is single-use unless the caller passes a higher maxUses. isInviteActive(invite, now) is the derived source of truth:

!invite.revoked && invite.expiresAt > now && invite.uses < invite.maxUses

There is no background expiry timer. Expiry is lazy: every redemption, quota count, or explicit isInviteActive call evaluates now against expiresAt. This means an expired invite remains in store.invites until explicitly removed by user deletion; it simply stops being active and stops counting toward quota.

countActiveInvites(store, hostLogin, now) filters by hostLogin and the active predicate. createInvite(store, hostLogin, { ttlMs, maxUses, now, quota }) throws StoreError('QUOTA') when the host already has >= quota active invites. Otherwise it mints an 8-character crypto-random alphanumeric code, sets expiresAt = now + ttlMs, uses = 0, revoked = false, pushes the invite, and returns it.

redeemInvite(store, code, now) checks in this exact error order:

  1. UNKNOWN if no invite has that code.
  2. REVOKED if revoked is true.
  3. EXPIRED if expiresAt <= now.
  4. EXHAUSTED if uses >= maxUses.

Only after those checks does it increment uses and return the invite. revokeInvite(store, code) throws UNKNOWN for a missing code; otherwise it sets revoked = true and returns the invite. Revoking an already-revoked invite is idempotent from the caller’s perspective because the invite is found and remains revoked.

stateDiagram-v2
  [*] --> Active: createInvite
  Active --> Active: redeemInvite / uses < maxUses
  Active --> Exhausted: redeemInvite / uses == maxUses
  Active --> Revoked: revokeInvite
  Active --> Expired: now >= expiresAt
  Exhausted --> [*]
  Revoked --> [*]
  Expired --> [*]
Loading

“Active” here means isInviteActive returns true. “Expired” is not a stored flag; it is derived from expiresAt and now. Once an invite is revoked, exhausted, or expired, none of these functions provide a reactivation path. The extension point is to mint a new invite or add explicit store logic for reactivation.

Persistent store

store.mjs also owns the JSON file store. Two functions define the persistence boundary:

  • loadStore(file, defaults) reads UTF-8 JSON, parses it, and fills in any missing top-level keys via withDefaults. Unknown keys are preserved. If the file is missing (ENOENT) or the JSON is syntactically invalid, it returns a deep clone of defaults. Other read errors are rethrown.
  • saveStore(file, data) creates parent directories recursively, writes to ${file}.tmp, then fs.renameSync into place. A crash before the rename leaves the previous on-disk file intact. The implementation does not add file locking or fsync.

The important failure boundary is that an unparseable store file is treated as “start from defaults.” That is tolerant but destructive: the next saveStore will overwrite the corrupt file with defaults. There is no backup or migration version field in this slice. Schema evolution therefore happens by passing a larger defaults object to loadStore; withDefaults only fills missing keys, it does not transform existing values.

The store object is expected to contain at least users and invites arrays. github-auth.upsertUser writes users. store.mjs writes invites. Admin stats reads both lengths. No function in these files writes the store automatically after mutation.

Admin HTTP surface

createAdminHandler({ store, hub, adminToken, persist = () => {} }) returns a (req, res) handler. The handler uses new URL(req.url, 'http://localhost'), a local JSON response helper, and a Bearer-token check:

/^Bearer\s+(.+)$/.exec(req.headers.authorization)

Authorization is exact string comparison against adminToken. If adminToken is falsy, every /admin/* route returns 401. /healthz is handled before the /admin/ prefix check and is public. Any path that does not start with /admin/ returns 404 NOT-FOUND. Unmatched admin methods or paths also fall through to 404, not 405.

Method Path Auth Behavior
GET /healthz none 200 { ok: true }
GET /admin/stats Bearer hub.stats() plus users and invitesTotal from the store
POST /admin/invites/:code/revoke Bearer Decodes code, calls revokeInvite, calls persist(), returns { ok: true, code, revoked: true }; unknown code returns 404 UNKNOWN
DELETE /admin/users/:login Bearer Removes the user, removes all invites whose hostLogin matches, calls persist(), returns { ok: true, removed, removedInvites }
any any other — 404 NOT-FOUND
flowchart TD
  A[HTTP request] --> B{GET /healthz?}
  B -- yes --> H[200 ok:true]
  B -- no --> C{path starts /admin/?}
  C -- no --> N1[404 NOT-FOUND]
  C -- yes --> D{authorized Bearer?}
  D -- no --> U[401 UNAUTHORIZED]
  D -- yes --> E{route}
  E -- GET /admin/stats --> S[hub.stats + store counts]
  E -- POST /admin/invites/:code/revoke --> R[revokeInvite + persist]
  E -- DELETE /admin/users/:login --> X[remove user and hosted invites + persist]
  E -- other --> N2[404 NOT-FOUND]
Loading

Runtime notes for developers modifying this handler:

  • /admin/stats reports invitesTotal, which is store.invites.length. It does not report active invites. Active counts only exist through countActiveInvites.
  • hub is only read through hub.stats() in this snippet. User deletion does not visibly disconnect relay clients or invalidate live sessions; that integration would require additional hub methods not present here.
  • persist() is called after the in-memory mutation. If persist() throws, the mutation is already applied in memory and the handler does not roll it back or convert the error to JSON. An outer server error boundary would need to handle it.
  • POST /admin/invites/:code/revoke is idempotent for a known code because revokeInvite sets revoked = true without checking whether it was already true.
  • DELETE /admin/users/:login removes only invites whose hostLogin matches the deleted login. It does not remove users or invites by any other relationship.
  • There is no request-body parsing in this handler. These routes are parameterized by path only.
  • The visible security controls are the public /healthz, fail-closed admin-token check, and exact Bearer comparison. Rate limiting, audit logging, CORS handling, and constant-time comparison are not implemented in these three files.

How the modules collaborate

The login path crosses module boundaries in a deliberate sequence:

  1. A caller, usually relay server bootstrap, calls startDeviceFlow.
  2. After the user authorizes GitHub, the caller calls pollForToken until it receives an access token or a typed error.
  3. The caller calls fetchLogin to resolve the token to a GitHub login.
  4. The caller calls upsertUser(store, login, rawToken), which hashes the token and mutates store.users.
  5. The caller persists the store separately. github-auth.mjs never calls saveStore.

The invite path is split between host-side creation and redeem-side consumption:

  1. A host action calls createInvite(store, hostLogin, options). Quota is enforced against active invites. The new invite is only in memory.
  2. The caller persists and shares the code.
  3. A redeemer calls redeemInvite(store, code, now). The function returns typed StoreError codes for the UI or pairing layer to map.
  4. The caller persists after a successful redemption.

The admin path is the orchestration layer:

  1. createAdminHandler receives the same store object, the relay hub, the admin token, and a persistence callback.
  2. For revoke, it calls revokeInvite, then persist().
  3. For user deletion, it mutates store.users and store.invites, then persist().
  4. For stats, it combines live hub stats with store counts but does not mutate the store.

Boundaries and extension points

Boundaries to preserve:

  • Raw GitHub tokens must not be persisted. hashToken is the only conversion before storage, and upsertUser only writes tokenHash.
  • Store mutation and store persistence are separate concerns. New mutations must call saveStore or persist at the correct boundary.
  • Invite activity is derived, not stored. Adding a status field would duplicate isInviteActive and risk divergence unless all readers are updated.
  • Quota counts only active invites. Expired, revoked, and exhausted invites remain in the array but do not block new invites.
  • loadStore treats corrupt JSON as defaults. If you need recovery, backups, or migration versions, add them outside the existing withDefaults behavior.
  • Admin routes fail closed when adminToken is unset. /healthz is intentionally public; anything under /admin/ must stay behind the Bearer check unless deliberately changed.

Likely extension points:

  • Add GitHub scopes by extending the device-code request body in startDeviceFlow; the current implementation sends no scope parameter.
  • Add store fields by extending the defaults object passed to loadStore. withDefaults fills only missing top-level keys.
  • Change invite policy by changing the constants or by passing ttlMs, maxUses, now, and quota to createInvite. Per-call options already exist.
  • Add error codes by extending StoreError or GithubAuthError, then mapping them at the call site. Admin only maps UNKNOWN for revoke.
  • Add admin routes by inserting a branch after the auth block, using the local json helper, and calling persist() after any mutation. Public routes must be placed before the auth block only when intentional.
  • Swap persistence by replacing loadStore and saveStore while keeping the store object shape and the admin persist callback contract.
  • Integrate live relay state by extending hub with methods beyond stats() and calling them from admin routes that must disconnect users or invalidate sessions.

Limits of this page

The provided source snippets cover only relay/src/github-auth.mjs, relay/src/store.mjs, and relay/src/admin.mjs. The relay hub implementation, HTTP server bootstrap, production store path, exact default store object, login route, and invite creation/redeem call sites are not included. Statements about hub are limited to hub.stats() as used by the admin handler.

Sources: relay/src/github-auth.mjs, relay/src/store.mjs, relay/src/admin.mjs

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally