import { ADOPT_REASON_INVARIANT_FAILED, ADOPT_REASON_VALIDATION_FAILED, DEFAULT_BROADCAST_CHANNEL, ENGINE_METHOD_ADOPT, ENGINE_METHOD_ADOPT_SERVER, ENGINE_METHOD_CLEAR_LOCAL, ENGINE_METHOD_REFRESH, ENGINE_METHOD_REVOKE, EVENT_ADOPTED, EVENT_ADOPTED_SERVER, EVENT_EXPIRED, EVENT_EXTERNAL_CHANGED, EVENT_INITIAL_SESSION, EVENT_REFRESHED, EVENT_REFRESH_FAILED, EVENT_REVOKED, FIELD_ACTOR, FIELD_CREDENTIAL, FIELD_DATA, FIELD_USER, IDENTITY_ANONYMOUS, IDENTITY_IDENTIFIED, IDENTITY_NONE, REFRESH_STATUS_EXPIRED, REFRESH_STATUS_FAILED, REFRESH_STATUS_REFRESHED, REFRESH_STATUS_SKIPPED, REVOKE_REASON_MISSING_REVOKE_URL, REVOKE_REASON_NETWORK_ERROR, REVOKE_REASON_NO_SESSION, REVOKE_REASON_SERVER_REJECTED, REVOKE_SCOPE_GLOBAL, REVOKE_SCOPE_LOCAL, SESSION_DIAGNOSTIC_EVENTS, SKIP_REASON_NO_REFRESH_FN, SKIP_REASON_NO_SESSION, SKIP_REASON_STALE_GENERATION, adoptServerErrorMessage, disposedErrorMessage } from './consts.ts'; import { createSessionDiagnostics, emitSessionDiagnostic } from './diagnostics.ts'; import { SessDisposedError, SessInvalidSessionError } from './errors.ts'; import { freezeSession, readField, sameSnapshot, toError } from './session-helpers.ts'; import { createSessionBroadcastSync, hydrateSessionStorage, wireSessionStorageSync } from './session-sync.ts'; import { checkSessionInvariants, validateSessionCandidate } from './session-validation.ts'; import type { AdoptResultField, EngineSession, EngineSessionOptions, RefreshResult, RevokeOptions, RevokeResult, Session, SessionActor, SessionChange, SessionIdentityState, SessionListener } from './types.ts'; function refreshValidationDiagnosticEvent(field: AdoptResultField) { switch (field) { case FIELD_USER: return SESSION_DIAGNOSTIC_EVENTS.REFRESH_USER_SCHEMA_FAILED; case FIELD_CREDENTIAL: return SESSION_DIAGNOSTIC_EVENTS.REFRESH_CREDENTIAL_SCHEMA_FAILED; case FIELD_DATA: return SESSION_DIAGNOSTIC_EVENTS.REFRESH_DATA_SCHEMA_FAILED; case FIELD_ACTOR: return SESSION_DIAGNOSTIC_EVENTS.REFRESH_ACTOR_SCHEMA_FAILED; } } /** * Build a fresh `EngineSession`. Runes-free — safe to import from server-only * modules. The Active wrapper layers reactive state on top. * * Internally maintains: * - State cell (Session or null) * - `generation` counter incremented on every adopt/revoke/external change * (guards stale refresh from any source) * - `refreshing` slot that dedupes concurrent `refresh()` * - Listener set with synchronous `INITIAL_SESSION` dispatch on subscribe * - Optional storage sync * - Optional `BroadcastChannel` (event + generation only — never tokens) */ export function createEngineSession( options: EngineSessionOptions ): EngineSession { const { schemas, storage, onRefresh, onRevoke, logger } = options; const diagnostics = createSessionDiagnostics(logger); type Snap = Session; let snapshot: Snap | null = null; let generation = 0; let refreshing: Promise> | null = null; let disposed = false; const listeners = new Set>(); const detachers: Array<() => void> = []; // ── Storage sync ──────────────────────────────────────────────────────── if (storage !== undefined) { const promoted = hydrateSessionStorage(storage, diagnostics); if (promoted !== null) snapshot = promoted; const detachStorage = wireSessionStorageSync({ storage, diagnostics, warnNoOnChange: logger !== undefined, applyExternalChange: (next) => { if (next !== null || snapshot !== null) applyExternalChange(next); } }); if (detachStorage !== undefined) detachers.push(detachStorage); } // ── Cross-tab broadcast (event + generation only, never tokens) ───────── const channelName = options.broadcastChannel ?? DEFAULT_BROADCAST_CHANNEL; const broadcastSync = createSessionBroadcastSync< TUser, TCredential, TData, SessionChange['event'] >({ channelName, storage, applyExternalChange: (next) => { if (next !== null || snapshot !== null) applyExternalChange(next); } }); if (broadcastSync !== undefined) detachers.push(() => broadcastSync.detach()); function broadcast(event: SessionChange['event']): void { broadcastSync?.post(event, generation); } // ── State helpers ─────────────────────────────────────────────────────── function computeIdentity(s: Snap | null): SessionIdentityState { if (s === null) return IDENTITY_NONE; return s.user === null ? IDENTITY_ANONYMOUS : IDENTITY_IDENTIFIED; } /** * Apply a snapshot that came from outside this engine (storage event, * BroadcastChannel). Dedups against the current snapshot so a storage * event + a broadcast event for the same change only fire one * `EXTERNAL_CHANGED`. Increments `generation` so refresh promises * started before the external change cannot resurrect an old snapshot. */ function applyExternalChange(next: Snap | null): void { const prev = snapshot; if (sameSnapshot(prev, next)) return; const fromIdentity = computeIdentity(prev); generation += 1; snapshot = next; const toIdentity = computeIdentity(next); dispatch({ event: EVENT_EXTERNAL_CHANGED, current: next, previous: prev, generation, identity: { from: fromIdentity, to: toIdentity } }); } function persist(value: Snap | null): void { if (storage === undefined) return; try { if (value === null) { storage.adapter.removeItem(storage.key); } else { storage.adapter.setItem(storage.key, JSON.stringify(value)); } } catch (err) { emitSessionDiagnostic(diagnostics, SESSION_DIAGNOSTIC_EVENTS.STORAGE_PERSIST_FAILED, { error: toError(err) }); } } function dispatch(change: SessionChange): void { for (const listener of listeners) { try { listener(change); } catch (err) { emitSessionDiagnostic(diagnostics, SESSION_DIAGNOSTIC_EVENTS.LISTENER_THREW, { error: toError(err), event: change.event }); } } } function commitAdoption( next: Snap, event: typeof EVENT_ADOPTED | typeof EVENT_ADOPTED_SERVER | typeof EVENT_REFRESHED ): void { const prev = snapshot; const fromIdentity = computeIdentity(prev); generation += 1; snapshot = next; persist(next); const toIdentity = computeIdentity(next); dispatch({ event, current: next, previous: prev, generation, identity: { from: fromIdentity, to: toIdentity } }); broadcast(event); } function commitRevocation( event: typeof EVENT_REVOKED | typeof EVENT_EXPIRED, revoke?: RevokeResult ): void { const prev = snapshot; const fromIdentity = computeIdentity(prev); generation += 1; snapshot = null; persist(null); dispatch({ event, current: null, previous: prev, generation, identity: { from: fromIdentity, to: IDENTITY_NONE }, revoke }); broadcast(event); } // ── Public API ────────────────────────────────────────────────────────── function ensureLive(method: string): void { if (disposed) { const message = disposedErrorMessage(method); emitSessionDiagnostic(diagnostics, SESSION_DIAGNOSTIC_EVENTS.DISPOSED_ACCESS, { method }); throw new SessDisposedError(message); } } const engine: EngineSession = { get current() { return snapshot; }, get generation() { return generation; }, get identity() { return computeIdentity(snapshot); }, async adopt(input) { ensureLive(ENGINE_METHOD_ADOPT); const invariant = checkSessionInvariants(input); if (invariant !== null) { return { ok: false, reason: ADOPT_REASON_INVARIANT_FAILED, invariant }; } const validated = await validateSessionCandidate(input, schemas); if (!validated.ok) { return { ok: false, reason: ADOPT_REASON_VALIDATION_FAILED, field: validated.field, issues: validated.issues }; } const next = validated.session; commitAdoption(next, EVENT_ADOPTED); return { ok: true, session: next }; }, adoptServer(input) { ensureLive(ENGINE_METHOD_ADOPT_SERVER); const invariant = checkSessionInvariants(input); if (invariant !== null) { const message = adoptServerErrorMessage(invariant); emitSessionDiagnostic(diagnostics, SESSION_DIAGNOSTIC_EVENTS.ADOPT_SERVER_INVALID, { invariant }); throw new SessInvalidSessionError(invariant, message); } const next = freezeSession({ user: input.user, credential: readField(input, FIELD_CREDENTIAL), data: readField(input, FIELD_DATA), actor: readField(input, FIELD_ACTOR), issuedAt: input.issuedAt, expiresAt: input.expiresAt }); commitAdoption(next, EVENT_ADOPTED_SERVER); return next; }, async refresh() { ensureLive(ENGINE_METHOD_REFRESH); if (refreshing !== null) return refreshing; if (snapshot === null) { return { status: REFRESH_STATUS_SKIPPED, reason: SKIP_REASON_NO_SESSION }; } if (onRefresh === undefined) { return { status: REFRESH_STATUS_SKIPPED, reason: SKIP_REASON_NO_REFRESH_FN }; } const startedAt = generation; const current = snapshot; refreshing = (async (): Promise> => { try { const next = await onRefresh(current, { logger }); if (startedAt !== generation) { return { status: REFRESH_STATUS_SKIPPED, reason: SKIP_REASON_STALE_GENERATION }; } if (next === null) { commitRevocation(EVENT_EXPIRED); return { status: REFRESH_STATUS_EXPIRED }; } const invariant = checkSessionInvariants(next); if (invariant !== null) { emitSessionDiagnostic( diagnostics, SESSION_DIAGNOSTIC_EVENTS.REFRESH_INVARIANT_VIOLATED, { invariant } ); return { status: REFRESH_STATUS_FAILED, error: new TypeError(invariant), session: current }; } const validated = await validateSessionCandidate(next, schemas); if (!validated.ok) { emitSessionDiagnostic( diagnostics, refreshValidationDiagnosticEvent(validated.field), { error: validated.issues } ); return { status: REFRESH_STATUS_FAILED, error: validated.issues, session: current }; } if (startedAt !== generation) { return { status: REFRESH_STATUS_SKIPPED, reason: SKIP_REASON_STALE_GENERATION }; } commitAdoption(validated.session, EVENT_REFRESHED); return { status: REFRESH_STATUS_REFRESHED, session: validated.session }; } catch (err) { if (startedAt !== generation) { return { status: REFRESH_STATUS_SKIPPED, reason: SKIP_REASON_STALE_GENERATION }; } emitSessionDiagnostic(diagnostics, SESSION_DIAGNOSTIC_EVENTS.REFRESH_TRANSIENT_FAILURE, { error: toError(err) }); dispatch({ event: EVENT_REFRESH_FAILED, current: snapshot, previous: snapshot, generation, identity: { from: computeIdentity(snapshot), to: computeIdentity(snapshot) }, error: err }); return { status: REFRESH_STATUS_FAILED, error: err, session: current }; } finally { refreshing = null; } })(); return refreshing; }, async revoke(opts: RevokeOptions = {}) { ensureLive(ENGINE_METHOD_REVOKE); if (snapshot === null) { return { localRevoked: true, globalRevoked: false, scope: REVOKE_SCOPE_LOCAL, reason: REVOKE_REASON_NO_SESSION }; } // Default to global when an `onRevoke` handler is configured — // cookie-auth apps almost always want the server-side cookie // cleared along with the local snapshot. const requestedScope = opts.scope ?? (onRevoke !== undefined ? REVOKE_SCOPE_GLOBAL : REVOKE_SCOPE_LOCAL); let result: RevokeResult; if (requestedScope === REVOKE_SCOPE_GLOBAL) { if (onRevoke === undefined) { result = { localRevoked: true, globalRevoked: false, scope: REVOKE_SCOPE_LOCAL, reason: REVOKE_REASON_MISSING_REVOKE_URL }; emitSessionDiagnostic(diagnostics, SESSION_DIAGNOSTIC_EVENTS.REVOKE_GLOBAL_NO_HANDLER, { error: REVOKE_REASON_MISSING_REVOKE_URL }); } else { try { const ok = await onRevoke(snapshot, { logger }); if (ok) { result = { localRevoked: true, globalRevoked: true, scope: REVOKE_SCOPE_GLOBAL }; } else { result = { localRevoked: true, globalRevoked: false, scope: REVOKE_SCOPE_LOCAL, reason: REVOKE_REASON_SERVER_REJECTED }; emitSessionDiagnostic( diagnostics, SESSION_DIAGNOSTIC_EVENTS.REVOKE_GLOBAL_SERVER_REJECTED, { error: REVOKE_REASON_SERVER_REJECTED } ); } } catch (err) { result = { localRevoked: true, globalRevoked: false, scope: REVOKE_SCOPE_LOCAL, reason: REVOKE_REASON_NETWORK_ERROR }; emitSessionDiagnostic( diagnostics, SESSION_DIAGNOSTIC_EVENTS.REVOKE_GLOBAL_HANDLER_FAILED, { error: toError(err) } ); } } } else { result = { localRevoked: true, globalRevoked: false, scope: REVOKE_SCOPE_LOCAL }; } commitRevocation(EVENT_REVOKED, result); return result; }, clearLocal() { ensureLive(ENGINE_METHOD_CLEAR_LOCAL); const result: RevokeResult = snapshot === null ? { localRevoked: true, globalRevoked: false, scope: REVOKE_SCOPE_LOCAL, reason: REVOKE_REASON_NO_SESSION } : { localRevoked: true, globalRevoked: false, scope: REVOKE_SCOPE_LOCAL }; if (snapshot !== null) commitRevocation(EVENT_REVOKED, result); return result; }, onChange(listener) { if (disposed) return () => {}; listeners.add(listener); try { const identity = computeIdentity(snapshot); listener({ event: EVENT_INITIAL_SESSION, current: snapshot, previous: snapshot, generation, identity: { from: identity, to: identity } }); } catch (err) { emitSessionDiagnostic(diagnostics, SESSION_DIAGNOSTIC_EVENTS.LISTENER_THREW, { error: toError(err), event: EVENT_INITIAL_SESSION }); } return () => listeners.delete(listener); }, dispose() { if (disposed) return; disposed = true; for (const detach of detachers) { try { detach(); } catch { // best-effort } } listeners.clear(); } }; return engine; }