You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/arts/session/README.md

26 KiB

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(...).

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
});
{#if Sess.current}
	<p>Hi {Sess.current.user.email}</p>
	<button onclick={() => Sess.revoke()}>Logout</button>
{:else}
	<a href="/login">Login</a>
{/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

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

type Session<TUser, TCredential = undefined, TData = undefined> = {
	readonly user: TUser | null; // null when anonymous
	readonly issuedAt: number; // strict epoch ms
	readonly expiresAt: number; // strict epoch ms
} & SessionCredential<TCredential> & // required iff TCredential set
	SessionData<TData>; // 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:

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).

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.

// 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():

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

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

type AdoptResult<TUser, TC, TD> =
    | { ok: true; session: Session<...> }
    | { ok: false; reason: 'validation_failed';
        field: 'user' | 'credential' | 'data' | 'actor';
        issues: ReadonlyArray<StandardSchemaV1.Issue> }
    | { ok: false; reason: 'invariant_failed';
        invariant: 'issuedAt_not_finite'
                 | 'expiresAt_not_finite'
                 | 'expiresAt_before_issuedAt'
                 | 'actor_kind_invalid'
                 | 'actor_confidence_out_of_range' };

RefreshResult

type RefreshResult<TUser, TC, TD> =
    | { status: 'refreshed'; session: Session<...> }
    | { status: 'expired' }
    | { status: 'failed'; error: unknown; session: Session<...> }
    | { status: 'skipped'; reason: 'no_session' | 'no_refresh_fn'
                                  | 'stale_generation' };

RevokeResult

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:

const onRefresh: RefreshFn<User, JwtCredential> = 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

type RevokeFn<TUser, TC, TD> = (
	current: Session<TUser, TC, TD>,
	ctx: RevokeContext
) => Promise<boolean>;
  • 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:

Sess.current; // Session | null
Sess.generation; // number
Sess.identity; // 'none' | 'anonymous' | 'identified'
{#if Sess.current}
	<p>Hi {Sess.current.user?.email}</p>
{:else if Sess.identity === 'anonymous'}
	<p>Cart: {Sess.current?.data.cartId}</p>
{/if}

<button disabled={Sess.identity === 'none'}>Action</button>

Mutations always go through methods (adopt, revoke, refresh) — the reactive cells are read-only.


API

createEngineSession(options) / createActiveSession(options)

const Sess = createActiveSession<User, JwtCredential, CartData>({
    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)

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

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:

import { createEngineBus } from '$bus';
import { createEngineSession, SESSION_EVENT_CHANGED, type SessEventMap } from '$session';

const Bus = createEngineBus<SessEventMap>();
Bus.on(SESSION_EVENT_CHANGED, (event) => {
	console.log(event.payload.event, event.payload.identity);
});

const Sess = createEngineSession<User>({
	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:

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:

const App = createActiveApp({
	services: {
		session: defineActiveSession<User, JwtCredential>({ ... })
	}
});

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

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

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:

interface SessionChange<TUser, TC, TD> {
	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

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

export const load: LayoutServerLoad = ({ locals }) => ({
	session: locals.session
});

Hydrate on the client without re-validation

<script>
	import { App } from '$lib/app';
	import { page } from '$app/state';

	$effect(() => {
		if (page.data.session && App.session?.current === null) {
			App.session.adoptServer(page.data.session);
		}
	});
</script>

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

const App = createActiveApp({
	services: {
		session: defineActiveSession<User, JwtCredential>({
			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.

import { createEngineSession } from '$session';
import { createMemoryAdapter } from '$storage';

const session = createEngineSession<User>({
	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.

Powered by TurnKey Linux.