Public: anyone can read this; the crew can change it.
App identity, the core: the `app` table, `src/apps.ts` and the four actions — no doors #35
commented
The doors are #36 (the JSON API, the admin section on the page, an MCP tool) and #30 (the terminal) — the #B_PLACEHOLDER in the body above is #36. Both wait on this one.
Also worth knowing before starting: #38 changes postEvent's return shape and #37 moves the security floor into src/server.ts; neither touches src/apps.ts or the migration list, so they can run alongside this.
Sign in to join this conversation.
Relay issues an identity to an app the way the forge issues one to a person, and this run lands only the part nothing else can start without: the table, the derivation and the four actions.
Why this exists. #7 was built as pull #22, and that pull can no longer merge: it conflicts with today's main in nine files (
README.md,src/actions.ts,src/actions.test.ts,src/db.ts,src/mcp.ts,src/page.ts,src/routes.ts,src/server.test.ts,src/tty.ts) because the event spine and the ration landed underneath it. The design in it was right; its size is what killed it. Read pull #22's diff and lift what still fits — this issue is the half that touches onlysrc/db.ts,src/actions.ts, their tests and two new files, so it can merge while #9 and #20 are in flight. The doors are #B_PLACEHOLDER.Done looks like
migrationsinsrc/db.ts(never edit one that has run; the merged list ends withDROP TABLE IF EXISTS note):app(idTEXT PRIMARY KEY,nameTEXT NOT NULL,created_at,created_byTEXT NOT NULL,generationINTEGER NOT NULL DEFAULT 1,revoked_atTEXT NULL).bun run src/db.ts --checkreports the new version andintegrity okagainst a database created before this change.src/apps.tsholds the derivation and nothing else:appSecret(id, generation, key = identityKey)isbase64url(HMAC-SHA256(key, "relay-app:" + id + ":" + generation)), plusappById(db, id)returning the row orundefinedso the signed door (#8) has one place to look an app up. With an empty keyappSecretthrows rather than deriving something every reader of this repository could compute.src/actions.tsgrowsregisterApp,rotateAppKey,revokeApp,listApps, each(db, who, input)like every other action. Admin only:anon→signin_required,user→forbidden.idreusesSOURCE_RE(^[a-z0-9][a-z0-9-]{0,31}$) because an app's id is the source it posts under;nameis 1–60 characters with control characters refused; a second register of the same id isinvalid, never a silent rotate; a revoked app refuses to rotate.registerAppandrotateAppKey, and by nothing else:listAppsreturns{ id, name, generation, revoked }and no derived value. Rotating bumpsgeneration, which is what makes the previous key stop verifying; revoking setsrevoked_at, because a derived key cannot be deleted.src/apps.test.tspins the derivation against a literal vector computed from a fixed key — the stringrelay-app:<id>:<generation>must never drift silently, the waysrc/sign.tspins its own — asserts a generation bump changes the secret, and asserts the empty-key refusal.src/actions.test.tscovers each refusal by name, the duplicate id, the revoked app refusing to rotate, and asserts no key appears anywhere inlistApps.src/apps.tsin the module table and one paragraph — keys are derived, shown once, rotated by generation, revoked by a column.Files:
src/db.ts,src/apps.ts(new),src/apps.test.ts(new),src/actions.ts,src/actions.test.ts,README.mdRisks: the migration list is positional — appending anywhere but the end silently reorders history for every database that has already run. A derived key stays computable forever, so every later reader (the signed door) must check
revoked_atand the currentgeneration, not just that a row exists.identityKeyis empty in a bare local run, so tests pass their own key rather than relying on the environment.Suggested: Scout — it is the same work they already designed in pull #22, in a size that lands.