{: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.