stable

session

Serializable session tokens that skip re-running the unlock ceremony, plus a SessionPolicy for idle/absolute timeouts and re-auth gates.
Subpath@noy-db/hub/session
FactorywithSession()
ClusterG — Collaboration & Auth
LOC cost~495 (off-bundle when not opted in)

What it does

Every noy-db vault starts locked: the app calls an unlock function (secret, WebAuthn, OIDC, magic-link — one of the on-* packages) and gets back a keyring, the decrypted per-user key material held in memory for that call. @noy-db/hub/session builds two things on top of a single unlock: session tokens — a serializable handle you can stash in sessionStorage and hand to resolveSession() later so the app doesn't re-run the unlock ceremony on every call — and a session policy shape (idle/absolute timeouts, an optional lock-on-background, and a list of high-risk operations that should require re-auth) that a PolicyEnforcer object can enforce for you once you drive it from your own code (see Usage). It also keeps a process-wide registry of active sessions so a "log out everywhere" action can revoke them all at once.

When you need it

  • Long-running web apps that need idle/absolute timeouts
  • Sensitive operations that must require fresh re-auth
  • Mobile/desktop apps that should lock when backgrounded
  • Multi-tab coordination via revokeSession(id) / revokeAllSessions()

Prerequisites

  • An enrolled unlock method to re-verify against — the policy engine decides when re-auth is due; how is your app calling an on-* verifier (e.g. verifyPasswordSlot from @noy-db/on-password) and reporting the result. Without an enrolled method there is nothing to re-verify.
  • A sessionPolicy — the factory enables enforcement; the policy object declares which operations require fresh auth.

Opt in

import { createNoydb } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
import { withSession } from '@noy-db/hub/session'

const db = await createNoydb({
  store: toMemory(),
  user: 'alice',
  sessionStrategy: withSession(),

  sessionPolicy: {
    idleTimeoutMs: 15 * 60_000,
    absoluteTimeoutMs: 8 * 60 * 60_000,
    lockOnBackground: true,
    requireReAuthFor: ['export', 'grant', 'rotate'],
  },
})

Dev-unlock (separate, no strategy):

import { enableDevUnlock, loadDevUnlock, isDevUnlockActive } from '@noy-db/hub'

Usage

Create and resolve a session token

createSession() wraps an already-unlocked keyring in a token; resolveSession() turns that token back into a keyring later, without re-running the unlock ceremony.

import { createNoydb } from '@noy-db/hub'
import { withSession, createSession, resolveSession } from '@noy-db/hub/session'
import { verifyPasswordSlot } from '@noy-db/on-password'

const db = await createNoydb({
  store: myStore,
  user: 'alice',
  sessionStrategy: withSession(),
})

// verifyPasswordSlot() is one unlock path — it returns an UnlockedKeyring
// (role, permissions, and the DEKs used to read/write each collection).
// WebAuthn, OIDC, and magic-link unlocks return the same shape.
const keyring = await verifyPasswordSlot(passwordSlot, 'correct horse battery staple')

const { token, sessionId } = await createSession(keyring, 'acme-corp', {
  ttlMs: 15 * 60_000, // defaults to 60 minutes if omitted
})
{
  "_noydb_session": 1,
  "sessionId": "01J8Z3F9R2K4Q6T8V0W2X4Y6Z8",
  "userId": "alice",
  "vault": "acme-corp",
  "role": "owner",
  "expiresAt": "2026-07-09T13:15:00.000Z",
  "wrappedKek": "Qk3f9CjH2s0LpV…",
  "kekIv": "u8xQnR2b…"
}

token is JSON-serializable — store it in sessionStorage. Despite the field name, wrappedKek does not carry the KEK itself (the KEK is a non-extractable key and can't be exported); it holds the vault's DEK set plus role/permissions, encrypted with a separate, non-extractable session key that lives only in a module-level Map for the life of the tab. Closing the tab drops the session key and makes every token issued in it permanently unresolvable — that's the built-in "closing the tab logs you out" behavior.

// Later — same tab, e.g. after a client-side navigation:
const keyringAgain = await resolveSession(token)
// → { userId: 'alice', displayName: 'Alice', role: 'owner', permissions: { 'acme-corp': 'rw' }, deks: Map(2) {…}, kek: null, salt: Uint8Array(32), authenticators: [] }

kek comes back null — sessions only need the DEKs to read/write records, not the KEK. If token.expiresAt has passed, resolveSession() throws SessionExpiredError; if the session key isn't in the store (tab reloaded, or the session was revoked), it throws SessionNotFoundError.

Validate a policy, then enforce it yourself

Passing sessionPolicy to createNoydb() validates it at construction time — validateSessionPolicy() (called automatically when withSession() is wired in) throws a plain Error if the shape is malformed, e.g. idleTimeoutMs >= absoluteTimeoutMs.

Validation is as far as the wiring goes today. Idle/absolute timeouts and the requireReAuthFor gate are not currently enforced automatically: db.grant(), db.revoke(), db.rotate(), and db.changeSecret() do not check sessionPolicy on their own, and there is no public API to attach an enforcer to a running Noydb instance. If you need that behavior today, drive a PolicyEnforcer yourself with createEnforcer():

import { createEnforcer } from '@noy-db/hub/session'

const enforcer = createEnforcer({
  policy: {
    idleTimeoutMs: 15 * 60_000,
    absoluteTimeoutMs: 8 * 60 * 60_000,
    requireReAuthFor: ['grant', 'rotate'],
  },
  sessionId,
  onRevoke: (reason) => {
    // 'idle' | 'absolute' | 'background' — decide what your app does,
    // e.g. clear local UI state and prompt for re-unlock.
  },
})

enforcer.touch() // call at the top of every operation you want the idle timer to track

enforcer.checkOperation('grant')
// → throws SessionPolicyError: Operation "grant" requires re-authentication per the active session policy.
// (or SessionExpiredError, thrown defensively, if absoluteTimeoutMs has already elapsed)

await db.grant('acme-corp', { grantee: 'bob@example.com', role: 'operator' })

checkOperation() is synchronous and only inspects the policy you passed in — call it yourself immediately before any operation you want gated; absoluteTimeoutMs always wins over idleTimeoutMs once the deadline passes, regardless of activity.

Check liveness and revoke

import { isSessionAlive, revokeSession, activeSessionCount } from '@noy-db/hub/session'

isSessionAlive(token)     // → true
revokeSession(sessionId)  // explicit logout — removes the session key from the store
isSessionAlive(token)     // → false
activeSessionCount()      // → 0

API

  • createSession(keyring, vault, options?) → Promise<{ token: SessionToken, sessionId: string }> — wrap an unlocked keyring in a serializable token. options.ttlMs defaults to 60 minutes.
  • resolveSession(token) → Promise<UnlockedKeyring> — turn a token back into a keyring. Throws SessionExpiredError / SessionNotFoundError.
  • revokeSession(sessionId) → void — drop one session's key from the store.
  • revokeAllSessions() → void — clear every active session (called by db.close()).
  • isSessionAlive(token) → boolean — metadata-only check (not expired + key still in the store); does not decrypt anything.
  • activeSessionCount() → number — size of the session-key store; useful for diagnostics/tests.
  • validateSessionPolicy(policy) → void — call-time validation of a SessionPolicy; throws a plain Error (not a NoydbError) on a malformed policy. Runs automatically when createNoydb({ sessionPolicy }) is used with withSession().
  • withSession() → SessionStrategy — the opt-in factory passed to createNoydb({ sessionStrategy }).
  • createEnforcer(opts) → PolicyEnforcer — builds a stateful timer/listener object for one session from { policy, sessionId, onRevoke }. Nothing in Noydb installs one automatically today; call it yourself and drive it with touch()/checkOperation() if you want idle/absolute-timeout or re-auth-gate enforcement (see Usage above).
  • PolicyEnforcer — the class createEnforcer() returns: touch() (reset the idle timer), checkOperation(op) (throw if op needs re-auth or the absolute timeout has passed), destroy() (tear down timers/listeners), plus idleMs/ageMs getters.
  • enableDevUnlock(vault, userId, keyring, options) / loadDevUnlock(vault, userId, options?) / clearDevUnlock(vault, userId, options?) / isDevUnlockActive(vault, userId, options?) — development-only persistent unlock (see below).
  • SessionExpiredError, SessionNotFoundError, SessionPolicyError — re-exported here so instanceof checks don't need the root @noy-db/hub barrel.

SessionPolicy

OptionTypeDefaultEffect
idleTimeoutMsnumberundefined (no idle limit)Session is revoked if no operation runs for this long; the clock resets on every successful operation.
absoluteTimeoutMsnumberundefined (no absolute limit)Session is revoked this long after creation, regardless of activity. Overrides idleTimeoutMs.
requireReAuthForreadonly ('export' | 'grant' | 'revoke' | 'rotate' | 'changeSecret')[][]Operations that throw SessionPolicyError until the user re-unlocks, even with a live session.
lockOnBackgroundbooleanfalseRevoke the session when the tab is hidden (visibilitychange, document.hidden === true). No-op outside a browser.

validateSessionPolicy() also rejects a policy where idleTimeoutMs >= absoluteTimeoutMs — the idle window has to fit inside the absolute one.

CreateSessionOptions

OptionTypeDefaultEffect
ttlMsnumber3_600_000 (60 minutes)How long the token is valid; resolveSession() throws SessionExpiredError past this.

DevUnlockOptions

OptionTypeDefaultEffect
acknowledgestring— requiredMust be exactly 'I-UNDERSTAND-THIS-DISABLES-UNLOCK-SECURITY', or enableDevUnlock() throws.
persistAcrossTabsbooleanfalsefalse stores in sessionStorage (cleared on tab close); true stores in localStorage.

Dev-unlock

enableDevUnlock() / loadDevUnlock() skip the unlock ceremony entirely on every page load by writing the keyring's DEKs to browser storage — it exists purely to remove secret re-entry from the hot-reload inner loop. It refuses to run unless NODE_ENV is not production and the hostname is localhost/127.0.0.1/::1/*.local, requires the exact acknowledge string above, and prints an unsuppressable console warning on first use. clearDevUnlock() removes the stored payload; isDevUnlockActive() checks whether one exists. It is a separate named import from @noy-db/hub and tree-shakes independently of withSession().

Behavior when NOT opted in

  • Setting sessionPolicy in createNoydb without sessionStrategy: withSession() throws at construction time, with a pointer to @noy-db/hub/session.
  • createEnforcer() throws under the same gate.
  • revokeAllSessions() is a silent no-op (the registry was never populated).

Pairs well with

  • team — resolveSession() restores the same role/permissions team assigned at unlock; gating grant/revoke/rotate behind re-auth requires driving a PolicyEnforcer yourself (see Usage)
  • broker — a cloud-store-backed app that gates storage-backend access with withBroker() typically also gates app sessions with withSession(); the two are independent
  • on-webauthn / on-oidc / on-magic-link / on-password (separate packages) — unlock paths that produce the UnlockedKeyring you pass to createSession()

Edge cases & limits

  • lockOnBackground registers a visibilitychange listener; no-op in non-browser environments.
  • Absolute timeout overrides idle timeout — you can't extend a session past absoluteTimeoutMs no matter how active you are.
  • PolicyEnforcer.checkOperation(op) throws SessionPolicyError for an op listed in requireReAuthFor — but only when your own code calls it (see Usage). db.grant, db.revoke, db.rotate, and db.changeSecret do not call it on their own; there is currently no public way to attach an enforcer to a Noydb instance.
  • The session key store is module-level and tab-scoped: it does not survive a full page reload, and sessions from one tab are not resolvable in another.

See also

  • metrics — planned hub-level observability (timings, cache stats, sync stats); session lifecycle timing is the kind of signal it would surface
  • Services — the full opt-in catalog
  • __tests__/session.test.ts, __tests__/session-policy.test.ts, __tests__/dev-unlock.test.ts
  • Proven by the recipe walkthrough recipe-unlock-ceremonies.