@nxgt/janus
v0.17.1
Published
Embeddable, type-safe identities and permissions: bring your own database
Maintainers
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 } | null0.x. A minor version may still change the surface; the changelog says how.
Install
bun add @nxgt/janusNo 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' }); // trueBoth — 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 backformatEntity, 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 millisecondUUIDv7, 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'); // 28800000The 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 → nulljanus() 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
janussets:id,type,emailVerified,active,hasPassword,hasSecondFactor,version,createdAt,updatedAt. A schema declaring one of those, or apassword, 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
Dateis refused at compile time.Several user types live in one instance:
auth.patient.*,auth.staff.*, and oneauthenticatewhose answer is a union narrowed byuser.type. A login is unique per type: the same e-mail may hold a patient user and a staff user.password.loginnames a top-level, required string field. A typo is a compile error onlogin, and the message lists the fields you could have meant. It is normalised with'lowercaseTrim'unless you say otherwise.emaildefaults to the field namedemail. A type without one has noverifyEmail, noresetPassword, nosignInCodeand nomagicLink— they are absent from its type, not failing at run time. Changing the e-mail setsemailVerifiedback tofalse.Per type:
create,find(ornull),get(orNOT_FOUND),list,update(user, patch)— merged over the stored fields, then validated whole —setActiveanddelete; with a password,signUp,signIn,findByLogin,setPasswordandchangePassword. Every write butdeletetakes an optionalifVersion.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 answersfalsefor an unknown id, or for one of another type, and leaves that user untouched.Shared:
authenticate,signOut,signOutEverywhere(user, { except }),findUserandgetUseracross types,cookie.serialize(token, session)andcookie.clear()—HttpOnly; SameSite=Lax; Secureunless you say otherwise — andcollectExpired.Password guessing is throttled, on by default: past ten passwords tried at one login in a 15-minute window,
signInrefuses every one — the right password included — withCREDENTIALS_INVALID,reason: 'throttled'andretryAfter, 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 throwsSTORE_FAILED(passwords).janus({ user, password: { login: 'email' }, store, hasher, signIn: { throttle: { attempts: 5, window: '1h' } } });Sessions last
'7d'and slide:authenticaterenews one oncerenewAfter('1d') has passed, writing at most once per period, and says so withrenewed. The token is handed back once; the store only holds itssha256.
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 kindspendUserTokens(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 unspentreauthenticateSession(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 codesA TOTP second factor — the six-digit codes of any authenticator app — for every user type with a password.
signInanswers a union oncesecondFactoris configured:{ status: 'signedIn', user, session, token }, or{ status: 'secondFactor', challenge, expiresAt, userId }for a user whose factor is active. Switch onstatus: readingtokenbefore that is a compile error. WithoutsecondFactor,signInanswers a session, as before.- A factor is enrolled, then active.
enrollanswers the secret and itsotpauth://URI once; enrolling again replaces a factor still waiting.activatechecks a first code, and only then doessignInask for one —hasSecondFactorsays so. confirm(challenge, code)opens the session. A challenge lives'5m'(secondFactor.challenge) and takes five attempts: a wrong code isCODE_INVALIDwithattemptsLeft, and the fifth spends the challenge. A code is accepted once, so a replay isCODE_INVALIDtoo.- Recovery codes, for a lost phone.
activateanswers aRecoveryCodesIssued—{ 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 aRecoveredSignIn: the session withrecoveryCodesLeft.recoveryCodesLeft(user)reads that count again — for auser.recoveryCodeUsedlistener, which has the id only — and answersnullfor a user with no active factor.regenerateRecoveryCodes(user, code)replaces them all, on a fresh code from the app, and answers aRecoveryCodesIssuedtoo;disableremoves them.regenerateRecoveryCodestakes five attempts per user per 15-minute window, counted by the store: a wrong code isCODE_INVALIDwithattemptsLeft, and past the fifth every call is refused until the next window. keysseal 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
verifyEmailbefore offeringenroll, and an owner's factor is never dropped this way. - Asking for a password or a code before
enroll,disableorregenerateRecoveryCodesis your policy, not the library's — a recentsession.authenticatedAtis one rule.regenerateRecoveryCodesasks 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
signInCodeat all. request(email)answersnullwhen nobody of this type holds that e-mail, or the user is inactive — never say which. It answers thecodefor the e-mail and thechallengefor 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 isCODE_INVALIDwithattemptsLeft, and the fifth spends it. An e-mail changed since isTOKEN_STALE; a user deactivated since isUSER_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.confirmdoes 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.passwordChangedfollowsuser.emailVerifiedwhen a password was dropped, thenuser.secondFactorDisabledwhen 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
secondFactorconfigured,confirmanswersSignInResulton a type with a password, and a user whose factor is active gets a challenge forsecondFactor.confirm— switch onstatus, as aftersignIn.
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 nomagicLinkat all. request(email)answersnullwhen nobody of this type holds that e-mail, or the user is inactive — never say which. It answers atokenof 32 random bytes, base64url, for a link; only its hash is stored. One link is live per user: a newrequestspends 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
requestper 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: nowA 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 nostepUp. request(user)answersvia: 'email'— a six-digitcodeto send toemail— 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: itsauthenticatedAtmoves 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, withregenerateRecoveryCodes. A request whose session is not a standing one of the challenge's user isTOKEN_UNKNOWN.assertFresh(session, maxAge, clock?)readsauthenticatedAtand nothing else:STEP_UP_REQUIRED(403) when it ismaxAgeold 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.newDeviceSignedInA 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 ofsignUp,signIn,secondFactor.confirm,secondFactor.recover,signInCode.confirmandmagicLink.confirm— aSignInOptions: the token the client holds, ornullwhen it holds none. Absent, the call is not tracked:newDevice: false,deviceToken: null, no event.- Every answer that opens a session carries
newDeviceanddeviceToken.signUpmints the first token and is never new. A malformed, forged or another user's token is a new device, never an error. user.newDeviceSignedInis sent for a new device, once the sign-in is complete, with the new session'ssessionId.devices: { keys }— aDevicesConfig— wires it; the keys takesecondFactor.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 answersjanus({ 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.emailChangedcarriesformerEmail, which nothing keeps once the write landed, so a notice can reach the inbox the account just left; anduser.newDeviceSignedIncarriessessionId, the session the new device holds.@nxgt/janus-webhooksposts neither. - Each event has an
idof 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.
occurredAtis the write's own time. A refused flow sends nothing. - Typed:
eventsis aUserEventListener;UserEventTypeis the closed union of the eleven types, so aswitchonevent.typeis exhaustive — and a new type, like the two recovery-code ones in 0.10, the two change ones in 0.14 oruser.newDeviceSignedInin 0.17, breaks it until handled. - A listener that throws fails no flow — the write happened. It is a
JANUS_EVENT_FAILEDwarning 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); // idempotentZanzibar'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:
fromFieldreads a relation from the object's own data — a record'sdoctorId— instead of a tuple kept in sync with it.can()is given the object, and the compiler requires every field afromFieldof its type reads.list()cannot read a field of objects it has not found, so it asks thelookup;whenputs a condition written in TypeScript on a rule. Itsctxis whatcan()andlist()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
PUTtrap; - pagination;
- sessions:
reauthenticateSessionmovesauthenticatedAtand 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
CHECKor an enum that forgotstepUpormagicLinkfails 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 concurrentcountAttemptcalls, 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 byexcept, 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
falseor0rather 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;
