session
| Subpath | @noy-db/hub/session |
| Factory | withSession() |
| Cluster | G — 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.verifyPasswordSlotfrom@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.ttlMsdefaults to 60 minutes.resolveSession(token)→Promise<UnlockedKeyring>— turn a token back into a keyring. ThrowsSessionExpiredError/SessionNotFoundError.revokeSession(sessionId)→void— drop one session's key from the store.revokeAllSessions()→void— clear every active session (called bydb.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 aSessionPolicy; throws a plainError(not aNoydbError) on a malformed policy. Runs automatically whencreateNoydb({ sessionPolicy })is used withwithSession().withSession()→SessionStrategy— the opt-in factory passed tocreateNoydb({ sessionStrategy }).createEnforcer(opts)→PolicyEnforcer— builds a stateful timer/listener object for one session from{ policy, sessionId, onRevoke }. Nothing inNoydbinstalls one automatically today; call it yourself and drive it withtouch()/checkOperation()if you want idle/absolute-timeout or re-auth-gate enforcement (see Usage above).PolicyEnforcer— the classcreateEnforcer()returns:touch()(reset the idle timer),checkOperation(op)(throw ifopneeds re-auth or the absolute timeout has passed),destroy()(tear down timers/listeners), plusidleMs/ageMsgetters.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 soinstanceofchecks don't need the root@noy-db/hubbarrel.
SessionPolicy
| Option | Type | Default | Effect |
|---|---|---|---|
idleTimeoutMs | number | undefined (no idle limit) | Session is revoked if no operation runs for this long; the clock resets on every successful operation. |
absoluteTimeoutMs | number | undefined (no absolute limit) | Session is revoked this long after creation, regardless of activity. Overrides idleTimeoutMs. |
requireReAuthFor | readonly ('export' | 'grant' | 'revoke' | 'rotate' | 'changeSecret')[] | [] | Operations that throw SessionPolicyError until the user re-unlocks, even with a live session. |
lockOnBackground | boolean | false | Revoke 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
| Option | Type | Default | Effect |
|---|---|---|---|
ttlMs | number | 3_600_000 (60 minutes) | How long the token is valid; resolveSession() throws SessionExpiredError past this. |
DevUnlockOptions
| Option | Type | Default | Effect |
|---|---|---|---|
acknowledge | string | — required | Must be exactly 'I-UNDERSTAND-THIS-DISABLES-UNLOCK-SECURITY', or enableDevUnlock() throws. |
persistAcrossTabs | boolean | false | false 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
sessionPolicyincreateNoydbwithoutsessionStrategy: 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 aPolicyEnforceryourself (see Usage) - broker — a cloud-store-backed app that gates storage-backend access with
withBroker()typically also gates app sessions withwithSession(); the two are independent - on-webauthn / on-oidc / on-magic-link / on-password (separate packages) — unlock paths that produce the
UnlockedKeyringyou pass tocreateSession()
Edge cases & limits
lockOnBackgroundregisters avisibilitychangelistener; no-op in non-browser environments.- Absolute timeout overrides idle timeout — you can't extend a session past
absoluteTimeoutMsno matter how active you are. PolicyEnforcer.checkOperation(op)throwsSessionPolicyErrorfor anoplisted inrequireReAuthFor— but only when your own code calls it (see Usage).db.grant,db.revoke,db.rotate, anddb.changeSecretdo not call it on their own; there is currently no public way to attach an enforcer to aNoydbinstance.- 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.