-
Notifications
You must be signed in to change notification settings - Fork 0
Relay Auth, Invites, Store & Admin API
The relay control plane in this slice is split across three cooperating modules under relay/src/:
-
github-auth.mjsperforms GitHub device-flow OAuth, resolves the authenticated GitHub login, and converts raw access tokens into persisted token hashes. -
store.mjsowns the JSON store file format, atomic persistence, and the invite state machine: creation, quota checks, redemption, expiry, revocation, and exhaust accounting. -
admin.mjsexposes the HTTP admin surface: health, stats, invite revocation, and user removal. It is the place where store mutations are combined with the injectedpersistcallback.
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-auth.mjs implements the three GitHub OAuth steps plus local user upsert:
-
startDeviceFlow({ clientId, fetchFn, clientSecret })posts to GitHub’s device-code endpoint withclient_idand optionalclient_secret. It returns camel-caseddeviceCode,userCode,verificationUri,interval, andexpiresInSeconds. GitHub or transport errors becomeGithubAuthError. -
pollForToken({ clientId, deviceCode, interval, fetchFn, sleepFn, maxAttempts, clientSecret })polls the token endpoint. It treatsauthorization_pendingas “sleep and retry”;slow_downsleeps at the current delay and then adds 5 seconds to every subsequent poll. A real token returns{ accessToken }; other errors throw; exhaustingmaxAttemptsthrowsGithubAuthError('TIMEOUT'). -
fetchLogin({ accessToken, fetchFn })callsGET https://api.github.com/userwith the bearer token and returnsdata.login. -
hashToken(raw)returns the SHA-256 hex digest. This is the only token form that is persisted. -
upsertUser(store, login, rawToken, { now })finds a user bylogin. If present, it replacestokenHashand 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 }
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.
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 = 5DEFAULT_INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000DEFAULT_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.maxUsesThere 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:
-
UNKNOWNif no invite has that code. -
REVOKEDifrevokedis true. -
EXPIREDifexpiresAt <= now. -
EXHAUSTEDifuses >= 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 --> [*]
“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.
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 viawithDefaults. Unknown keys are preserved. If the file is missing (ENOENT) or the JSON is syntactically invalid, it returns a deep clone ofdefaults. Other read errors are rethrown. -
saveStore(file, data)creates parent directories recursively, writes to${file}.tmp, thenfs.renameSyncinto 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.
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]
Runtime notes for developers modifying this handler:
-
/admin/statsreportsinvitesTotal, which isstore.invites.length. It does not report active invites. Active counts only exist throughcountActiveInvites. -
hubis only read throughhub.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. Ifpersist()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/revokeis idempotent for a known code becauserevokeInvitesetsrevoked = truewithout checking whether it was already true. -
DELETE /admin/users/:loginremoves only invites whosehostLoginmatches 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.
The login path crosses module boundaries in a deliberate sequence:
- A caller, usually relay server bootstrap, calls
startDeviceFlow. - After the user authorizes GitHub, the caller calls
pollForTokenuntil it receives an access token or a typed error. - The caller calls
fetchLoginto resolve the token to a GitHub login. - The caller calls
upsertUser(store, login, rawToken), which hashes the token and mutatesstore.users. - The caller persists the store separately.
github-auth.mjsnever callssaveStore.
The invite path is split between host-side creation and redeem-side consumption:
- A host action calls
createInvite(store, hostLogin, options). Quota is enforced against active invites. The new invite is only in memory. - The caller persists and shares the
code. - A redeemer calls
redeemInvite(store, code, now). The function returns typedStoreErrorcodes for the UI or pairing layer to map. - The caller persists after a successful redemption.
The admin path is the orchestration layer:
-
createAdminHandlerreceives the same store object, the relay hub, the admin token, and a persistence callback. - For revoke, it calls
revokeInvite, thenpersist(). - For user deletion, it mutates
store.usersandstore.invites, thenpersist(). - For stats, it combines live hub stats with store counts but does not mutate the store.
Boundaries to preserve:
- Raw GitHub tokens must not be persisted.
hashTokenis the only conversion before storage, andupsertUseronly writestokenHash. - Store mutation and store persistence are separate concerns. New mutations must call
saveStoreorpersistat the correct boundary. - Invite activity is derived, not stored. Adding a
statusfield would duplicateisInviteActiveand 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.
-
loadStoretreats corrupt JSON as defaults. If you need recovery, backups, or migration versions, add them outside the existingwithDefaultsbehavior. - Admin routes fail closed when
adminTokenis unset./healthzis 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 noscopeparameter. - Add store fields by extending the
defaultsobject passed toloadStore.withDefaultsfills only missing top-level keys. - Change invite policy by changing the constants or by passing
ttlMs,maxUses,now, andquotatocreateInvite. Per-call options already exist. - Add error codes by extending
StoreErrororGithubAuthError, then mapping them at the call site. Admin only mapsUNKNOWNfor revoke. - Add admin routes by inserting a branch after the auth block, using the local
jsonhelper, and callingpersist()after any mutation. Public routes must be placed before the auth block only when intentional. - Swap persistence by replacing
loadStoreandsaveStorewhile keeping the store object shape and the adminpersistcallback contract. - Integrate live relay state by extending
hubwith methods beyondstats()and calling them from admin routes that must disconnect users or invalidate sessions.
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
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance