When a forge sign-in has run out, say so — instead of quietly turning the person (or the agent) back into a guest.
What it is like today.src/who.ts answers ANON for six different situations: no headers at all, no FORGE_APP_IDENTITY_KEY configured, a bad signature, a malformed identity string, a role that is not user or admin, and — the common one — Number(expires) <= now (src/who.ts:38). Everything downstream sees the same { role: "anon" }, so:
a person whose sign-in lapsed while a board tab was open gets, on their next navigation, a page that says Sign in and offers to keep a board they already own, with no word that anything expired;
list_boards over MCP answers [] — an agent whose identity has run out is told, in effect, that it owns no boards, which is the same answer as "you are anonymous" and the opposite of "your credential expired";
an operator debugging a misconfigured FORGE_APP_IDENTITY_KEY sees exactly the same behaviour as a working app with nobody signed in.
Nothing here is insecure — an expired identity must be refused. The defect is that the refusal is silent and indistinguishable.
Done looks like
[ ] who.ts keeps returning a Who that is anonymous in every one of those cases (no behaviour change for authorization, ever), and additionally reports why: a second exported reading — identityState(request, key, now) returning "none" | "expired" | "invalid" | "unconfigured" | "ok" — computed from the same parse, with no second implementation of the check.
[ ] The page acts on "expired" only: the door shows "Your sign-in expired" beside the Sign in link (which already carries ?to=<path>, so one press returns to the same board). "invalid" and "unconfigured" are never shown to a visitor — they are one logged line each (#78's format), because they are operator faults, not user ones.
[ ] The MCP seat acts on it too: a tool call carrying an expired or invalid identity answers as a tool error — expired: your forge sign-in has run out; sign in again and retry — instead of silently returning an anonymous result. A call with no identity at all keeps behaving exactly as today (anonymous is a legitimate way to use the link-scoped tools).
[ ] No timing or content difference gives away whether an unsigned identity string was well-formed: the signature is still checked first, in constant time, before anything else is looked at. A test asserts the order.
[ ] Tests that fail without the change: an identity that is valid but expired by one millisecond reports expired and still authorizes nothing; a bad signature reports invalid and never reports expired, whatever the stamp says; an absent header reports none; an empty key reports unconfigured; the board page shows the expired sentence only in the expired case; the MCP error is returned for an expired call and not for an anonymous one.
[ ] The README's identity section lists the five states and what each one means to a person, an agent and an operator.
Files I expect to change
src/who.ts, src/routes.ts, src/pages/board.ts (the door), src/mcp.ts, src/who.test.ts, src/server.test.ts, README.md.
Risks
The whole value is in not weakening the check. who() must remain the only thing anything authorizes on, and identityState must be derived from the same parse rather than a lookalike that can drift — one function, two readings.
Saying "expired" to a caller who presented a signature that does not verify would tell an attacker their forgery was well-formed. Order matters: verify, then report.
Suggested: ledger if free, otherwise scout, reviewed by sentinel.
When a forge sign-in has run out, say so — instead of quietly turning the person (or the agent) back into a guest.
What it is like today.
src/who.tsanswersANONfor six different situations: no headers at all, noFORGE_APP_IDENTITY_KEYconfigured, a bad signature, a malformed identity string, a role that is notuseroradmin, and — the common one —Number(expires) <= now(src/who.ts:38). Everything downstream sees the same{ role: "anon" }, so:list_boardsover MCP answers[]— an agent whose identity has run out is told, in effect, that it owns no boards, which is the same answer as "you are anonymous" and the opposite of "your credential expired";FORGE_APP_IDENTITY_KEYsees exactly the same behaviour as a working app with nobody signed in.Nothing here is insecure — an expired identity must be refused. The defect is that the refusal is silent and indistinguishable.
Done looks like
who.tskeeps returning aWhothat is anonymous in every one of those cases (no behaviour change for authorization, ever), and additionally reports why: a second exported reading —identityState(request, key, now)returning"none" | "expired" | "invalid" | "unconfigured" | "ok"— computed from the same parse, with no second implementation of the check."expired"only: the door shows "Your sign-in expired" beside the Sign in link (which already carries?to=<path>, so one press returns to the same board)."invalid"and"unconfigured"are never shown to a visitor — they are one logged line each (#78's format), because they are operator faults, not user ones.expired: your forge sign-in has run out; sign in again and retry— instead of silently returning an anonymous result. A call with no identity at all keeps behaving exactly as today (anonymous is a legitimate way to use the link-scoped tools).expiredand still authorizes nothing; a bad signature reportsinvalidand never reportsexpired, whatever the stamp says; an absent header reportsnone; an empty key reportsunconfigured; the board page shows the expired sentence only in theexpiredcase; the MCP error is returned for an expired call and not for an anonymous one.Files I expect to change
src/who.ts,src/routes.ts,src/pages/board.ts(the door),src/mcp.ts,src/who.test.ts,src/server.test.ts,README.md.Risks
who()must remain the only thing anything authorizes on, andidentityStatemust be derived from the same parse rather than a lookalike that can drift — one function, two readings.Suggested: ledger if free, otherwise scout, reviewed by sentinel.