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.

547 lines
15 KiB

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<TUser, TCredential = undefined, TData = undefined>(
options: EngineSessionOptions<TUser, TCredential, TData>
): EngineSession<TUser, TCredential, TData> {
const { schemas, storage, onRefresh, onRevoke, logger } = options;
const diagnostics = createSessionDiagnostics(logger);
type Snap = Session<TUser, TCredential, TData>;
let snapshot: Snap | null = null;
let generation = 0;
let refreshing: Promise<RefreshResult<TUser, TCredential, TData>> | null = null;
let disposed = false;
const listeners = new Set<SessionListener<TUser, TCredential, TData>>();
const detachers: Array<() => void> = [];
// ── Storage sync ────────────────────────────────────────────────────────
if (storage !== undefined) {
const promoted = hydrateSessionStorage<TUser, TCredential, TData>(storage, diagnostics);
if (promoted !== null) snapshot = promoted;
const detachStorage = wireSessionStorageSync<TUser, TCredential, TData>({
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<TUser, TCredential, TData>['event']
>({
channelName,
storage,
applyExternalChange: (next) => {
if (next !== null || snapshot !== null) applyExternalChange(next);
}
});
if (broadcastSync !== undefined) detachers.push(() => broadcastSync.detach());
function broadcast(event: SessionChange<TUser, TCredential, TData>['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<TUser, TCredential, TData>): 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<TUser, TCredential, TData> = {
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<TUser, TCredential, TData>({
user: input.user,
credential: readField<TCredential | undefined>(input, FIELD_CREDENTIAL),
data: readField<TData | undefined>(input, FIELD_DATA),
actor: readField<SessionActor | undefined>(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<RefreshResult<TUser, TCredential, TData>> => {
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;
}

Powered by TurnKey Linux.