Skip to content

ADR-0050: fs-auth — the Armory's Sanctum SPA Session Package

Proposed Cross-Project Universal

Proposed 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 the baseURL baked 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.
  • user is a writable Ref on the public interface, and one kendo file assigns to authService.user.value six 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). handleSessionExpired has 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.
  • resolveSafeRedirect is 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.com becomes //evil.com at any location.href-shaped sink. Harmless in kendo, where the sink is router.push; a live open redirect in a package that cannot know its consumer's sink.
  • permissions.ts is a kendo permission-model adapter wearing an auth filename and stays territory-side. Its three kendo-internal imports are all import 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.

ts
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 to user.value.
  • loadSession(), login(credentials): Promise<LoginOutcome>, logout(): Promise<LogoutOutcome> — outcome-returning, never throwing on a state the screen draws (ADR-0048). A LoginOutcome is {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'}) and registerUnauthorizedMiddleware(http, store, {returnTo?})isPublic and resolveReturnTo are injected, never inferred: fs-router 0.3.0's before-route slot hands the middleware the matched route RECORD (its path is 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's guards.ts, on fs-router's middleware slot and fs-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_STATUSES lives in the package and nowhere else.
  • 419 gets no behaviour of its own, with one exception: a store configured with csrf re-primes once on a 419 from login() 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 refused arm or as the rejection, and the consumer's own middleware discriminates (kendo's three 403 body-key handlers stay where they are).
  • A me response that fails parseUser is outage, never signed_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

  1. 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.
  2. kendo — second, with ally Jasper in the loop before the PR (co-authored file, script-development scope). The migration is bounded: six user.value writes become setUser, two composition roots become two createSessionStore calls with explicit endpoint maps, permissions.ts stays.
  3. wijs and ublgenie — the frozen forks, next touch of an auth path.
  4. laravel-skeleton casts every new territory with the package once it ships (its backend half, statefulApi(), is WR-1406).
  5. emmie, entreezuil, isms, scripthub, time-machine — on next touch, per ADR-0048's rollout stance. isms is the multi-guard proof: two createSessionStore calls replace two copied services.

Options Considered

OptionVerdictReason
Fleet convention only — a documented shape plus a per-territory arch test, no packageRejectedThe 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 packageRejected as the end stateCorrect 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 verbatimRejectedEndpoints 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 copyAccepted (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.push today; the package fixture table is the guard, and kendo inherits it on adoption.
  • A consumer bypasses setUser by reaching into a Ref it should not have. Mitigation: user is exposed readonly(); a consumer-side arch test (the WR-1383 gate generalised) fails a second SIGNED_OUT_STATUSES declaration or a me fetch outside the store.

Enforcement

WhatMechanismScope
Package correctness and contractfs-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 consumerTerritory arch test: one SIGNED_OUT_STATUSES, no me-endpoint fetch outside the factory, no assignment to session.user.valueEach adopting territory's frontend/src
No navigation and no copy inside the packagePackage 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 ownPackage spec: a 419 outside login() with csrf configured takes the 401 path; teeth-proved by adding a 419 branch and watching it go redpackages/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

  1. 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.
  2. 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

TerritoryStateNotes
fs-packagesNot Startedpackages/auth; Trusted Publisher bootstrap before the first CI publish; two consumers on branches before 0.1.0
lokalekeuzeNot StartedFirst consumer — WR-1383 becomes the adoption; four stores → four factory calls
kendoNot StartedSecond consumer; ally conversation first; permissions.ts stays; six user.value writes → setUser
wijs · ublgenieNot StartedFrozen forks; next touch of an auth path
laravel-skeletonNot StartedCasts with the package once shipped; backend half is WR-1406
emmie · entreezuil · isms · scripthub · time-machineNot StartedNext touch; isms is the multi-guard proof

Architecture documentation for contributors and collaborators.