npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nxgt/janus

v0.17.1

Published

Embeddable, type-safe identities and permissions: bring your own database

Readme

@nxgt/janus

Identities and permissions as an embeddable TypeScript library: your process, your database, behind a port you can implement. Use identities alone, permissions alone, or both — see Three ways to use it.

import { z } from 'zod';
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';

const auth = janus({
	user: z.object({ email: z.email(), name: z.string() }),
	password: { login: 'email' },
	store: createMemoryStores(),
	hasher: scryptHasher(),
});

const { user, token } = await auth.signUp({ email, name, password });
const current = await auth.authenticate(request); // { user, session, token, renewed } | null

0.x. A minor version may still change the surface; the changelog says how.

Install

bun add @nxgt/janus

No runtime dependency. typescript (6) is a required peer. Your tsconfig resolves as a bundler does ("moduleResolution": "bundler", which Bun and every bundler use): the declarations import without extensions, so nodenext is not supported.

Subpaths

| Import | What it holds | | --- | --- | | @nxgt/janus | Identities: janus(), the identity stores' port and its in-memory reference (createMemoryStores), the hashers, the user events (UserEvent, UserEventListener, UserEventType). And the shared vocabulary: errors, subjects and the tuple notation, ids, pagination, time | | @nxgt/janus/permissions | Permissions: defineModel, fromField, when, permissions() — can, list, grant, revoke — the relation store's port and its in-memory reference (createMemoryRelations) | | @nxgt/janus/conformance | For adapters: the suites a store runs — describeJanusStores, describeRelationStores — their cases as data, and the reference harnesses |

A subpath appears in exports only once it exports something you should call: a published entry point is a promise.

Three ways to use it

Janus has two sides. Identities answers who is this? — users, their logins and passwords, codes and links sent by e-mail, a TOTP second factor, sessions, one-time tokens. Permissions answers may they? — a model, the tuples stored against it, and can. Each side is usable alone, and neither loads the other's code: a spec reads the import graph of each entry point and fails if one reaches into the other.

Identities only — users, logins, passwords, sessions, one-time tokens:

import { z } from 'zod';
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';

const auth = janus({
	user: z.object({ email: z.email() }),
	password: { login: 'email' },
	store: createMemoryStores(),
	hasher: scryptHasher(),
});

Permissions only — your users live elsewhere; name their types as the subjects:

import {
	createMemoryRelations,
	defineModel,
	permissions,
} from '@nxgt/janus/permissions';

const access = permissions({
	model: defineModel({
		subjects: ['user'],
		types: {
			document: {
				related: { owners: ['user'], viewers: ['user'] },
				permits: { view: ['owners', 'viewers'] },
			},
		},
	}),
	store: createMemoryRelations(),
});

await access.grant({ type: 'document', id: 'd1' }, 'viewers', { type: 'user', id: 'u1' });
await access.can({ type: 'user', id: 'u1' }, 'view', { type: 'document', id: 'd1' }); // true

Both — the user types become the subjects, and deleting a user deletes every tuple naming them:

import { z } from 'zod';
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
import {
	createMemoryRelations,
	defineModel,
	permissions,
} from '@nxgt/janus/permissions';

const relations = createMemoryRelations();

const auth = janus({
	user: z.object({ email: z.email() }),
	password: { login: 'email' },
	store: createMemoryStores(),
	relations,
	hasher: scryptHasher(),
});

const access = permissions({
	model: defineModel({
		subjects: auth.types,
		types: {
			document: {
				related: { owners: ['user'] },
				permits: { view: ['owners'] },
			},
		},
	}),
	store: relations,
});

The words used throughout — side, subject, tuple, identity stores, relation store, adapter — are defined once, in the shared vocabulary.

The one rule

An absence is null. A failure throws.

Everything else in this package is downstream of that sentence. A store that cannot answer — a refused connection, a timeout, a primary stepping down, a bug in the adapter — throws, and a caller answers 503. Mapping that to a 404, to null or to false turns an outage into a silent lockout: every user is told they do not exist. That has been measured twice in this organisation, two days apart, which is why it is a term of the port here rather than a note in the documentation.

API

Errors

import { JanusError, StoreFailure, NotFoundError, type JanusErrorCode } from '@nxgt/janus';

async function signIn(email: string, password: string): Promise<Response> {
	try {
		const { token } = await auth.signIn({ email, password });
		return Response.json({ token });
	} catch (error) {
		if (error instanceof JanusError && error.code === 'CREDENTIALS_INVALID') {
			return new Response(null, { status: 401 });
		}
		throw error; // STORE_FAILED included: that is your 503, never a 401
	}
}

JanusError is the base of everything thrown at call time. It extends Error, so no consumer has to order their catch blocks. code is a union of twenty string literals, so a switch over it is exhaustive and adding a code breaks the compilation of callers that exhaust it. statusOf(code) answers the status below, as JanusErrorStatus — a union of the eight literals, which a framework's own status type accepts:

import { JanusError, statusOf } from '@nxgt/janus';

if (error instanceof JanusError) {
	return Response.json({ code: error.code }, { status: statusOf(error.code) }); // STORE_FAILED → 503
}

| Code | Answer it deserves | | --- | --- | | STORE_FAILED | 503. Never a negative answer | | NOT_FOUND | 404 | | LOGIN_TAKEN, VERSION_CONFLICT | 409 | | USER_INVALID | 400, field by field from issues | | PASSWORD_TOO_SHORT, HASH_UNSUPPORTED | 400 | | CREDENTIALS_INVALID | 401 — one code for an unknown login, no password, a wrong one and a login throttled for too many; retryAfter, when set, belongs in the body and a Retry-After header | | CODE_INVALID | 401 — a one-time code that does not match: a second factor's, or one sent by e-mail; attemptsLeft from either confirm belongs in the body | | SECOND_FACTOR_NOT_ENROLLED, SECOND_FACTOR_ACTIVE | 409 — the factor is not in the state the call needs | | USER_INACTIVE | 403 | | STEP_UP_REQUIRED | 403 — the session proved who it is too long ago for this action: ask for a step-up, then send the request again | | TOKEN_UNKNOWN, TOKEN_SPENT, TOKEN_EXPIRED, TOKEN_STALE | 400 | | INVALID_CURSOR | 400 | | UNSUPPORTED | 501 — a wiring mistake, and the message names the store to change | | PERMISSION_DEPTH | 500 — a permission check or list walked past maxDepth; not a denial |

Each code has its class, all exported: StoreFailure, StoreConflict (on: 'login' | 'version'), NotFoundError, UserInvalidError, CredentialError, UserInactiveError, StepUpRequiredError, TokenError (the TOKEN_* codes and CODE_INVALID), SecondFactorError, InvalidCursorError, UnsupportedError and PermissionDepthError. StoreFailure and StoreConflict are exported because an adapter throws them. An adapter defines no error class of its own, so instanceof holds across the two packages.

No message ever holds a secret — not a password, not a hash, not a session token, not a token's hash, not a challenge, a code or a TOTP secret, and not a connection URI, because a connection string holds a password. Nor a login: a message reports a shape, never a value, so LOGIN_TAKEN names the login in error.login, not in its message.

A refusal that can only come from how you wired the library — a lifespan that is not a duration, a store missing a method — throws a bare TypeError instead. No request handler should ever answer one, so no handler needs to tell it apart.

Subjects

import { type Subject, subjectOf, setOf, formatTuple, parseTuple, isSubjectSet } from '@nxgt/janus';

subjectOf(user);                        // { type: 'staff', id: '…' }: the user IS the subject
setOf(user, 'managers');                // { type: 'staff', id: '…', relation: 'managers' }: everyone who manages them
formatTuple({
  object: { type: 'record', id: 'r1' },
  relation: 'members',
  subject: { type: 'team', id: 't2', relation: 'members' },
});
// 'team:t1#members@team:t2#members'
parseTuple('team:t1#members@staff:u1'); // the RelationTuple back

formatEntity, formatSubject and parseSubject do the same for one part, and isSubjectSet tells { type, id, relation } from { type, id } by shape alone: pass a user through subjectOf first, so a field named relation does not read as a set. isSetOf answers whether setOf made a value — what can() and grant() read on a user type. The types are Entity, SubjectSet, Subject (either), SetOf (what setOf answers) and RelationTuple.

A user passed as it is, is that user, even with a field named relation: setOf is the one way to write a set on a user type the model also declares as an object type — grant(note, 'readers', setOf(bob, 'managers')). On an object type, { type, id, relation } written out is a set too. parseSubject and parseTuple answer a set as setOf makes it — frozen, and marked — so compare a parsed set with setOf(…) or through formatSubject, not with a plain { type, id, relation }.

In Ory, the equality between a Kratos identity id and Keto's subject_id is a comment and a convention, restated in three repositories and enforced nowhere. Here it is a type and a one-line function — and that shared vocabulary is the reason identities and permissions are one package rather than two.

Subjects are typed, unlike Keto's: { type, id } for one entity, and { type, id, relation } for a subject set. One application has patients and staff, and an object can hold a relation too, so a bare id does not say who. type is the same word as a user's own.

Ids

import { type Id, mintId, isId, mintedAt } from '@nxgt/janus';

const id: Id = mintId(); // '0199…': a UUIDv7
isId(id);                // true — and false for anything this package could not have minted
mintedAt(id);            // a Date, to the millisecond

UUIDv7, minted by the core and not by the store. Ids sort in creation order as strings, so the pagination cursor is the last id: one index, and the ordering is already total. insertUser becomes idempotent under retry, and every adapter reports the same shape. The price, stated plainly: an adapter cannot reuse an existing numeric primary key.

Pagination and time

import { fixedClock, parseDuration } from '@nxgt/janus';

let cursor: string | null = null;
do {
	const page = await auth.list({ after: cursor, limit: 100 }); // CursorPage<User>
	cursor = page.nextCursor;
} while (cursor);

const clock = fixedClock(Date.UTC(2026, 0, 1)); // .now(), .advance(ms), .set(at)
parseDuration('8h', 'session.lifespan');        // 28800000

The types are CursorPage<T>, Clock and Duration ('15m', '8h', '7d', or milliseconds). DEFAULT_PAGE_SIZE (20), MAX_PAGE_SIZE (100), pageLimit and invalidCursor are what an adapter uses to page the way the core does; systemClock is the default Clock.

CursorPage has items and nextCursor, and no total: a count over a cursor-paged collection is a second query whose answer is stale by the time you read it. nextCursor is string | null with no undefined, so while (cursor) is the loop.

fixedClock is shipped, not test-only — testing session expiry needs it, and so do your own tests.

Identities — janus()

import { z } from 'zod';
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';

// One user type
const auth = janus({
	user: z.object({ email: z.email(), name: z.string() }),
	password: { login: 'email' },
	store: createMemoryStores(),
	hasher: scryptHasher(),
});

await auth.signUp({ email, name, password });      // { status: 'signedIn', user, session, token }
await auth.signIn({ email, password });            // { status: 'signedIn', user, session, token }
await auth.authenticate(request);                  // { user, session, token, renewed } | null
await auth.signOut(request);
await auth.verifyEmail.send(user);                 // { token, email, expiresAt } — sending it is yours
await auth.verifyEmail.confirm(token);
await auth.resetPassword.request(email);           // … | null
await auth.resetPassword.confirm(token, newPassword);
await auth.signInCode.request(email);              // { code, challenge, email, expiresAt, user } | null
await auth.signInCode.confirm(challenge, code);    // { status: 'signedIn', user, session, token }
await auth.magicLink.request(email);               // { token, email, expiresAt, user } | null
await auth.magicLink.confirm(token);               // { status: 'signedIn', user, session, token }
await auth.stepUp.request(user);                   // { via: 'email', code, challenge, email, expiresAt, user }
await auth.stepUp.confirm(request, challenge, code); // the request's session, authenticatedAt: now

// Several user types
const clinic = janus({
	users: {
		patient: { schema: Patient, password: { login: 'email' } },
		staff: {
			schema: Staff,
			password: { login: 'username' },
			session: { lifespan: '8h', renewAfter: false },
		},
	},
	store,
	hasher,
});

await clinic.staff.signIn({ username, password });
const current = await clinic.authenticate(request);
if (current?.user.type === 'staff') current.user.service; // narrowed by type
await clinic.authenticate(request, { type: 'staff' });  // a patient's session → null

janus() assembles synchronously and with no I/O: it checks that every store answers every method of the port, and connects to nothing. Everything else reaches the store and is asynchronous.

  • A user is your schema's fields, at the top level, plus what janus sets: id, type, emailVerified, active, hasPassword, hasSecondFactor, version, createdAt, updatedAt. A schema declaring one of those, or a password, is refused at compile time. The password hash never reaches a user.

  • Schemas are any Standard Schema — Zod 4, Valibot, ArkType. There is no validation peer. The output must be JSON, and a schema producing a Date is refused at compile time.

  • Several user types live in one instance: auth.patient.*, auth.staff.*, and one authenticate whose answer is a union narrowed by user.type. A login is unique per type: the same e-mail may hold a patient user and a staff user.

  • password.login names a top-level, required string field. A typo is a compile error on login, and the message lists the fields you could have meant. It is normalised with 'lowercaseTrim' unless you say otherwise.

  • email defaults to the field named email. A type without one has no verifyEmail, no resetPassword, no signInCode and no magicLink — they are absent from its type, not failing at run time. Changing the e-mail sets emailVerified back to false.

  • Per type: create, find (or null), get (or NOT_FOUND), list, update(user, patch) — merged over the stored fields, then validated whole — setActive and delete; with a password, signUp, signIn, findByLogin, setPassword and changePassword. Every write but delete takes an optional ifVersion.

  • delete(user) deletes the user together with every session and one-time token they had, so nothing of theirs is kept: a token holds the e-mail it was sent to. The user goes first, so an outage half-way leaves only sessions and tokens that authenticate nobody. It is idempotent, and calling it again finishes the job. It answers false for an unknown id, or for one of another type, and leaves that user untouched.

  • Shared: authenticate, signOut, signOutEverywhere(user, { except }), findUser and getUser across types, cookie.serialize(token, session) and cookie.clear() — HttpOnly; SameSite=Lax; Secure unless you say otherwise — and collectExpired.

  • Password guessing is throttled, on by default: past ten passwords tried at one login in a 15-minute window, signIn refuses every one — the right password included — with CREDENTIALS_INVALID, reason: 'throttled' and retryAfter, the seconds until the next window. Nothing locks, a login nobody holds is counted alike, and a password sign-in that opens a session — after its second factor, when one is active — starts the count again. signIn: { throttle: { attempts, window } } changes it, signIn: { throttle: false } turns it off; a store that cannot count throws STORE_FAILED (passwords).

    janus({ user, password: { login: 'email' }, store, hasher, signIn: { throttle: { attempts: 5, window: '1h' } } });
  • Sessions last '7d' and slide: authenticate renews one once renewAfter ('1d') has passed, writing at most once per period, and says so with renewed. The token is handed back once; the store only holds its sha256.

Hashers. scryptHasher() runs on Node and on Bun, with no dependency and OWASP's parameters (N = 2^17, r = 8, p = 1). bunHasher() is argon2id through Bun.password, on Bun only. There is no silent fallback: a user type with a password and no hasher is refused at wiring. Hashes describe themselves ($scrypt$ln=17,r=8,p=1$…, $argon2id$…). Wire the hashers a database was written with as verifiers, and every one of them can verify while exactly one hashes.

Rehash on sign-in. When a password matches a stale hash, signIn rewrites it with hasher. A hash is stale when a verifiers hasher wrote it, or when hasher wrote it with other parameters than it uses now: a raised scrypt cost, or argon2id parameters other than the pinned m=65536,t=2,p=1. Moving off a hasher, or raising its cost, therefore reaches every active user with no migration to run. The password's updatedAt is kept, since the password did not change; the user's version moves. The write happens only at the version just read. If a concurrent update wins, the sign-in still succeeds and the next sign-in tries again. An outage on that write still fails the sign-in.

The port. JanusStores is three stores — UserStore, SessionStore, TokenStore, whose records are UserRecord, SessionRecord and TokenRecord — in the slots users, sessions, tokens, cut where atomicity is not required, so sessions can live in Redis while users live in MongoDB. createMemoryStores() is the reference implementation. It is shipped for your own tests, and it is what to compare against when writing an adapter. assertStores(store, where) is the check janus() runs on it, for an adapter that wants to fail as early. The six rules an adapter keeps are written on the port's types.

The port holds what one-time codes need: a user's secondFactor (a TOTP secret the core seals before a store sees it, or null), a token's codeHash and attempts, and TokenStore.countAttempt, which counts one attempt in one conditional write:

import { createMemoryStores, mintId } from '@nxgt/janus';

const { tokens } = createMemoryStores();
const tokenHash = 'a'.repeat(64); // the core stores sha256 of the secret
await tokens.insertToken({
	tokenHash,
	kind: 'signInCode',
	userId: mintId(),
	address: '[email protected]',
	codeHash: 'c'.repeat(64),
	attempts: 0,
	expiresAt: new Date(Date.now() + 10 * 60_000),
	spentAt: null,
	createdAt: new Date(),
});

await tokens.countAttempt(tokenHash, 'signInCode'); // { …, attempts: 1 }
await tokens.countAttempt(tokenHash, 'verifyEmail'); // null: no token of that kind

spendUserTokens(userId, kind, at, except?) spends the unspent tokens of one user and one kind — but the one whose hash is except — and answers how many: what issuing a sign-in code and writing a password call:

const userId = mintId();
const now = new Date();
const code = (tokenHash: string) => ({
	tokenHash,
	kind: 'signInCode' as const,
	userId,
	address: '[email protected]',
	codeHash: 'c'.repeat(64),
	attempts: 0,
	expiresAt: new Date(now.getTime() + 10 * 60_000),
	spentAt: null,
	createdAt: now,
});
const kept = code('d'.repeat(64));
await tokens.insertToken(kept);
await tokens.insertToken(code('e'.repeat(64)));
await tokens.spendUserTokens(userId, 'signInCode', now, kept.tokenHash); // 1: the other one
await tokens.spendUserTokens(userId, 'signInCode', now); // 1: `kept`, now
await tokens.spendUserTokens(userId, 'signInCode', now); // 0: none left unspent

reauthenticateSession(id, at) moves a standing session's authenticatedAt — what a step-up writes once a signed-in user proved again who they are — and answers null for a revoked one, which it never brings back:

const { sessions } = createMemoryStores();
const session = {
	id: mintId(),
	tokenHash: 'f'.repeat(64),
	userId: mintId(),
	authenticatedAt: new Date('2026-01-01T00:00:00.000Z'),
	expiresAt: new Date(Date.now() + 86_400_000),
	revokedAt: null,
	createdAt: new Date('2026-01-01T00:00:00.000Z'),
};
await sessions.insertSession(session);
await sessions.reauthenticateSession(session.id, new Date()); // { …, authenticatedAt: now }

A token of kind stepUp is that confirmation's challenge: kept apart from signInCode, so neither is ever redeemed as the other. A token of kind magicLink, new in 0.15, is a sign-in link's, kept apart from signInCode the same way: a sign-in code's challenge is held by whoever asked for it, and must never sign anyone in as a link. A store that lists the kinds — a CHECK, a validator's enum — adds magicLink.

An adapter written against @nxgt/janus 0.3 does not compile against this port until it implements countAttempt, nor one written against 0.6 until it implements spendUserTokens, nor one written against 0.11 until it implements reauthenticateSession, and janus() refuses each at wiring — Writing an adapter has the contracts: countAttempt, spendUserTokens and reauthenticateSession. A store that lists the token kinds — a CHECK, a validator's enum — adds stepUp.

Second factor — secondFactor

import { z } from 'zod';
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';

const auth = janus({
	user: z.object({ email: z.email() }),
	password: { login: 'email' },
	store: createMemoryStores(),
	hasher: scryptHasher(),
	secondFactor: {
		issuer: 'Example',                                           // shown in the authenticator app
		keys: [{ id: '2026-09', key: process.env.TOTP_KEY ?? '' }], // openssl rand -base64 32
	},
});

const { secret, uri } = await auth.secondFactor.enroll(user); // show uri as a QR code, secret beside it
const { recoveryCodes } = await auth.secondFactor.activate(user, code); // the first code: the factor is active
// recoveryCodes: ten, 'xxxxx-xxxxx' — show them now, no call answers them again

const result = await auth.signIn({ email, password });
if (result.status === 'secondFactor') {
	// no session yet: keep result.challenge for the next request — never in a URL or a log
	const signedIn = await auth.secondFactor.confirm(result.challenge, code); // { status: 'signedIn', … }
	// phone gone? a recovery code instead, spent once:
	// await auth.secondFactor.recover(result.challenge, recoveryCode) // { …, recoveryCodesLeft: 9 }
}

await auth.secondFactor.recoveryCodesLeft(user);             // 9, or null without an active factor
await auth.secondFactor.regenerateRecoveryCodes(user, code); // { user, recoveryCodes }: ten new, the old ones end
await auth.secondFactor.disable(user);                       // the factor and its recovery codes

A TOTP second factor — the six-digit codes of any authenticator app — for every user type with a password.

  • signIn answers a union once secondFactor is configured: { status: 'signedIn', user, session, token }, or { status: 'secondFactor', challenge, expiresAt, userId } for a user whose factor is active. Switch on status: reading token before that is a compile error. Without secondFactor, signIn answers a session, as before.
  • A factor is enrolled, then active. enroll answers the secret and its otpauth:// URI once; enrolling again replaces a factor still waiting. activate checks a first code, and only then does signIn ask for one — hasSecondFactor says so.
  • confirm(challenge, code) opens the session. A challenge lives '5m' (secondFactor.challenge) and takes five attempts: a wrong code is CODE_INVALID with attemptsLeft, and the fifth spends the challenge. A code is accepted once, so a replay is CODE_INVALID too.
  • Recovery codes, for a lost phone. activate answers a RecoveryCodesIssued — { user, recoveryCodes }, ten single-use codes, shown once, stored only as keyed hashes. recover(challenge, code) redeems a sign-in's challenge with one instead of the app's code, sharing its five attempts, and answers a RecoveredSignIn: the session with recoveryCodesLeft. recoveryCodesLeft(user) reads that count again — for a user.recoveryCodeUsed listener, which has the id only — and answers null for a user with no active factor. regenerateRecoveryCodes(user, code) replaces them all, on a fresh code from the app, and answers a RecoveryCodesIssued too; disable removes them. regenerateRecoveryCodes takes five attempts per user per 15-minute window, counted by the store: a wrong code is CODE_INVALID with attemptsLeft, and past the fifth every call is refused until the next window.
  • keys seal every TOTP secret with AES-256-GCM before a store sees it, and key the recovery codes' hashes. The first seals and every key opens, so keys rotate: put the new one first, keep the old one until no secret is sealed and no recovery code hashed with it.
  • The first proof of an e-mail by a sign-in code or link removes the factor too, active or waiting, with its recovery codes: whoever enrolled it had not proved the address. Send verifyEmail before offering enroll, and an owner's factor is never dropped this way.
  • Asking for a password or a code before enroll, disable or regenerateRecoveryCodes is your policy, not the library's — a recent session.authenticatedAt is one rule. regenerateRecoveryCodes asks for the app's code itself.

The second factor guide has every option, error and state, recovery codes, key rotation, and sign-in routes with the challenge in a cookie.

Sign-in codes — signInCode

import { z } from 'zod';
import { createMemoryStores, janus } from '@nxgt/janus';

const auth = janus({
	user: z.object({ email: z.email(), name: z.string() }), // no password: codes only
	store: createMemoryStores(),
});

const issued = await auth.signInCode.request(email); // null for nobody, or an inactive user
if (issued !== null) {
	await sendMail(issued.email, `Your sign-in code: ${issued.code}`); // the code, and only the code
}
// answer the same page either way; keep issued.challenge with the visitor — a cookie, never a URL

const signedIn = await auth.signInCode.confirm(challenge, code); // { status: 'signedIn', user, session, token }

A six-digit code sent to the user's e-mail signs them in, with no password.

  • On every user type with an e-mail, with a password or without one. A type with no password signs in by code alone; a type with no e-mail has no signInCode at all.
  • request(email) answers null when nobody of this type holds that e-mail, or the user is inactive — never say which. It answers the code for the e-mail and the challenge for the visitor; the code's hash is stored, keyed by the challenge.
  • confirm(challenge, code) marks the e-mail verified and opens the session. A challenge lives '10m' (tokens.signInCode) and takes five attempts: a wrong code is CODE_INVALID with attemptsLeft, and the fifth spends it. An e-mail changed since is TOKEN_STALE; a user deactivated since is USER_INACTIVE.
  • An e-mail proved for the first time drops the password and the second factor, and signs out every session before the new one opens, as resetPassword.confirm does for the password: whoever registered the address without holding its inbox keeps nothing, and the owner is asked for no factor they never set. user.passwordChanged follows user.emailVerified when a password was dropped, then user.secondFactorDisabled when an active factor was. An e-mail already verified changes nothing.
  • An active second factor is still asked for on an e-mail already verified: with secondFactor configured, confirm answers SignInResult on a type with a password, and a user whose factor is active gets a challenge for secondFactor.confirm — switch on status, as after signIn.

The sign-in code guide has the request that tells nobody who exists, the challenge in a cookie, every error, and a test.

Sign-in links — magicLink

const issued = await auth.magicLink.request(email); // null for nobody, or an inactive user
if (issued !== null) {
	const link = `https://app.example/sign-in/link?token=${issued.token}`; // a page of yours
	await sendMail(issued.email, `Sign in: ${link}`); // the token in the e-mail, and nowhere else
}
// answer the same page either way: nothing reaches the visitor, so there is nothing to forge

// the page's button POSTs the token back — never confirm on the link's GET
const signedIn = await auth.magicLink.confirm(token); // { status: 'signedIn', user, session, token }

A link sent to the user's e-mail signs them in: a sign-in code with nothing to type.

  • On every user type with an e-mail, with a password or without one, as signInCode. A type with no e-mail has no magicLink at all.
  • request(email) answers null when nobody of this type holds that e-mail, or the user is inactive — never say which. It answers a token of 32 random bytes, base64url, for a link; only its hash is stored. One link is live per user: a new request spends the ones before. A link and a code are separate: asking for one leaves the other live.
  • confirm(token) spends the token in one conditional write — of two confirmations at once, one signs in — marks the e-mail verified and opens the session. A link lives '15m' (tokens.magicLink); there are no attempts, since there is nothing to guess. TOKEN_STALE, USER_INACTIVE, an active second factor and an e-mail proved for the first time — the password and the second factor dropped, every session signed out — are handled as for a code.
  • Not ended by a password write, nor limited by the sign-in throttle: the password proves nothing a link does, and the throttle counts passwords. Rate-limit request per address and per client.

The sign-in link guide has the page that confirms from a POST so mail scanners spend nothing, every error, routes and a test.

Step-up — stepUp and assertFresh

import { assertFresh } from '@nxgt/janus';

// A sensitive route asks for a recent proof: signed in, or confirmed since.
const current = await auth.authenticate(request);
if (current === null) return new Response(null, { status: 401 });
assertFresh(current.session, '10m'); // StepUpRequiredError, STEP_UP_REQUIRED (403), past ten minutes

// The client, told STEP_UP_REQUIRED, asks for a step-up:
const issued = await auth.stepUp.request(current.user);
if (issued.via === 'email') await sendMail(issued.email, `Confirm it is you: ${issued.code}`);
// …keeps issued.challenge, and sends it back with the code:
const session = await auth.stepUp.confirm(request, challenge, code); // authenticatedAt: now

A signed-in user proves again who they are before something a stolen session should not do alone.

  • On every user type with an e-mail, as signInCode. A type with no e-mail has no stepUp.
  • request(user) answers via: 'email' — a six-digit code to send to email — or, for a user whose second factor is active, via: 'secondFactor': nothing to send, ask for their app's code. A step-up is never weaker than the sign-in the account asks for. One step-up is live per user; a challenge lives '10m' (tokens.stepUp).
  • confirm(request, challenge, code) stamps the session the request presents: its authenticatedAt moves to now, and it is answered. It opens no session and hands out no token. Five attempts per challenge; an app's codes are also counted per user, five per 15-minute window, with regenerateRecoveryCodes. A request whose session is not a standing one of the challenge's user is TOKEN_UNKNOWN.
  • assertFresh(session, maxAge, clock?) reads authenticatedAt and nothing else: STEP_UP_REQUIRED (403) when it is maxAge old or more. A fresh sign-in is fresh too.

The step-up guide has the two requests, every error, and the codes an app confirms.

Devices — devices

import { z } from 'zod';
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';

const auth = janus({
	user: z.object({ email: z.email() }),
	password: { login: 'email' },
	store: createMemoryStores(),
	hasher: scryptHasher(),
	devices: { keys: [{ id: '2026-09', key: process.env.DEVICES_KEY ?? '' }] }, // openssl rand -base64 32
});

// `cookie`: the device cookie the request carries — string | undefined
const signedIn = await auth.signIn({ email, password }, { device: cookie ?? null });
// { status: 'signedIn', user, session, token, newDevice, deviceToken }
// keep signedIn.deviceToken in a long-lived HttpOnly cookie; newDevice: true sent user.newDeviceSignedIn

A sign-in from a device the user had not signed in from is told apart, so you can tell the user. Nothing is stored: the client keeps a device token janus signs, and presents it at its next sign-in.

  • { device } is the last argument of signUp, signIn, secondFactor.confirm, secondFactor.recover, signInCode.confirm and magicLink.confirm — a SignInOptions: the token the client holds, or null when it holds none. Absent, the call is not tracked: newDevice: false, deviceToken: null, no event.
  • Every answer that opens a session carries newDevice and deviceToken. signUp mints the first token and is never new. A malformed, forged or another user's token is a new device, never an error.
  • user.newDeviceSignedIn is sent for a new device, once the sign-in is complete, with the new session's sessionId.
  • devices: { keys } — a DevicesConfig — wires it; the keys take secondFactor.keys' format. The first signs, every one checks: a token signed by an older key is known, and handed back signed with the first. Removing a key is the only way to forget devices, and it forgets every device that key signed.

@nxgt/janus-mail's newSignIn sends the notice, and @nxgt/janus-hono's deviceOf and sendSession keep the cookie. The devices guide has the cookie, the second factor, the e-mail, key rotation, every error and a test.

User events — events

import { z } from 'zod';
import { createMemoryStores, janus, scryptHasher, type UserEvent } from '@nxgt/janus';

const auth = janus({
	user: z.object({ email: z.email() }),
	password: { login: 'email' },
	store: createMemoryStores(),
	hasher: scryptHasher(),
	async events(event: UserEvent) {
		// { id, type: 'user.created', occurredAt, userId, userType }
		await queue.add(event.type, event, { jobId: event.id }); // deliver it once
	},
});

await auth.signUp({ email, password }); // the listener has the event before this answers

janus({ events }) hears what happened to a user, once it is written:

| type | Sent by | | --- | --- | | user.created | create, signUp | | user.emailVerified | verifyEmail.confirm; resetPassword.confirm, magicLink.confirm and signInCode.confirm, whose link or code proves the e-mail too — never for an e-mail already verified | | user.passwordReset | resetPassword.confirm | | user.passwordChanged | changePassword, setPassword; signInCode.confirm and magicLink.confirm when their first proof of the e-mail dropped a password — never a reset, which is user.passwordReset alone | | user.emailChanged | update, when it changed the e-mail — carrying formerEmail, the address before (null for none) | | user.secondFactorEnabled | secondFactor.activate, once the factor is active — not enroll, which leaves it waiting | | user.secondFactorDisabled | secondFactor.disable, when it removed an active factor; signInCode.confirm and magicLink.confirm when their first proof of the e-mail removed one, after user.emailVerified and any user.passwordChanged — never for a user who had none, or one still waiting | | user.recoveryCodesRegenerated | secondFactor.regenerateRecoveryCodes — not activate, whose codes come with user.secondFactorEnabled | | user.recoveryCodeUsed | secondFactor.recover, once the recovery code is spent: a sign-in without the user's phone | | user.newDeviceSignedIn | signIn, secondFactor.confirm, secondFactor.recover, signInCode.confirm and magicLink.confirm, given a device its token did not prove, once the sign-in is complete — carrying sessionId, the new session's. Never signUp, nor a call given no device | | user.deleted | delete, once — a replay that deletes nobody sends nothing |

  • The user is named by id, and nothing else: no login, no field, no password, no token. Whoever receives the event reads the rest from where it is kept, if they may. Two exceptions: user.emailChanged carries formerEmail, which nothing keeps once the write landed, so a notice can reach the inbox the account just left; and user.newDeviceSignedIn carries sessionId, the session the new device holds. @nxgt/janus-webhooks posts neither.
  • Each event has an id of its own, a UUIDv7: the key to deliver it once.
  • The listener runs after the write, and is awaited before the flow answers, so a durable queue has the event by then. occurredAt is the write's own time. A refused flow sends nothing.
  • Typed: events is a UserEventListener; UserEventType is the closed union of the eleven types, so a switch on event.type is exhaustive — and a new type, like the two recovery-code ones in 0.10, the two change ones in 0.14 or user.newDeviceSignedIn in 0.17, breaks it until handled.
  • A listener that throws fails no flow — the write happened. It is a JANUS_EVENT_FAILED warning naming the event's type, its id and the user's id, never the failure's message.

To tell the user their password or e-mail changed, their second factor was turned on or off, that a recovery code was used, or that a new device signed in, send @nxgt/janus-mail's notice from the listener — or from a queue it feeds:

// `mail` is janusMail({ … }), and the user schema holds a name and a locale.
async events(event) {
	if (event.type === 'user.emailChanged' && event.formerEmail != null) {
		const user = await auth.get(event.userId);
		// To the former address: the new one belongs to whoever changed it.
		await mail.emailChanged({ name: user.name, locale: user.locale, formerEmail: event.formerEmail, newEmail: user.email });
	}
	if (event.type === 'user.secondFactorDisabled') {
		const user = await auth.get(event.userId);
		await mail.twoFactorDisabled({ name: user.name, locale: user.locale, email: user.email });
	}
	if (event.type === 'user.recoveryCodeUsed') {
		const user = await auth.get(event.userId);
		const recoveryCodesLeft = await auth.secondFactor.recoveryCodesLeft(user); // the event has the id only
		if (recoveryCodesLeft === null) return; // the factor was turned off since
		const when = new Intl.DateTimeFormat(user.locale, { dateStyle: 'long', timeStyle: 'short', timeZone }).format(
			event.occurredAt,
		); // timeZone: the user's, from your own data
		await mail.recoveryCodeUsed({ name: user.name, locale: user.locale, email: user.email }, { when, recoveryCodesLeft });
	}
},

To post them as signed webhooks: @nxgt/janus-webhooks. The user events guide has the listener, the eleven types, mail for a recovery code used or a password or e-mail changed, what a failure costs, and a test.

Permissions — @nxgt/janus/permissions

import { defineModel, fromField, when, permissions, createMemoryRelations } from '@nxgt/janus/permissions';

export const model = defineModel({
	subjects: clinic.types, // 'patient' | 'staff': a user type is a subject type
	types: {
		team: {
			related: { members: ['staff', 'team#members'], leads: ['staff'] },
			permits: { manage: ['leads'], view: ['members', 'manage'] },
		},
		record: {
			related: {
				doctors: fromField('doctorId', 'staff', { lookup: (staffId) => db.records.idsByDoctor(staffId) }),
				patients: fromField('patientId', 'patient', { lookup: (patientId) => db.records.idsByPatient(patientId) }),
				teams: ['team'],
			},
			permits: {
				view: ['doctors', 'patients', 'teams->view'],
				review: ['teams->leads'],
				edit: [when('doctors', (ctx: { onShift: boolean }) => ctx.onShift)],
			},
		},
	},
});

// staff: a user from clinic.staff; record: a row carrying doctorId and patientId.
const access = permissions({ model, store: createMemoryRelations() });
await access.grant({ type: 'team', id: 't1' }, 'members', staff);
await access.can(staff, 'edit', { type: 'record', ...record }, { ctx: { onShift } }); // boolean
await access.list(staff, 'view', 'record', { limit: 50 });                        // CursorPage<string>; view reaches no condition
await access.revoke({ type: 'team', id: 't1' }, 'members', staff);               // idempotent

Zanzibar's model — relations between objects and subjects, permissions computed from them — without its infrastructure: the tuples live in your database, so a read follows a write and there is nothing to cache or to sequence. An object type declares its relations under related and its permissions under permits — Keto's words — and relation names are plural by convention (defineModel does not enforce it). Subject sets ('team#members'), arrows ('teams->view': whoever can view one of the record's teams; 'teams->leads': whoever leads one) and permissions naming permissions are Zanzibar's. Two things are not:

  • fromField reads a relation from the object's own data — a record's doctorId — instead of a tuple kept in sync with it. can() is given the object, and the compiler requires every field a fromField of its type reads. list() cannot read a field of objects it has not found, so it asks the lookup;
  • when puts a condition written in TypeScript on a rule. Its ctx is what can() and list() then require — and only for the permissions whose rules reach it.

A denial is false, a failure throws. A relation store that cannot answer is STORE_FAILED; a walk that crosses more than maxDepth relations (25) is PERMISSION_DEPTH. Neither is ever false, which would deny everybody everything during an outage and say nothing. A cycle in the data — a team member of itself — is cut, and is not an error. null is anonymous: false, or an empty page, before any store call.

Everything is typed from the model. A relation naming a type that does not exist, a rule naming nothing, an arrow to a permission its target lacks, a permission asked of the wrong type, an object missing a field, a missing ctx, a grant of a relation read from a field or to a holder it does not admit, a subject set naming a relation its type lacks or a user type not declared under types, a list() through a fromField without a lookup: each is a compile error, on the offending argument. So are the keys before 0.2, relations and permissions: the error names the new one — team.relations is now related: rename the key — and defineModel refuses them the same way at run time. defineModel refuses with a TypeError what only running it can see: names that are not camelCase, a permission that reaches itself without crossing a relation, a subject set or an arrow that would have to read another object's field.

A function of your own around can() refuses the same mistakes through Can<C>, its signature, and CheckArgs<C, T, P>, its options argument — ctx required exactly when a when is reachable. Declare T and P const, or the permission widens and the ctx requirement is lost; the permissions guide has the example.

A user type may also be an object type: declare staff under types, and a staff member is an object too — who may edit them is a relation on them. See permissions on a user.

Wire the relation store into janus() too — janus({ …, relations }) — and deleting a user deletes every tuple naming them. Deleting an object's tuples is store.deleteEntity({ type, id }), from your own code.

The port is RelationStore: six methods answering one-hop questions about stored tuples (write, has, findSubjectSets, findEntities, findObjects, deleteEntity). The traversal is the core's, written once.

Conformance — @nxgt/janus/conformance

If you write an adapter, you run this suite against it:

import { describe, it } from 'bun:test';
import { describeJanusStores } from '@nxgt/janus/conformance';

describeJanusStores({
	name: 'my adapter',
	runner: { describe, it },
	harness: {
		async open() {
			const db = await freshDatabase(); // one per case, never shared
			return {
				stores: myStores(db),
				faults: { fail: (slot, method) => db.failNext(method) },
				close: () => db.drop(),
			};
		},
	},
});

There are 55 cases. They cover:

  • round-trip, byte for byte — including every edge character the core lets through (control characters, U+FFFF, a surrogate pair);
  • uniqueness, as a constraint: of twenty concurrent inserts of one login, exactly one is accepted — and a login is unique per user type; the refusal carries the login in error.login, never in its message;
  • versions: a refused update writes nothing;
  • omission, named after the Kratos PUT trap;
  • pagination;
  • sessions: reauthenticateSession moves authenticatedAt and nothing else, and never brings back a revoked session, even one revoked at the same moment;
  • one-time tokens: a token of every kind the port names is stored, counted and spent — a CHECK or an enum that forgot stepUp or magicLink fails here — and neither a step-up nor a sign-in link is ever counted, spent nor answered as a sign-in code; of twenty concurrent redemptions, exactly one succeeds; of twenty concurrent countAttempt calls, each answers a distinct count, and none is counted once a racing redemption spent the token;
  • spending a user's tokens of one kind — spendUserTokens — spends only the unspent ones of that user and kind, spares the one named by except, and never spends the same token as a racing redemption;
  • a second-factor challenge, whose address is '', kept, counted and spent;
  • a user's second factor: round-trip, kept by a patch that does not name it, removed by one that names null;
  • deletion: a user's logins are freed, and every session and token of theirs goes, with a replay answering false or 0 rather than failing;
  • outages, one case for each of the fourteen methods whose honest answer can be "nothing".

The suite imports no test framework and no assertion library. It runs under bun test, vitest and jest. Its cases are also exported as data (allCases, or by store: userStoreCases, sessionStoreCases, tokenStoreCases, outageCases), with runCase to run one without any runner. skip: { [caseId]: reason } skips a case and reports why; SKIP_REASONS holds the reasons the suite gives itself.

faults is optional, and its absence is reported, never passed over. Without it, the outage cases are skipped under the reason "faults not provided: the outage invariant is not proven for this adapter". Make your database fail the way it really fails — for MongoDB, the failCommand failpoint with code 91. A wrapper that throws in front of your adapter proves the wrapper, not the adapter. Fail only the method named: outage.write reads the store back afterwards, to prove the rejected write changed nothing.

referenceHarness() runs the suite against the reference store, and is the example to copy.

A relation store has its own suite, describeRelationStores({ name, harness }) — 16 cases: round-trip, a subject whose relation is undefined read as its entity, ids of edge characters kept exactly, absence, idempotent writes, a tuple stored once, one write's removals and additions applied together, the one-hop reads, the reverse index in pages, deleteEntity, and an outage for each of the six methods — a write that rejects must have changed nothing. referenceRelationHarness() is its example; allRelationCases, relationStoreCases, relationOutageCases and runRelationCase are the runner-less layer.

Traps

Once secondFactor is configured, switch on signIn's status — and on signInCode.confirm's and magicLink.confirm's. A user whose factor is active gets { status: 'secondFactor', challenge, expiresAt, userId }, with no token and no session: an e-mailed code or link proves the e-mail, not the factor. userId is for your logs and rate limits — answer the visitor the challenge alone. if (result.status === 'secondFactor') … before anything reads them.

A challenge is a secret, like a session token — signIn's and signInCode.request's alike. Keep it in a short-lived HttpOnly cookie or the body of the code form — never in the e-mail, never in a URL, where logs, proxies and the Referer header see it, and never in a log. The e-mail holds the code and nothing else.

Confirm a step-up on the request that asked for it. stepUp.confirm stamps the session the request presents, and only a standing session of the challenge's user: a challenge carried to another browser, or confirmed after a sign-out, is TOKEN_UNKNOWN. Its challenge is a secret like signInCode's — and check freshness on the server with assertFresh, never from a flag the client keeps.

Rate-limit stepUp.request per user. An app's codes are counted per user and window, but an e-mailed code gets five guesses per challenge and a new challenge takes only a new request: without a limit, a stolen session can keep asking — and fill the user's inbox while it does.

Answer signInCode.request the same whether it issued a code or not — the same status, body and cookie: set a random challenge when it answered null. The code route then still tells a decoy (TOKEN_UNKNOWN) from a real challenge (CODE_INVALID, attemptsLeft, or TOKEN_SPENT once a later request spent it): answer its refusals alike where addresses must stay secret. And rate-limit the request per address: at most one code is live per user — a new request spends the one before — so without a limit anyone who knows an address can fill its inbox, or cancel its owner's code before they type it.

Confirm a sign-in link from a POST, never from its GET. Mail scanners open every link in an e-mail before the user does: a route that calls magicLink.confirm on the link's GET spends it for the scanner, and the user's click answers TOKEN_SPENT. Link to a page that spends nothing, whose button posts the token — and echo into that page only a token of the token's shape. Refuse that POST from another site — check its Origin, or Sec-Fetch-Site: same-origin — or any page can sign a visitor into an account whose link it holds. Send that page with Referrer-Policy: strict-origin, not no-referrer, which makes its form's Origin null and the check refuse its own button. Whoever opens the link signs in, on the device that opened it: where the sign-in must complete in the browser that asked, send a code.

The first sign-in by link or code drops the password and the second factor. signUp does not wait for the address to be proved, so anyone can register somebody else's e-mail with a password of theirs, and enrol a second factor on their own phone. When signInCode.confirm or magicLink.confirm proves an e-mail never verified, it drops the password and the second factor — active or waiting, with its recovery codes — and signs out every session before opening its own — so a user who signed up, never verified, and then signs in by code has no password afterwards, and signIn answers CREDENTIALS_INVALID (reason: 'noPassword'). Offer setPassword after such a sign-in, or send verifyEmail at sign-up. An e-mail changed by update is unverified again, and its first proof by link or code drops both too. So does a user who enrolled a factor before proving their own e-mail: they sign in with no challenge, and enrol again — offer secondFactor.enroll after such a sign-in, or send verifyEmail before letting anyone enrol. An e-mail already verified keeps both.

Every janus() that signs users in needs the same secondFactor. An instance without keys never signs in a user whose factor is active: signIn throws a TypeError rather than open a session on the password alone. Build the configuration once and import it everywhere.

Never remove a sealing key while a secret is sealed or a recovery code hashed with it, nor change a key under the same id. That user's next sign-in is a TypeError, not a refusal — or, for a key changed, recovery codes that silently match nothing. For a recovery code, only secondFactor.recover throws; regenerateRecoveryCodes still works while the key sealing the secret is held, and is the way out: it hashes the new codes with the first key. Put the new key first and keep the old one until your database holds no secret and no recovery code starting v1.<old id>.: recovery codes are never hashed again, so a user who does not regenerate them keeps the old key in use.

activate answers { user, recoveryCodes }, not the user (since 0.10). (await activate(user, code)).hasSecondFactor no longer compiles; read .user. The codes are shown once — no call answers them again — so show them on that response, sent with Cache-Control: no-store, and never log them.

enroll, disable and regenerateRecoveryCodes do not know who is calling. Whether the user proves their password or a code first is yours to decide; without a check, a stolen session can switch the factor off. regenerateRecoveryCodes asks for the app's code, and nothing else.

Past five wrong codes, regenerateRecoveryCodes refuses the right one too. The attempts are counted per user, in the store, in 15-minute windows: the sixth call in a window is CODE_INVALID with attemptsLeft: 0 and the message too many codes tried, whatever code it carries, and nothing is written. Tell the user to wait; a code accepted — a regenerate, or a sign-in finished with the app — starts the count again. A thief with the session can therefore spend a user's five attempts and hold them off for a window: that is the price of bounding the guesses.

Two sign-ins with the same recovery code at once open one session. The other rejects with VERSION_CONFLICT, not CODE_INVALID: answer it as "sign in again". Two different codes typed on one challenge at once are another matter: the later one can still be removed from the user before it finds the challenge spent, so two codes go for the one session the challenge opens.

A sign-in given no device tracks nothing. Absent, newDevice is false, deviceToken is null and no event is sent — not "a new device". Pass { device: null } for a client that holds no token yet, or it is never given one.

undefined is not null. A cookie read as string | undefined and passed as device is untracked whenever the cookie is missing — a compile error under exactOptionalPropertyTypes, silent without it. Write { device: cookie ?? null }.

Give the device again to secondFactor.confirm and recover. The challenge signIn, signInCode.confirm or magicLink.confirm answers carries no device, so a confirmation called without { device } is untracked, whatever the sign-in was given: auth.secondFactor.confirm(challenge, code, { device: cookie ?? null }).

Every janus() that signs users in needs the same devices. An instance without devices throws a TypeError for any device, and one whose keys differ reports every other instance's devices as new.

One device cookie holds one user's token. Two people sharing a browser who take turns signing in each present the other's token, which proves nothing for them: each gets a new token, and a notice, at every turn.

Removing a device key forgets every device it signed. Each is reported new once, at its next sign-in. It is the only way to forget devices — there is no per-device forget — so rotate by putting a new key first and keeping the old one.

A device token is not a credential. It proves only that this user signed in on this device before. Never authenticate a request with it, and never let it skip a password or a second factor.

A SignedIn built by hand needs newDevice and deviceToken (since 0.17): a test double that answers { status, user, session, token } no longer compiles — add newDevice: false, deviceToken: null.

On a user type, only setOf makes a set. A user passed as it is — or { type: 'staff', id, relation: 'managers' } written out — is that one user, even with a field named relation. The compiler refuses the written-out set in grant() and revoke() where the relation admits one. From JavaScript it grants that user, silently, where the relation also admits the user — and is refused where it does not. grant(note, 'readers', setOf(bob, 'managers')). A spread of a set is still the set; through JSON or structuredClone it comes back as the user — call setOf again, or parseSubject on its notation (staff:u1#managers).

Narrowing a model hides stored tuples; it does not delete them. A tuple the model no longer admits grants nothing, and revoke() refuses it — remove it with relations.write({ remove: [tuple] }), or widening the model again brings it back.

The first session credential present wins, not the first valid one. Authorization: Bearer, then X-Session-Token, then the cookie. A client that sends a lapsed bearer beside a live cookie is anonymous, and it should fix its header rather than be rescued in silence.

An outage is not anonymous. authenticate rejects with STORE_FAILED when the store cannot answer. Answer 503: a 401 would sign everybody out during an outage, and send them to a sign-in page that cannot work either.

Never put a CREDENTIALS_INVALID's reason in a response body. unknownLogin is an account-enumeration oracle. signIn compares against a dummy hash when nobody holds the login, so the hashing time does not tell. The store's own latency still does, and that limit is stated rather than denied. resetPassword.request answers null for an unknown e-mail for the same reason: answer the visitor the same page either way.

resetPassword.confirm signs the user out everywhere, and opens no session. Whoever had the old password loses their sessions, and a sign-in they left waiting on its second factor is spent with them — as it is by setPassword and changePassword; what the visitor does next is your policy. A password refused for its length does not spend the token.

Only the last reset link works, and only until the password changes. A resetPassword.request spends the user's earlier links, and any password written — by a link, changePassword or setPassword — spends every link still live, so an older e-mail's link answers TOKEN_SPENT. Tell the visitor to use the latest e-mail, and rate-limit resetPassword.request per address: each request cancels the link before it.

signIn throttles each login, not each client. Ten passwords per login per 15 minutes, then CREDENTIALS_INVALID with retryAfter until the window ends — the right password too, so tell the visitor to wait retryAfter seconds rather than that the password is wrong. CredentialRefusal gained 'throttled': an exhaustive switch over error.reason must handle it. A test that tries more than ten wrong passwords at one login over a fixedClock is throttled too: advance the clock past retryAfter, or wire signIn: { throttle: false }. Somebody who knows a login can keep its password sign-in shut, ten tries a window; a sign-in code or link, when you wire them, still opens it — the throttle counts passwords, never those. On PostgreSQL, delete lapsed tokens on a schedule: every login tried adds a row per window. One password tried against many logins is not counted: rate-limit signIn per client address yourself (passwords). The counts live in the tokens store: a flushed or evicting Redis forgets them, and a tokens store that cannot answer fails every password sign-in with STORE_FAILED.

A sign-in can move a user's version. Rewriting a stale hash is a write. A user object read before that sign-in, and then passed as ifVersion, gets VERSION_CONFLICT. That is the conflict doing its job: read the user again.

Expiry is decided by the core, not by the store. A store may still hold a lapsed session, and authenticate answers it as anonymous. A TTL index keeps storage tidy; it is not the expiry mechanism.

update merges, then validates the whole. The patch is spread over the stored fields and the result is checked against the schema, so a patch can never leave a user that the schema would refuse. Pass ifVersion to make the write conditional on what you read.

Under bun test, pass runner: { describe, it }. Measured: Bun gives a test file describe and it as bare identifiers, not as properties of globalThis. jest, and vitest with globals: true, are found without it.

undefined is not an absence here. Every method that can find nothing answers null. undefined is what a missing property and a function with no return both produce, so a store that forgot to answer would report "not found" by accident. null has to be written on purpose.

The notation is typed, and refuses Keto's untyped subject. team:t1#members@staff:u1, and team:t1#members@team:t2#members for a subject set. No part may hold @, # or a parenthesis, and a type may not hold a :, so every string reads one way. parseTuple refuses team:t1#members@grace, and its message says what a subject is.

parseTuple throws a bare TypeError, not a JanusError. Nothing in this package reads a tuple off the network, so a malformed string came from your own code — a wiring mistake, and no handler should answer one.

mintedAt is not createdAt. The sequence may have borrowed a millisecond and a clock that stepped backwards is held rather than followed, so it is accurate to the millisecond and no further.

mintId(now) steers ids forward, never back. The last millisecond is module state, so passing a now earlier than an id already minted in this process does not produce an earlier id — it holds the last one and keeps counting, because a decreasing id would break the pagination cursor, which is the whole reason the core mints ids at all. A test that needs a fixed instant wants fixedClock, not this argument.

Error codes are SCREAMING_SNAKE, everything else is camelCase. The codes are data values, not keys. There is no snake_case key anywhere in this package, unlike Ory — a Biome naming-convention rule holds it.

list() costs what the subject can reach, every round. It walks backwards from the subject — every page of findObjects for every id each step reaches — and repeats a round whenever a relation loops back on itself (a folder viewable through its parent) until a round finds nothing new. Fine for what one user can see;