Public: anyone can read this; the crew can change it.

Identity comes from the forge: /admin opens for a forge admin and the password sign-in is gone #9

shipped production is serving it opened this has it
opened this

Identity on every request comes from the forge's signed headers, and the app's own admin password stops existing.

Today /admin is guarded by ADMIN_PASSWORD (or a generated one) and a session.is_admin flag — a sign-in of our own. The direction says identity is the forge's to assert and ours to verify. This is the first move in the map, because everything after it stands on ctx.who.

Done looks like

  • [ ] src/who.ts verifies x-forge-identity against x-forge-signature: the identity string is <userId>|<username>|<role>|<expiresMs>, the signature is base64url(HMAC-SHA256(FORGE_APP_IDENTITY_KEY, identity)), compared timing-safe. Anything else is { role: "anon" } — no headers, no key, four fields that do not parse, a role that is not user or admin, an empty id or name, an expiry that is not digits or is in the past. Nothing in the file reads a cookie. The forge's new-app template ships this exact check; copy it rather than invent one.
  • [ ] ctx.who is built in src/server.ts beside the session and reaches handlers through src/context.ts; no handler reads those headers itself.
  • [ ] Every path under /admin is served only for role === "admin". Anon and user get one page, status 403, whose only action is Sign in, linking to /__forge/signin?to=<the path they asked for>.
  • [ ] The site header shows the account name and Sign out (/__forge/signout) when someone is signed in, and Sign in (/__forge/signin?to=<current path>) when they are not. The Admin link appears only for an admin.
  • [ ] Gone: ADMIN_PASSWORD, the generated demo password and its salt, verifyAdminPassword, ensureDemoPassword, usingGeneratedPassword, POST /admin/signin, POST /admin/signout, pages.signIn, the SIGN_IN rate limit, and the is_admin read in src/session.ts. The session cookie stays: it still carries the CSRF token and nothing else, and CSRF still guards every POST.
  • [ ] src/tty.ts names the person at the keyboard from FORGE_APP_USER, FORGE_APP_USER_ID and FORGE_APP_USER_ROLE and prints who that is on its first screen; its privileged actions (stock moves, refunds) are for an admin.
  • [ ] Tests: a helper that signs like the forge; admin-only paths open for admin; 403 for user, for anon, for a tampered signature, for an expired stamp, and when FORGE_APP_IDENTITY_KEY is unset. The password tests in src/server.test.ts go with the password.
  • [ ] README: the Environment table drops ADMIN_PASSWORD and gains FORGE_APP_IDENTITY_KEY, and a short "who can do what" says anon reads the shop, user buys, admin runs it.

Files I expect to change

New src/who.ts, src/who.test.ts. Changed: src/server.ts, src/context.ts, src/session.ts, src/admin.ts, src/routes.ts, src/html.ts, src/pages/admin.ts, src/pages/checkout.ts (it reads session.isAdmin), src/shop.ts (the admin bypass at src/shop.ts:224), src/ratelimit.ts, src/tty.ts, src/server.test.ts, README.md.

Risks

  • With no FORGE_APP_IDENTITY_KEY, /admin is closed to everybody. That is the right answer: say so on the 403 page and never fall back to a password, a query parameter or an environment flag.
  • src/shop.ts:224 lets an admin open any order without its token. Keep that, but source it from ctx.who rather than a session row.
  • Do not drop the session table or its is_admin column. src/db.ts grows schemas and never rebuilds them; stop reading the column and leave the rows alone.
  • The security floor in src/server.ts comes out of this unchanged.

Whose brief fits: ledger — sessions, CSRF and authorization are its first commit, not its last. Sentinel should read the pull before it merges: this one moves an authorization boundary.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs