ADR-0050: fs-auth — the Armory's Sanctum SPA Session Package
Proposed Cross-Project UniversalProposed 2026-09-14 on a Commander ruling (WR-1384, 2026-09-14) that overruled the General's not-yet recommendation. The four semantics were ruled the same afternoon (see Resolved Questions) — each one changes the package's public contract, and three of them are the class of finding that cost lokalekeuze fifteen review rounds on one login PR. Open before acceptance: who tells ally Jasper, and the Commander's press. Evidence: the fleet survey (
campaigns/lokalekeuze/2026-09-12-the-login-flow-fleet-survey-and-why-pr-220-cannot-be-fixed-into-green.md§ 2) and the kendo deep dive (reports/kendo/field/2026-09-14-surveyor-auth-session-layer-fs-auth-extraction-recon.md, debriefed the same day).
Date: 2026-09-14
Compliance: ISO 27001 (A.8.15 — session end is a security event on every certified territory) | AVG (the session is the boundary between a data subject's data and the public)
Context
The Armory ships fourteen packages and no session package. fs-http provides the transport primitives — withCredentials, withXSRFToken, a response-error middleware hook — and its own docs tell each consumer to write its own 401 redirect. fs-router 0.3.0 ships the middleware slot and a typed redirect-return whose changelog names "the ordinary auth-guard shape" as the motivating case, and ships no guard.
So every territory hand-rolls the same thing. Nine were surveyed at HEAD on 2026-09-12: three lineages (wijs ↔ ublgenie ↔ kendo are one forked file with kendo the evolved head; emmie ↔ entreezuil share a second ancestor; scripthub and time-machine were written fresh), not one handling 419 as a status, only kendo validating the return-to, only kendo and emmie detecting mid-session expiry on a named, tested path, and isms carrying its whole auth service twice (colleague and worker). lokalekeuze alone holds four copies of one session store and re-declares SIGNED_OUT_STATUSES = new Set([401, 419]) in fifteen stores.
The seed was deep-dived on 2026-09-14. kendo's createAuthService(http, storage, router, toast, options?) is the fleet's most evolved shape and it is doctrine-clean where it counts: every dependency injected through four hand-rolled structural interfaces naming no library type; service.ts, guards.ts and types.ts import nothing kendo-internal and are importable into a package unchanged today. What the dive found against extracting it as designed:
- The factory does not parameterise the guard. Sixteen endpoint paths are string literals inside
service.ts; the entire tenant/central difference is thebaseURLbaked into the injected HTTP client. Two composition roots call one factory with different clients, and six of its twenty-five methods would 404 or 405 on central. The nine-field option bag has zero live overrides across both roots. One surface serving two guards is already over-wide at n=2. useris a writableRefon the public interface, and one kendo file assigns toauthService.user.valuesix times from profile, avatar, e-mail, pin and notification-preference flows. A package that narrows it breaks all six; a package that keeps it writable ships an unguarded write seam to every consumer. This is the largest kendo-side migration cost and it is invisible from the factory signature.- Four semantics were never ruled. A failed logout is silent from the service (the DELETE is awaited before any local teardown — the ADR-0048 shape). A 401 while logged in emits two toasts (the toast middleware runs before the unauthorized guard).
handleSessionExpiredhas no re-entrancy guard, so N concurrent 401s produce N toasts and N navigations. The return-to survives login and the 2FA challenge and is lost on expiry, on OAuth completion and on the 2FA step-up. resolveSafeRedirectis sound for kendo's sink and not for a package's. It rejects non-strings, non-/starts,//prefixes and backslashes. It accepts a path carrying an ASCII tab, LF or CR — and browsers strip those before URL parsing, so/<TAB>/evil.combecomes//evil.comat anylocation.href-shaped sink. Harmless in kendo, where the sink isrouter.push; a live open redirect in a package that cannot know its consumer's sink.permissions.tsis a kendo permission-model adapter wearing an auth filename and stays territory-side. Its three kendo-internal imports are allimport type, so the argument is design, not erasure.- The layer is co-authored, not an ally's sole work: 32 commits, ally Jasper 44 %, the Commander 38 %, four other script-development hands. This is moving a shared file into the Armory, not taking someone's file.
The second seed is lokalekeuze, the fleet's most mature priming and the only territory with ruled session semantics (Commander 2026-09-12, LK-0453): a four-state machine (loading | authenticated | signed_out | outage) whose store is its only writer; primeCsrf memoised per page load and reset only by a caller that has just seen a 419; login() and logout() answering with the sentence to render rather than throwing (ADR-0048); state moving to signed_out on a successful logout only, with no probe behind a failure; and session end mid-shell as a full document load to the entrance carrying the return-to, because the document boundary is the ordering primitive that kills every in-flight continuation.
And 419 is a dev-only status on a same-origin SPA. Laravel 13's PreventRequestForgery::hasValidOrigin returns true on Sec-Fetch-Site: same-origin before any token compare (verified at the vendor source, 2026-09-14). Every Fly territory serves its SPA from the API's own origin, so a current browser cannot draw a 419 there; the Vite dev port is cross-origin-same-site and hits the token compare, which is where every developer saw one and wrote a branch for it. The residual is the pre-16.4 Safari tail and the genuinely cross-origin consumers (scripthub's frontend calls a separate API origin), for whom priming is real.
Decision
1. One package, @script-development/fs-auth, built on fs-http and fs-router
The package owns the session: whether there is one, how it is established, how it ends, and how the app finds out. It composes fs-http (transport, the response-error hook) and fs-router (the middleware slot, the typed redirect-return) and re-implements neither.
2. The factory is createSessionStore(config), and the endpoint set is explicit
kendo's shape — total dependency injection, structural interfaces naming no library type — is the reference. kendo's endpoint literals and option bag are not.
import {createSessionStore, sanctumEndpoints} from '@script-development/fs-auth';
export const session = createSessionStore<Employer>({
guard: 'employer', // the literal the API keys on
http: httpService, // an fs-http service; the package never creates one
endpoints: sanctumEndpoints('auth/employer'), // {me, login, logout} — a preset, overridable per key
parseUser: (body) => isEmployer(body) ? body : undefined, // the consumer's own type guard; `undefined` = outage, not signed-out
timeoutMs: REQUEST_TIMEOUT_MS, // principle 8 — per-call, explicit
csrf: {primeUrl: `${location.origin}/sanctum/csrf-cookie`}, // OPTIONAL — cross-origin consumers only
});The store exposes:
state: Readonly<Ref<'loading' | 'authenticated' | 'signed_out' | 'outage'>>— the store is the only writer.user: Readonly<Ref<TUser | undefined>>plus one explicit writer,setUser(next), for the profile-update class of caller. No consumer assigns touser.value.loadSession(),login(credentials): Promise<LoginOutcome>,logout(): Promise<LogoutOutcome>— outcome-returning, never throwing on a state the screen draws (ADR-0048). ALoginOutcomeis{kind: 'authenticated'} | {kind: 'challenge', body: unknown} | {kind: 'refused', status, body}; the challenge arm is how 2FA stays outside the package without the package pretending it does not exist.onSessionEnd(listener: (event: {reason: 'logout' | 'expired', returnTo?: string}) => void)— fired once per session epoch (a single-flight guard: the first 401 wins, later concurrent ones are absorbed). The package performs no navigation itself; the consumer's listener chooses the exit (kendo: a router push; lokalekeuze: a full document load). A package cannot know its consumer's sink, so it does not pretend to own it.resolveSafeRedirect(candidate: unknown): string | undefined— hardened: non-string, non-/start,//prefix, backslash, and any ASCII control character or whitespace are rejected; nothing is stripped or normalised. Its fixture table is a Level-1 gate in the package.registerAuthGuard(router, store, {loginRouteName, isPublic, resolveReturnTo, redirectQuery: 'redirect'})andregisterUnauthorizedMiddleware(http, store, {returnTo?})—isPublicandresolveReturnToare injected, never inferred: fs-router 0.3.0's before-route slot hands the middleware the matched route RECORD (itspathis the pattern), never the visited location, so the package cannot read a return-to itself (measured at extraction 2026-09-14, WR-1430) — the two registrars from kendo'sguards.ts, onfs-router's middleware slot andfs-http's response-error hook.
3. Status handling is fixed by the package, not by each consumer
- 401 and 419 are one class with one action: the session is over for this tab.
SIGNED_OUT_STATUSESlives in the package and nowhere else. - 419 gets no behaviour of its own, with one exception: a store configured with
csrfre-primes once on a 419 fromlogin()and retries; a second 419 is rendered as a refusal, never interpreted. Nothing else re-primes. - 403, 422, 429 and transport failures are not the package's — they reach the caller as the
refusedarm or as the rejection, and the consumer's own middleware discriminates (kendo's three 403 body-key handlers stay where they are). - A
meresponse that failsparseUserisoutage, neversigned_out: rendering a broken API as "please log in" invites a password that would have worked a minute earlier.
4. What stays territory-side, by name
Permissions and role models. 2FA enrolment and management (kendo's eight methods, isms's challenge). OAuth. Impersonation (which, verified, exists in no territory today). emmie's inactivity timer. Toast and sentence copy — the package returns outcomes and fires events; it never renders a word, so i18n never enters it. The tenant/central baseURL split (that is the injected http).
5. Adoption order
- lokalekeuze — first consumer. Its pending fold (WR-1383) is implemented as adopting the package rather than as a territory-local
shared/auth/: the four stores are the richest seed for the ADR-0048 and 419 semantics and the fold was going to build this shape anyway. - kendo — second, with ally Jasper in the loop before the PR (co-authored file, script-development scope). The migration is bounded: six
user.valuewrites becomesetUser, two composition roots become twocreateSessionStorecalls with explicit endpoint maps,permissions.tsstays. - wijs and ublgenie — the frozen forks, next touch of an auth path.
- laravel-skeleton casts every new territory with the package once it ships (its backend half,
statefulApi(), is WR-1406). - emmie, entreezuil, isms, scripthub, time-machine — on next touch, per ADR-0048's rollout stance. isms is the multi-guard proof: two
createSessionStorecalls replace two copied services.
Options Considered
| Option | Verdict | Reason |
|---|---|---|
| Fleet convention only — a documented shape plus a per-territory arch test, no package | Rejected | The convention already exists in kendo and was forked twice and frozen both times; a convention cannot carry a fix. Nine copies drift nine ways. |
Fold per territory (shared/auth/ in each repo), no package | Rejected as the end state | Correct as lokalekeuze's first step and wrong as a destination: it turns nine copies into nine better copies. Kept as the adoption vehicle, not the design. |
Extract kendo's createAuthService verbatim | Rejected | Endpoints hardcoded, option bag unused, user writable, four semantics unruled, a redirect guard that is safe only for kendo's sink. The shape is right; the content is kendo's. |
createSessionStore with an explicit endpoint map, outcome-returning calls, no navigation, no copy | Accepted (proposed) | Serves one guard per instance (isms, lokalekeuze) and many per app (kendo) with the same API; keeps the sink, the copy and the domain layers with the consumer; fixes the four semantics once. |
Consequences
Positive
- One
SIGNED_OUT_STATUSES, one 419 stance, one open-redirect guard, one single-flight session end — fixed in one place, tested under the Armory's 100 % coverage and 90 % mutation gates, shipped to every consumer on a version bump. - The multi-guard case collapses: isms's duplicated services and lokalekeuze's four stores become N factory calls.
- ADR-0048 is satisfied by construction on the login and logout paths — an outcome-returning API cannot lose the failure.
Negative
- A fifteenth package with the Trusted Publisher bootstrap that has bitten three times; the first version is hand-published and the grant is proved by a CI publish, per fs-packages
CLAUDE.md § Adding a Package. - kendo's migration touches a co-authored file and a six-site write seam; it is an alliance conversation before it is a PR.
- The package fires events and returns outcomes; every consumer still writes its exit and its sentences. This is deliberate and it is also work the consumer might have hoped to shed.
Risks
- A design frozen on one consumer. Mitigation: the package is not published until lokalekeuze (single guard, cross-origin priming off) and kendo (two guards, same-origin) both compile against the same API on branches — two consumers before
0.1.0, not one. - The tab/newline vector is fixed in the package and not in kendo. Mitigation: kendo's sink is
router.pushtoday; the package fixture table is the guard, and kendo inherits it on adoption. - A consumer bypasses
setUserby reaching into aRefit should not have. Mitigation:useris exposedreadonly(); a consumer-side arch test (the WR-1383 gate generalised) fails a secondSIGNED_OUT_STATUSESdeclaration or amefetch outside the store.
Enforcement
| What | Mechanism | Scope |
|---|---|---|
| Package correctness and contract | fs-packages 8-gate CI; PACKAGE_THRESHOLDS entry (100 % coverage, 90 % mutation); resolveSafeRedirect vector fixture table (Level 1) | packages/auth/** |
| No second session store in a consumer | Territory arch test: one SIGNED_OUT_STATUSES, no me-endpoint fetch outside the factory, no assignment to session.user.value | Each adopting territory's frontend/src |
| No navigation and no copy inside the package | Package arch test: no location, window, router.push or string-literal sentence in packages/auth/src (Level 1) | packages/auth/src/** |
| 419 has no branch of its own | Package spec: a 419 outside login() with csrf configured takes the 401 path; teeth-proved by adding a 419 branch and watching it go red | packages/auth/src/** |
Resolved Questions
Failed logout — what does the package do when the logout request fails?
Resolved 2026-09-14 (Commander). State moves to signed_out on success only; the LogoutOutcome names the failure; nothing probes the server afterwards. lokalekeuze's 2026-09-12 ruling generalised — a cookie the server still honours is never reported as gone. ublgenie's finally (tear down regardless) rejected for exactly that reason.
Return-to on session expiry — does the sessionEnd event carry the page the user was on?
Resolved 2026-09-14 (Commander). Yes. The event carries returnTo and the consumer's exit writes it under whatever query name the consumer uses. kendo's expiry path gains what its login path already has.
Is user writable from outside the store?
Resolved 2026-09-14 (Commander). No. user is readonly() outward; setUser(next) is the one explicit writer. kendo's six external user.value writes become setUser calls on adoption.
Who navigates on session end?
Resolved 2026-09-14 (Commander). The package fires; the consumer navigates. lokalekeuze's full-document-load rule and kendo's router push are both consumer choices, and the package cannot know its sink.
What needs deciding
- Who tells Jasper, and when. Before the kendo adoption PR, by the Commander or the General on the Commander's word; this page is the negotiation material.
- Acceptance. The four contract rulings are in; acceptance is the Commander's press, and publication to npm additionally waits on two consumers compiling against the API on branches (§ Risks).
Implementation
| Territory | State | Notes |
|---|---|---|
| fs-packages | Not Started | packages/auth; Trusted Publisher bootstrap before the first CI publish; two consumers on branches before 0.1.0 |
| lokalekeuze | Not Started | First consumer — WR-1383 becomes the adoption; four stores → four factory calls |
| kendo | Not Started | Second consumer; ally conversation first; permissions.ts stays; six user.value writes → setUser |
| wijs · ublgenie | Not Started | Frozen forks; next touch of an auth path |
| laravel-skeleton | Not Started | Casts with the package once shipped; backend half is WR-1406 |
| emmie · entreezuil · isms · scripthub · time-machine | Not Started | Next touch; isms is the multi-guard proof |