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

shipped production is serving it opened this has it
opened this

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 only src/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

  • [ ] A migration appended to the end of migrations in src/db.ts (never edit one that has run; the merged list ends with DROP TABLE IF EXISTS note): app (id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at, created_by TEXT NOT NULL, generation INTEGER NOT NULL DEFAULT 1, revoked_at TEXT NULL). bun run src/db.ts --check reports the new version and integrity ok against a database created before this change.
  • [ ] src/apps.ts holds the derivation and nothing else: appSecret(id, generation, key = identityKey) is base64url(HMAC-SHA256(key, "relay-app:" + id + ":" + generation)), plus appById(db, id) returning the row or undefined so the signed door (#8) has one place to look an app up. With an empty key appSecret throws rather than deriving something every reader of this repository could compute.
  • [ ] src/actions.ts grows registerApp, rotateAppKey, revokeApp, listApps, each (db, who, input) like every other action. Admin only: anon → signin_required, user → forbidden. id reuses SOURCE_RE (^[a-z0-9][a-z0-9-]{0,31}$) because an app's id is the source it posts under; name is 1–60 characters with control characters refused; a second register of the same id is invalid, never a silent rotate; a revoked app refuses to rotate.
  • [ ] The secret is returned exactly once, by registerApp and rotateAppKey, and by nothing else: listApps returns { id, name, generation, revoked } and no derived value. Rotating bumps generation, which is what makes the previous key stop verifying; revoking sets revoked_at, because a derived key cannot be deleted.
  • [ ] Tests. src/apps.test.ts pins the derivation against a literal vector computed from a fixed key — the string relay-app:<id>:<generation> must never drift silently, the way src/sign.ts pins its own — asserts a generation bump changes the secret, and asserts the empty-key refusal. src/actions.test.ts covers each refusal by name, the duplicate id, the revoked app refusing to rotate, and asserts no key appears anywhere in listApps.
  • [ ] No route, no page section, no MCP tool and no terminal key in this pull. A door that calls an action that does not exist yet is the only thing that makes this a two-run job instead of one.
  • [ ] README: a row for src/apps.ts in 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.md

Risks: 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_at and the current generation, not just that a row exists. identityKey is 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.

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.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs