---
schema_version: 1
built_at_commit: 2d858be6d664244cebe4734d6316072c83ca24d0
built_at: 2026-06-27
scope: [apps/mod-cms/src/lib/auth.ts, apps/mod-cms/src/lib/csrf.ts, apps/mod-cms/src/lib/sanitize.ts, apps/mod-cms/src/lib/sanitize-sinks.canary.test.ts, apps/mod-cms/src/lib/admin-engine.ts, apps/mod-cms/src/pages/api/admin/session.ts, apps/mod-cms/src/pages/api/admin/content.ts, apps/mod-cms/src/pages/api/admin/me.ts]
---
## apps/mod-cms/src/lib/auth.ts
- Trust boundary: HTTP Request → resolveSession (Bearer header / Cookie header)
- Entry points: resolveSession, resolveAdmin, resolveEditor, requireAdmin, requireEditor, loginWithCredentials, logout, setSessionCookies, clearSessionCookies, actorFromPrincipal
- Untrusted inputs: Authorization header, Cookie header (mod_session access JWT, mod_refresh opaque token), JSON login body
- AuthN/Z model: Bearer JWT OR opaque service-token PAT OR session cookie; requireRole('admin'|'editor', ['viewer','editor','admin']); authMode∈{token,session} drives CSRF exemption
- Invariants: Bearer→cookie fall-through never shadows a session; authMode reflects REAL credential; silent refresh rotates + reuse-detection family-revoke; logout always clears both cookies (finally)
- Sinks: CookieSink.set/delete (HttpOnly,Secure,SameSite=Lax,Path=/), engine.signIn/refresh/signOut/verifySession

## apps/mod-cms/src/lib/csrf.ts
- Trust boundary: Request headers (Origin, Sec-Fetch-Site)
- Entry points: assertCsrf
- AuthN/Z model: token mode exempt; session mode → isSameOrigin([SITE_ORIGIN]) exact match else 403
- Invariants: fail-closed (neither Origin nor Sec-Fetch-Site → false); host exempts Bearer before calling
- Sinks: throws CsrfError → 403

## apps/mod-cms/src/lib/sanitize.ts
- Trust boundary: admin/editor-authored HTML body (write-time put) + render-time set:html re-sanitize
- Entry points: sanitizeContentBody (Sanitize contract)
- Invariants: allowlist tags h1/p/h2/strong/em/ul/li/img; attrs *=[dir], img=[src,alt,width,height]; allowedSchemes=[] global, img https-only; allowProtocolRelative=false; disallowedTagsMode=discard
- Sinks: HTML stored then rendered into set:html / dangerouslySetInnerHTML
- Engine: sanitize-html@2.17.5 (no known CVE)

## apps/mod-cms/src/lib/sanitize-sinks.canary.test.ts
- Trust boundary: build-time canary (not runtime control)
- Invariants: every .astro set:html is tight sanitizeContentBody(<ident/member>) or themeCss (exactly 2); no .tsx dangerouslySetInnerHTML
- Note: SINK_RE [^}] won't span brace-containing exprs; secondary defense (write-time sanitize is primary)

## apps/mod-cms/src/lib/admin-engine.ts  (hash: live; patched @ PR#41 2d858be)
- Entry points: buildAuthEngine, buildServiceTokenResolver, getAuthDb
- Untrusted inputs: env (DB binding / DATABASE_URL / AUTH_SESSION_SECRET / AUTH_PEPPER / ADMIN_LOGIN_LIMITER)
- Invariants: D1 preferred over Neon; createCustomEngine NOW wired WITH rateLimit hook when env.ADMIN_LOGIN_LIMITER present (PR#41 activated the engine per-account login/refresh throttle — keyed `${scope}:${key}` = `auth.login:<email>` / `auth.refresh:<refreshToken[0..16]>`); hook fails CLOSED (limiter throw → 500; malformed return → 429); absence of binding = fail-open by design (auth gate still binds). Edge IP limiter in middleware (login:<ip>) stacks on top.
- Sinks: DB clients, resolveServiceToken (hashToken lookup, owner status=active gate), limiter.limit()

## apps/mod-cms/src/pages/api/admin/session.ts
- Entry points: POST (login, CSRF-exempt by design), DELETE (logout, CSRF via logout())
- Untrusted inputs: JSON {email,password}
- Invariants: login returns Principal (no token/hash); uniform InvalidSessionError('invalid credentials') — no enumeration via error type; edge ADMIN_LOGIN_LIMITER 10/60s/IP
- Sinks: loginWithCredentials, logout, mapContentError (generic 500)

## apps/mod-cms/src/pages/api/admin/content.ts
- Entry points: POST (requireEditor + assertCsrf + sanitizeContentBody + recordAudit), GET (requireEditor, read-only)
- Untrusted inputs: JSON {id?,slug,type,title,body?,visibility?}; query status/type
- AuthN/Z model: editor-or-admin flat editorial (actor.canEditAny=editor||admin, canPublish=admin); object authz inside content.put
- Invariants: id validated as strict UUID (isEntityId) at boundary; visibility allowlist public|private|members; body sanitized before put; audit AFTER commit (best-effort)
- Sinks: content.put (parameterized drizzle), recordAudit

## apps/mod-cms/src/pages/api/admin/me.ts
- Entry points: GET (resolveSession, NO role gate, read-only UX probe)
- Invariants: ONLY InvalidSessionError → 200 null; every other error surfaces real status (429/500); silent refresh occurs (acceptable, CORS-unreadable)
- Sinks: jsonOk(principal) — caller's own principal only
