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.
Identity on every request comes from the forge's signed headers, and the app's own admin password stops existing.
Today
/adminis guarded byADMIN_PASSWORD(or a generated one) and asession.is_adminflag — 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 onctx.who.Done looks like
src/who.tsverifiesx-forge-identityagainstx-forge-signature: the identity string is<userId>|<username>|<role>|<expiresMs>, the signature isbase64url(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 notuseroradmin, 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.whois built insrc/server.tsbeside the session and reaches handlers throughsrc/context.ts; no handler reads those headers itself./adminis served only forrole === "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>./__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.ADMIN_PASSWORD, the generated demo password and its salt,verifyAdminPassword,ensureDemoPassword,usingGeneratedPassword,POST /admin/signin,POST /admin/signout,pages.signIn, theSIGN_INrate limit, and theis_adminread insrc/session.ts. The session cookie stays: it still carries the CSRF token and nothing else, and CSRF still guards every POST.src/tty.tsnames the person at the keyboard fromFORGE_APP_USER,FORGE_APP_USER_IDandFORGE_APP_USER_ROLEand prints who that is on its first screen; its privileged actions (stock moves, refunds) are for an admin.admin; 403 foruser, for anon, for a tampered signature, for an expired stamp, and whenFORGE_APP_IDENTITY_KEYis unset. The password tests insrc/server.test.tsgo with the password.ADMIN_PASSWORDand gainsFORGE_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 readssession.isAdmin),src/shop.ts(the admin bypass atsrc/shop.ts:224),src/ratelimit.ts,src/tty.ts,src/server.test.ts,README.md.Risks
FORGE_APP_IDENTITY_KEY,/adminis 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:224lets an admin open any order without its token. Keep that, but source it fromctx.whorather than a session row.sessiontable or itsis_admincolumn.src/db.tsgrows schemas and never rebuilds them; stop reading the column and leave the rows alone.src/server.tscomes 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.