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
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;
|
|
}
|