Review: hooks.server.ts + app.d.ts — westside-admin#15
Verdict: APPROVED
Board item: #1135 on
Forgejo issue: forgejo_admin/westside-admin#15 (open).
Type: Feature.
Lineage: Sub-task 2 of 4 from decomposition of
One-line: Scope is tight, file targets are accurate against post-#14 ground truth, all three claimed lib exports actually exist on
board-pal-e-platform.Forgejo issue: forgejo_admin/westside-admin#15 (open).
Type: Feature.
Lineage: Sub-task 2 of 4 from decomposition of
westside-admin#2. Depends on #14 (merged today as PR #18). Siblings still in backlog: #16 (auth endpoints), #17 (403 page).One-line: Scope is tight, file targets are accurate against post-#14 ground truth, all three claimed lib exports actually exist on
main. Two tiny BODY tightening items noted but neither blocks dispatch.Template Completeness
- [x] Type — Feature
- [x] Lineage — cites parent #2, dependency #14, siblings #16/#17
- [x] Repo —
forgejo_admin/westside-admin - [x] User Story —
story-westside-admin-admin-row-crudwith funnel-auth rationale - [x] Context — explains the per-request enforcement role, lazy-refresh behavior, how it consumes #14, does-not-define for #16/#17
- [x] File Targets — 1 create, 1 update, 3 explicit do-not-touch directories with their owning sub-tasks named
- [x] Acceptance Criteria — 8 testable items
- [x] Test Expectations — manual integration matrix (4 cases) with stated rationale for skipping vitest target until #17 lands
- [x] Constraints — SvelteKit cookie API, locals-not-globalThis, exact cookie-name string, rewrite-not-redirect for 403, no route allowlist, import path style
- [x] Checklist — includes funnel-auth review per
feedback_funnel_requires_auth - [x] Related — project page, parent, dep, dataflow note, funnel-auth memory
Traceability
- [x]
story:admin-row-crudlabel — matches project Safety Constraints contract - [x] story note verified — entry present in
project-westside-adminuser-stories table (story-westside-admin-admin-row-crud) - [x]
arch:westside-adminlabel — umbrella satisfied byarch-dataflow-westside-adminFlow 1, which sequences this hook explicitly (browser to SK to hooks.server.ts to JWKS validation to role parse to 403 OR locals.user to load()) - [!]
arch:keycloaklabel has NO backing note — same gap as siblings #14 (review-1134), parent #1132, dep #1131.arch-keycloakstill does not exist;arch-dataflow-westside-adminFlow 1 jointly covers the Keycloak surface for this ticket. Per the carry-over[SCOPE]recommendation in review-1134, Ava's call whether to authorarch-keycloaknow or defer. Not a per-ticket blocker. - [x] Forgejo issue valid and open. Target repo
forgejo_admin/westside-admin, sub-task 2 of 4.
File Targets
Repo at
/home/ldraney/westside-admin. Verified against ground truth on main (HEAD 982df5b, post-#18 merge, post-pull confirmed).- [x]
src/hooks.server.ts(create) — does not exist yet (verified via find). Correct location: SvelteKit picks this path up automatically. OK to create. - [x]
src/app.d.ts(update) — exists with the unmodified scaffold (App namespace block with all interfaces commented out). AddingApp.Locals.userhere is the one-line uncomment + fill, exactly the right surface. - [x] Do-NOT-touch:
- [x]
vitest.config.ts+package.jsontestscripts landed via #14/PR #18 (verified). The 'no vitest target' note in Test Expectations is accurate scoping (manual integration only for this sub-task), but vitest IS now available if the agent wants to add a unit test for cookie parsing or 30s-window calculation as a free-throw.
Sanity check on cookie-name attribution: the lib does NOT export a cookie-name constant. The issue body says the cookie name 'MUST be exactly
westside_admin_session' and asserts this 'matches #14's expectation' — strictly, #14 has no expectation: the lib is cookie-name-agnostic by design (it operates on ciphertext strings). This is the right separation of concerns; the hook owns the cookie name. The directive is unambiguous; the attribution is just a hair off. Optional [BODY] below.Repo Placement
OK — single repo (
forgejo_admin/westside-admin). No cross-repo concerns. Env-var landing already merged in pal-e-deployments#147; ArgoCD reconciling per prompt context. The hook code itself does not block on cluster-Secret rollout — it reads env vars at request time, not at module load.Dependencies
- westside-admin#14 (keycloak.ts lib) — MERGED as PR #18 (
982df5b). Board item #1134 sits invalidationcolumn. Direct unblock. - pal-e-deployments#147 (KEYCLOAK_CLIENT_SECRET landing) — MERGED. ArgoCD reconciling per prompt. The hook's env-var reads (KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, COOKIE_SIGNING_KEY) all happen at request time via the lib's
requireEnv; absent envs throwMissingEnvError. For dev validation againstnpm run devon archbox, the agent will need a local.envshim — not a blocker but worth flagging. - Sibling sub-task #16 (auth endpoints) — OPEN, in backlog. The hook redirects to
/auth/login?redirect={url}; sub-task #16 must create this endpoint. Hook does NOT block on #16 to land code, but end-to-end manual ACs cannot fully pass until #16 + #17 ship together. Acknowledged in the issue body's Test Expectations. - Sibling sub-task #17 (403 page) — OPEN, in backlog. Hook rewrites to the
(unauthorized)route group; #17 creates the page itself. Hook code can ship; the rewrite target is a 404 until #17 lands. - Implicit ordering: #15 then #16 then #17, with full E2E only after #17. Acknowledged.
Acceptance Criteria
8 ACs, all verifiable. Notable strengths:
- AC #4 (tampered cookie treated as anonymous, never throws) — directly maps to
decryptCookiePayload's null-on-tamper contract verified above. Crisp. - AC #3 (missing-admin = rewrite, NOT redirect) — explicit and re-stated in Constraints. Prevents the common bug of redirecting to /403 (which would create a redirect loop if /403 itself is admin-gated).
- AC #6 (cookie attributes on every refresh) — covers the failure mode where a refresh path forgets to re-set HttpOnly+Secure+SameSite.
- AC #7 (no tokens / refresh tokens / ciphertext in any log) — mechanical grep verification post-implementation.
- AC #8 (event.locals.user typed end-to-end) — couples the hook to the
app.d.tsupdate; agent cannot half-finish either side.
One small gap (NOT blocking): the issue body does not specify what happens if
refreshTokensIfNeeded throws TokenRefreshError mid-request. The implication from AC #4 is 'treat as anonymous' (since refresh failure means session is no longer trustworthy). Agent is expected to infer this; the constraint 'tampered/expired/wrong-key cookie is treated as anonymous, never throws' reasonably extends to 'refresh-failure cookie.' Optional [BODY] below.Blast Radius
Maximum. hooks.server.ts gates EVERY admin request — every page load, every form action, every health-check route. A bug here is a 100% outage or, worse, a 100% security hole on a public Tailscale funnel.
feedback_funnel_requires_auth applies in full force.Specific risks the dev agent should be aware of:
- Health-check exclusion: issue body says 'every route is admin-gated by default; the only exclusions are
/auth/*and/(unauthorized)'.src/routes/healthalready exists (verified via ls). Either it must be admin-gated (and Kubernetes liveness/readiness probes need to be configured to skip auth, which they cannot directly — the funnel does not know about probes), OR the hook needs a third exclusion for/health. This is a real implementation question that the issue body does not resolve. See Recommendation. - Cookie attribute symmetry: the lib does not enforce cookie attributes (it returns ciphertext strings only). Every hook code path that calls
event.cookies.set(...)must include{ httpOnly: true, secure: true, sameSite: 'lax', path: '/' }. SvelteKit'ssecuredefault is only true in HTTPS contexts;httpOnlydefaults to true but explicit is better. AC #6 covers this conceptually. - Locals shape lock-in: the
App.Locals.usertype defined in this PR becomes a load-bearing interface for every+page.server.tsin the project. RenamingrolestorealmRoleslater means touching every consumer. Issue body's explicit shape —{ sub: string; email: string; name: string; roles: string[] }— is fine; just call it out as locked once shipped.
Decomposition Assessment
No decomposition needed.
- File count: 1 create + 1 update = 2 files. Well under threshold.
- AC count: 8 — over the 5-AC paper rule but, like #14, the ACs all map to a single file's behavior. They are facets of one handler function (handle), not orthogonal user-visible features.
- Estimated time: 5-8 min agent run. The hook is roughly 60-100 lines of TypeScript.
- Cohesion: handle is necessarily a single function in one file. Splitting (e.g., one PR for happy path, one for refresh, one for 403) would create N partially-broken intermediate states. Anti-pattern.
Recommendation
Each recommendation tagged for
skill-refine-ticket machine consumption:[BODY](should-fix, not blocker) Resolve the/healthroute question. Add a third exclusion to the 'every route is admin-gated by default; the only exclusions are/auth/*and/(unauthorized)' sentence — either explicitly include/healthas a third exclusion, or explicitly state '/health is admin-gated; Kubernetes probes hit the pod via a cluster-internal path.' Without this clarification the agent has to guess. Recommend: exclude/health(the simpler answer).[BODY](optional) Add to AC #4 or as a new AC: 'IfrefreshTokensIfNeededthrowsTokenRefreshError, the request is treated as anonymous (cookie cleared, 302 to/auth/login).' Currently inferable but explicit is better.[BODY](optional, cosmetic) Tighten the Constraints sentence 'Cookie name MUST be exactlywestside_admin_session(matches #14's expectation and the spec in #2)' to 'Cookie name MUST be exactlywestside_admin_session(defined here for the first time; #14's lib is cookie-name-agnostic by design — the hook owns this constant; matches the spec in #2).' More accurate.[SCOPE](carry-over from review-1134) Decide whether to createarch-keycloaknote now or continue deferring. Same status as before:arch-dataflow-westside-adminFlow 1 covers the surface this ticket touches. Ava's call.- Sequencing note for whoever dispatches: #15 is unblocked NOW (#14/PR #18 merged, env vars merged via #147). Ready to move backlog to todo to next_up to in_progress. Sibling sub-tasks #16/#17 can start in parallel if Ava wants — they don't share files with #15 (do-not-touch fences are clean) and the import surface they need from
$lib/server/keycloakis already on main. The only constraint is end-to-end manual ACs require all four landed.
Key Finding Summary
The ticket is well-scoped and dispatch-ready. Three small
[BODY] items: one should-fix (the /health route exclusion question), two optional (refresh-failure AC clarity, cookie-name attribution tightening). None block dispatch — the dev agent has enough context to infer reasonable answers. Verdict: APPROVED. Recommend Ava also routes the /health question to the dev agent's prompt rather than blocking the ticket on a body refinement, since it is a 30-second clarification.