# session Professional session lifecycle. **Zero external dependencies.** Three narrow generics (`TUser` / `TCredential` / `TData`) — auth-scheme-agnostic. Tagged `AdoptResult`, `RefreshResult` and `RevokeResult` (no `void` lying about scope). Generation-guarded refresh dedup that survives concurrent revoke / re-adopt / cross-tab races. Per-field Standard Schema validation on adopt (Sium / Zod / Valibot / ArkType — same code). Cross-tab via `BroadcastChannel` + `$storage`'s adapters — payload carries event + generation only, never tokens. SvelteKit-native via `adoptServer(...)` and `readSessionFromCookies(...)`. ```ts import { createActiveSession, withAutoRefresh } from '$session'; import { object, string, email } from '$sium/core'; interface JwtCredential { accessToken: string; refreshToken: string; } const Sess = createActiveSession<{ id: string; email: string }, JwtCredential>({ schemas: { user: object({ id: string(), email: email() }) }, onRefresh: async (current) => { const r = await fetch('/api/refresh', { method: 'POST', body: JSON.stringify({ refreshToken: current.credential.refreshToken }) }); if (!r.ok) return null; // fatal: auto-revokes return await r.json(); // engine adopts the new Session }, onRevoke: async (current) => { const r = await fetch('/api/logout', { method: 'POST', body: JSON.stringify({ refreshToken: current.credential.refreshToken }) }); return r.ok; // boolean drives globalRevoked } }); withAutoRefresh(Sess); await Sess.adopt({ user: { id: 'u-1', email: 'a@b.c' }, credential: { accessToken: 'tok', refreshToken: 'r' }, issuedAt: Date.now(), expiresAt: Date.now() + 3_600_000 }); ``` ```svelte {#if Sess.current}

Hi {Sess.current.user.email}

{:else} Login {/if} ``` --- ## Why another one `auth.js`, `lucia`, `supabase-js`, `@clerk/clerk-js` all exist. None combine the trade-offs this artifact targets: - **Lifecycle only, not acquisition.** Acquiring a session (OAuth, password exchange, magic link) is the consumer's responsibility — no provider lock-in, no MFA flow, no password handling. `session` adopts credentials you already have and runs the lifecycle around them. - **Auth-scheme-agnostic.** `TCredential` is opaque: JWT (`{accessToken, refreshToken}`), API key (`{apiKey}`), `undefined` for cookie auth, mTLS — all the same engine. Authorization (`role`, `permissions`) and acquisition (OAuth, MFA) live above this layer. - **Three-state identity.** `none` / `anonymous` / `identified`. UC-4 (anonymous cart that survives login over cookie auth) is expressed cleanly via `TData` — `cartId` is neither identity nor credential. - **Generation-guarded refresh.** A revoke during an in-flight refresh cannot resurrect the dead session — and external storage / broadcast changes also bump generation, so a slow refresh in tab B cannot undo a logout from tab A. - **Tagged result unions, never `void`.** `revoke({scope:'global'})` without an `onRevoke` returns `{globalRevoked: false, reason: 'missing_revoke_url'}` — the call site can show truthful UI copy. Same shape for `adopt()` (validation/invariant failures) and `refresh()` (skipped/expired/failed). - **Standard Schema first-class.** Per-generic `schemas.{user,credential,data}` validate `adopt()` input. Works with Sium / Zod / Valibot / ArkType identically. No runtime dep on Sium — only the `StandardSchemaV1` interface. - **Composable wrappers.** Auto-refresh ticker, 401-retry hook, JWT exp parser and SSR cookie reader are separable opt-in helpers — pay for what you reach. Engine is ~400 LOC. - **Storage-agnostic via `$storage`.** Cookie / localStorage / memory / custom adapters all work. Cross-tab via `withBroadcast` is opt-in. --- ## Architecture ``` session/ ├── index.ts barrel ├── types.ts Session, RevokeResult, AdoptResult, RefreshResult, │ SessionEvent, EngineSession, ActiveSession, ... ├── consts.ts defaults + every event/identity/scope literal ├── errors.ts SessionDisposedError, SessionAlreadyCreatedError, │ SessionInvalidError + guards ├── engine-session.ts createEngineSession() — runes-free core ├── active-session.svelte.ts createActiveSession() — runes wrapper, reactive │ `current` + `generation` + `identity` ├── bus-helpers.ts safe `session.*` event publisher/listener helpers ├── auto-refresh.ts withAutoRefresh(engine, opts) — keyed timer │ support + visibilitychange + jitter ├── http-integration.ts createBeforeErrorHook(engine, {applyAuth?}) — │ 401 → refresh → retry, with loop guard ├── jwt.ts extractJwtExp(token) — opt-in JWT exp helper ├── ssr.ts readSessionFromCookies(cookies, opts) — SvelteKit └── test/ ├── engine-session.test.ts adopt/revoke/refresh/dedup/generation ├── active-session.svelte.test.ts reactive cells track engine.onChange ├── auto-refresh.test.ts ticker + cleanup + margin ├── http-integration.test.ts 401 hook + refresh + retry + loop guard ├── jwt.test.ts base64url + exp extraction ├── ssr.test.ts cookie parsing + invariants └── errors.test.ts error class names + guards ``` ## Alias ```js alias: { $session: 'src/arts/session'; } ``` --- ## Scope **In scope:** - Adopt an already-acquired session (user + credential + data + times) - Persist it (any `SyncStorageAdapter` from `$storage`) - Refresh it (consumer-supplied `onRefresh`, deduped + generation-guarded) - Auto-refresh wrapper (ticker + visibility + jitter) - Revoke it (local + optional consumer-supplied `onRevoke`) - Cross-tab broadcast (event + generation, never tokens) - Reactive surface (`Sess.current` / `.generation` / `.identity`) - Per-generic Standard Schema validation - SvelteKit `+layout.server.ts` hydration - 401 → refresh → retry hook for `arts/http` (loop-guarded) - Optional JWT `exp` extractor **Out of scope:** - OAuth / OpenID Connect provider exchange (Google, GitHub, ...) - Password handling / hashing / MFA flows - Authorization checks (`has()`, RBAC, claims) — that lives above session - Server-side session storage (DB, Redis) - JWT signature verification (cryptography belongs elsewhere) - CSRF protection (delegate to SvelteKit's built-in `csrf`) --- ## The `Session` type — three generics ```ts type Session = { readonly user: TUser | null; // null when anonymous readonly issuedAt: number; // strict epoch ms readonly expiresAt: number; // strict epoch ms } & SessionCredential & // required iff TCredential set SessionData; // required iff TData set ``` Times are **strict epoch milliseconds**. The engine does not normalise `Date` or ISO strings — pass `Date.now() + N` directly. If you have a `Date` or ISO string, use `toEpochMs(value)` from `$libs/days`: ```ts import { toEpochMs } from '$libs/days'; await Sess.adopt({ user, credential, issuedAt: toEpochMs(payload.iat * 1000), expiresAt: toEpochMs(payload.exp * 1000) }); ``` Conditional credential / data slots use `[T] extends [undefined]` so union distribution does not fire — `TCredential = string | undefined` correctly means "required, may be the literal string undefined", not "optional". `SESSION_NEVER_EXPIRES` (`8_640_000_000_000_000`) is the canonical sentinel for long-lived credentials (API keys, mTLS). --- ## Actor metadata (optional) Orthogonal to identity. Identity answers "what's in the session?"; actor answers "what nature does the client driving it have?". A session can be `automated` whether `anonymous` (scraping bot, crawler) or `identified` (service account, AI agent with API key). ```ts interface SessionActor { readonly kind: 'unknown' | 'human' | 'automated'; readonly source?: | 'user_agent' | 'captcha' | 'fingerprint' | 'api_key' | 'server_assertion' | 'manual' | (string & {}); // open — keep custom literals readonly confidence?: number; // [0, 1] } ``` Optional throughout: apps that don't classify actors omit it and pay nothing. When present the engine validates structural invariants (kind in enum, confidence in `[0, 1]`) and runs `schemas.actor` if configured. ```ts // UC-4 — anonymous cart with bot-detection signal: await Sess.adopt({ user: null, data: { cartId: 'cart_123' }, actor: { kind: 'automated', source: 'user_agent', confidence: 0.87 }, issuedAt: Date.now(), expiresAt: Date.now() + 60_000 }); // Service account / AI agent with API key: await Sess.adopt({ user: serviceAccount, credential: { apiKey: 'sk_…' }, actor: { kind: 'automated', source: 'api_key', confidence: 1 }, issuedAt: Date.now(), expiresAt: Date.now() + 86_400_000 }); ``` Actor changes propagate via a regular `adopt()`: ```ts await Sess.adopt({ ...Sess.current!, actor: { kind: 'automated', source: 'captcha', confidence: 0.91 } }); // Emits ADOPTED — no separate ACTOR_CHANGED event needed. ``` Actor is **not derivable** from `current.user` (a service account looks identical to a human user) — the app supplies it from its detector (UA parsing, captcha, fingerprint, server-side assertion). Sess only persists, validates and transports. There is intentionally **no `Sess.actor` getter, no `isBot()`/`isHuman()` helper**. Read `Sess.current?.actor` directly — convenience methods would start leaking product semantics into the runtime layer. --- ## Identity state ```ts type SessionIdentityState = 'none' | 'anonymous' | 'identified'; Sess.identity; // 'none' when current === null // 'anonymous' when current.user === null // 'identified' when current.user !== null ``` Anonymous sessions are how UC-4 (anonymous cart that becomes a logged-in cart) is modelled: `current` is non-null and carries `data.cartId`, but `user` is `null` until login. The engine **skips** `schemas.user` when `user === null` — a userSchema that validates `TUser` would otherwise reject the anonymous case. --- ## Tagged results — never `void` ### `AdoptResult` ```ts type AdoptResult = | { ok: true; session: Session<...> } | { ok: false; reason: 'validation_failed'; field: 'user' | 'credential' | 'data' | 'actor'; issues: ReadonlyArray } | { ok: false; reason: 'invariant_failed'; invariant: 'issuedAt_not_finite' | 'expiresAt_not_finite' | 'expiresAt_before_issuedAt' | 'actor_kind_invalid' | 'actor_confidence_out_of_range' }; ``` ### `RefreshResult` ```ts type RefreshResult = | { status: 'refreshed'; session: Session<...> } | { status: 'expired' } | { status: 'failed'; error: unknown; session: Session<...> } | { status: 'skipped'; reason: 'no_session' | 'no_refresh_fn' | 'stale_generation' }; ``` ### `RevokeResult` ```ts type RevokeResult = | { localRevoked: true; globalRevoked: true; scope: 'global' } | { localRevoked: true; globalRevoked: false; scope: 'local'; reason?: 'missing_revoke_url' | 'network_error' | 'server_rejected' | 'no_session'; }; const r = await Sess.revoke({ scope: 'global' }); if (r.globalRevoked) toast('Signed out everywhere.'); else toast(`Signed out on this device. (${r.reason ?? ''})`); ``` `REVOKED` fires regardless — locally you ARE signed out. --- ## `RefreshFn` contract — null = fatal, throw = transient The single most important rule. Encode it consciously in your `onRefresh`: ```ts const onRefresh: RefreshFn = async (current) => { let response: Response; try { response = await fetch('/api/refresh', { method: 'POST', body: JSON.stringify({ refreshToken: current.credential.refreshToken }) }); } catch (err) { // Transient: network failed. Throw → engine preserves the // session and emits REFRESH_FAILED. Auto-refresh / 401 hook // will try again later. throw err; } if (response.status === 401 || response.status === 403) { // Fatal: refresh credential is dead. Return null → engine // auto-revokes locally and emits EXPIRED. return null; } if (!response.ok) throw new Error(`refresh failed: ${response.status}`); return await response.json(); // engine adopts as REFRESHED }; ``` Mismatch this contract and a flaky network logs your users out. --- ## `RevokeFn` contract — boolean answer ```ts type RevokeFn = ( current: Session, ctx: RevokeContext ) => Promise; ``` - Resolves `true` → engine sets `scope: 'global'`. - Resolves `false` → engine degrades to `scope: 'local'` with `reason: 'server_rejected'`. - Throws → degrades to `scope: 'local'` with `reason: 'network_error'`. The handler is called with the snapshot **before** local revocation — read whatever you need from `current.credential` to talk to the server. The local snapshot is cleared after the handler resolves regardless. When `onRevoke` is configured, `Sess.revoke()` defaults to `scope: 'global'` — cookie-auth apps almost always want the server to clear its cookie too. Use `Sess.revoke({ scope: 'local' })` to bypass the handler. --- ## Generation guard — why it exists The engine maintains a `generation` counter incremented on every `adopt()` / `revoke()` / external change (storage event / broadcast). The counter is captured at the start of every `refresh()`. If the captured value differs when the refresh resolves, the result is **discarded silently** with `status: 'skipped', reason: 'stale_generation'`. This prevents three race classes: 1. **Local revoke during refresh.** User clicks "Logout" → `revoke()` bumps generation → slow refresh resolves with new tokens → guard discards → session stays revoked. 2. **Cross-tab logout during refresh.** Tab A logs out → storage event in tab B bumps tab B's generation → tab B's in-flight refresh is discarded. 3. **Re-adopt during refresh.** `adopt({differentUser})` runs while refresh is in flight → guard prevents the refresh from stamping the old user's data over the new one. --- ## Reactive surface `ActiveSession` exposes three reactive properties — all backed by `$state`, all updated through the same `engine.onChange` dispatch path the external listeners use: ```ts Sess.current; // Session | null Sess.generation; // number Sess.identity; // 'none' | 'anonymous' | 'identified' ``` ```svelte {#if Sess.current}

Hi {Sess.current.user?.email}

{:else if Sess.identity === 'anonymous'}

Cart: {Sess.current?.data.cartId}

{/if} ``` Mutations always go through methods (`adopt`, `revoke`, `refresh`) — the reactive cells are read-only. --- ## API ### `createEngineSession(options)` / `createActiveSession(options)` ```ts const Sess = createActiveSession({ schemas: { user: UserSchema, credential: CredentialSchema, data: CartSchema }, storage: { adapter: localAdapter, key: 'app:session' }, onRefresh: async (current, ctx) => { ... }, onRevoke: async (current, ctx) => { ... }, logger: App.logger, bus: App.bus, broadcastChannel: 'my-app:session' }); ``` All options are optional — the engine works as a pure in-memory store without storage / refresh / revoke handlers. ### Engine methods (also on Active) ```ts Sess.adopt(session) // → AdoptResult, validates schemas + invariants Sess.adoptServer(session) // SSR: skips schemas; throws SessionInvalidError // on bad invariants Sess.refresh() // → RefreshResult, deduped + generation-guarded Sess.revoke(opts?) // → RevokeResult, defaults global if onRevoke set Sess.clearLocal() // local-only revoke, no network call Sess.onChange(listener) // INITIAL_SESSION fires synchronously Sess.dispose() ``` ### Wrappers ```ts const stop = withAutoRefresh(Sess, { tickMs: 30_000, marginMs: 90_000, jitterMs: 5_000, refreshOnVisible: true, timers: App.timers // optional when using active-app; omit for native interval }); // ... later stop(); import { createBeforeErrorHook } from '$session'; import { createEngineHttp } from '$http'; const http = createEngineHttp({ hooks: { beforeError: [ createBeforeErrorHook(Sess, { applyAuth: (request, session) => { request.headers.set('authorization', `Bearer ${session.credential.accessToken}`); } }) ] } }); ``` The 401 hook: 1. Fires when a response returns 401. 2. Calls `engine.refresh()` (deduped — N concurrent 401s share one). 3. If refreshed: mutates the request via `applyAuth` (omit for cookie auth — the browser resends the new cookies), sets a sentinel header, and re-issues `fetch` returning the new `Response`. 4. If the retry itself returns 401, the hook fires again, sees the sentinel, and bails out — no infinite loop. 5. If `expired`: engine has already auto-revoked locally; the 401 propagates to the caller as `kind: 'http'`, and `Sess.onChange` subscribers handle the `EXPIRED` event. ### Bus integration `session` can publish safe module events when a bus is injected: ```ts import { createEngineBus } from '$bus'; import { createEngineSession, SESSION_EVENT_CHANGED, type SessEventMap } from '$session'; const Bus = createEngineBus(); Bus.on(SESSION_EVENT_CHANGED, (event) => { console.log(event.payload.event, event.payload.identity); }); const Sess = createEngineSession({ bus: Bus }); ``` Published module events: | Event | When | | --- | --- | | `SESSION_EVENT_CHANGED` | Any lifecycle event except `INITIAL_SESSION`. | | `SESSION_EVENT_IDENTITY_CHANGED` | `identity.from !== identity.to`. | | `SESSION_EVENT_REVOKED` | `REVOKED`. | | `SESSION_EVENT_EXPIRED` | `EXPIRED`. | | `SESSION_EVENT_REFRESHED` | `REFRESHED`. | The bus payload is intentionally small: ```ts interface SessLifecyclePayload { readonly event: SessionEvent; readonly generation: number; readonly identity: { readonly from: SessionIdentityState; readonly to: SessionIdentityState }; } ``` It does **not** include `current`, `previous`, `user`, `credential`, tokens or session `data`. Code that needs the full snapshot should use `Sess.onChange(...)` or read `Sess.current` directly. `createEngineSession({ bus })` publishes from the engine. `createActiveSession` publishes from the active wrapper after `$state` has been updated, so consumers that react through `App.bus` see the latest `Sess.current` in the same tick. When registered through the App service schema: ```ts const App = createActiveApp({ services: { session: defineActiveSession({ ... }) } }); await App.session.adopt({ user, credential, ... }); ``` `defineActiveSession(...)` makes the App builder inject `Logger` and `Bus` automatically. The session art publishes its own `SESSION_EVENT_*` events on `App.bus`; cross-module reactions live in orca presets at the App level (`applyCacheClearOnIdentityChange`, `applyPermInvalidateOnIdentityChange`, etc.) — the standard set is wired by `applyStandardOrca(App)`. ### Helpers ```ts import { extractJwtExp } from '$session/jwt'; import { toEpochMs } from '$libs/days'; await Sess.adopt({ user, credential, issuedAt: Date.now(), expiresAt: extractJwtExp(accessToken) ?? Date.now() + 3_600_000 }); import { readSessionFromCookies } from '$session/ssr'; event.locals.session = readSessionFromCookies(event.cookies, { key: 'app:session' }); ``` --- ## Lifecycle events ```ts type SessionEvent = | 'INITIAL_SESSION' // sync on subscribe; current snapshot or null | 'ADOPTED' // adopt() succeeded | 'ADOPTED_SERVER' // adoptServer() was called (SSR hydration) | 'REFRESHED' // refresh() returned a new session | 'REFRESH_FAILED' // refresh() threw (transient); session preserved | 'EXPIRED' // refresh() returned null (fatal); auto-revoked | 'REVOKED' // revoke() (any scope, including degraded global) | 'EXTERNAL_CHANGED'; // cross-tab broadcast updated local state ``` `onChange()` invokes the listener **synchronously once** with `INITIAL_SESSION` so the subscriber is the single source of truth — no "subscribed-after-the-snapshot-was-set" race. The change payload is rich: ```ts interface SessionChange { readonly event: SessionEvent; readonly current: Session | null; readonly previous: Session | null; readonly generation: number; readonly identity: { from: SessionIdentityState; to: SessionIdentityState }; readonly error?: unknown; // populated for REFRESH_FAILED readonly revoke?: RevokeResult; // populated for REVOKED } ``` Consumers never compare `prev` to `curr` by hand — `identity.from/to` and the typed event already carry the discriminator. --- ## SvelteKit integration ### Server: read cookie in `hooks.server.ts` ```ts import type { Handle } from '@sveltejs/kit'; import { readSessionFromCookies } from '$session/ssr'; export const handle: Handle = async ({ event, resolve }) => { event.locals.session = readSessionFromCookies(event.cookies, { key: 'app:session' }); return resolve(event); }; ``` `readSessionFromCookies` returns `null` on missing / unparseable / invalid payloads (missing required fields, non-finite times, expiresAt < issuedAt) — same invariant set the engine enforces. ### Pass to client via `+layout.server.ts` ```ts export const load: LayoutServerLoad = ({ locals }) => ({ session: locals.session }); ``` ### Hydrate on the client without re-validation ```svelte ``` `adoptServer` skips schema validation (the server already validated) **but enforces invariants** — bad timestamps throw `SessionInvalidError`, surfacing the bug at the boundary instead of poisoning the engine state. --- ## Composition with App ```ts const App = createActiveApp({ services: { session: defineActiveSession({ schemas: { user: UserSchema }, storage: { adapter: localAdapter, key: 'app:session' }, onRefresh: async (current) => { const r = await App.http.post('/api/refresh', { body: { refreshToken: current.credential.refreshToken }, schema: SessionResponseSchema }); return r.ok ? r.value : null; }, onRevoke: async (current) => { const r = await App.http.post('/api/logout', { body: { refreshToken: current.credential.refreshToken } }); return r.ok; } }) } }); applyStandardOrca(App); // cross-module reactions on identity changes ``` `defineActiveSession(...)` makes the App builder inject `Logger` and `Bus`; `App.http` is reachable through closure capture inside the handlers. The session art publishes `SESSION_EVENT_*` directly on `App.bus`. --- ## Cross-tab sync The engine opens a `BroadcastChannel` (default legacy key `'arts:sess'`, overridable via `broadcastChannel`) and emits `{ type, event, generation }` on every commit — never tokens. Receivers re-read the storage adapter, validate the payload, freeze it, and emit `EXTERNAL_CHANGED`. Storage adapters that already have an `onChange` (like `withBroadcast(localAdapter)`) deliver the same signal through both paths — the engine dedups identical snapshots so only one `EXTERNAL_CHANGED` fires per real change. Adapters without `onChange` (memory, raw `sessionStorage`) get a single warning at construction. Wrap with `$storage`'s `withBroadcast(...)` to add cross-tab sync to any adapter. --- ## Errors | Class | When | Behavior | | ------------------------- | ---------------------------------------- | -------------------------------------- | | `SessionDisposedError` | mutator called after `dispose()` | **Thrown** + logged via `logger.error` | | `SessionInvalidError` | `adoptServer()` invariant violation | **Thrown** + logged via `logger.error` | | `SessionAlreadyCreatedError` | session service registered twice | **Thrown** by App factory | Type guards: `isSessionDisposedError`, `isSessionInvalidError`, `isSessionAlreadyCreatedError`. Runtime conditions (validation failures, refresh transients, revoke degradation) are **not** exceptions — they are tagged result fields (`AdoptResult`, `RefreshResult`, `RevokeResult`) and lifecycle events (`REFRESH_FAILED`, `EXPIRED`). Exceptions are reserved for programmer errors where catching at the call site is the right pattern. --- ## Testing Use `createMemoryAdapter()` from `$storage` for isolation. Schemas can come from any Standard Schema vendor — test fixtures often hand-roll a small schema rather than pulling Sium for a single field. ```ts import { createEngineSession } from '$session'; import { createMemoryAdapter } from '$storage'; const session = createEngineSession({ schemas: { user: { '~standard': { /* ... */ } } }, storage: { adapter: createMemoryAdapter(), key: 'session' }, onRefresh: async () => null, onRevoke: async () => true }); ``` The engine tests at `src/arts/session/test/engine-session.test.ts` cover the race-condition cases (concurrent refresh dedup, generation guard discard on revoke-during-refresh and external-change-during-refresh, fatal vs transient distinction, invariant rejection of stored payloads, freeze of hydrated snapshots). Read them before adding refresh-adjacent features. --- ## Bundle profile | Layer | Approx. size (min) | | ------------------------------------ | ------------------ | | Engine + types + errors + consts | ~4 KB | | Active wrapper (runes) | +1 KB | | Auto-refresh wrapper | +1 KB | | HTTP integration (401 retry) | +0.5 KB | | JWT helper | +0.3 KB | | SSR helper | +0.2 KB | | **Total when everything is reached** | **~7 KB** | Zero runtime dependencies. The `StandardSchemaV1` import is type-only.