@nxgt/janus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/dist/auth/config.d.ts +201 -0
  4. package/dist/auth/config.d.ts.map +1 -0
  5. package/dist/auth/context.d.ts +87 -0
  6. package/dist/auth/context.d.ts.map +1 -0
  7. package/dist/auth/hashers.d.ts +35 -0
  8. package/dist/auth/hashers.d.ts.map +1 -0
  9. package/dist/auth/index.d.ts +9 -0
  10. package/dist/auth/index.d.ts.map +1 -0
  11. package/dist/auth/janus.d.ts +38 -0
  12. package/dist/auth/janus.d.ts.map +1 -0
  13. package/dist/auth/outage.d.ts +61 -0
  14. package/dist/auth/outage.d.ts.map +1 -0
  15. package/dist/auth/port/assert-stores.d.ts +16 -0
  16. package/dist/auth/port/assert-stores.d.ts.map +1 -0
  17. package/dist/auth/port/memory.d.ts +25 -0
  18. package/dist/auth/port/memory.d.ts.map +1 -0
  19. package/dist/auth/port/types.d.ts +393 -0
  20. package/dist/auth/port/types.d.ts.map +1 -0
  21. package/dist/auth/secrets.d.ts +17 -0
  22. package/dist/auth/secrets.d.ts.map +1 -0
  23. package/dist/auth/sessions.d.ts +31 -0
  24. package/dist/auth/sessions.d.ts.map +1 -0
  25. package/dist/auth/standard-schema.d.ts +41 -0
  26. package/dist/auth/standard-schema.d.ts.map +1 -0
  27. package/dist/auth/types.d.ts +353 -0
  28. package/dist/auth/types.d.ts.map +1 -0
  29. package/dist/auth/users.d.ts +9 -0
  30. package/dist/auth/users.d.ts.map +1 -0
  31. package/dist/chunks/index-658b6mr2.js +269 -0
  32. package/dist/chunks/index-658b6mr2.js.map +11 -0
  33. package/dist/chunks/index-6dytvy3h.js +93 -0
  34. package/dist/chunks/index-6dytvy3h.js.map +11 -0
  35. package/dist/chunks/index-6p56fpbe.js +147 -0
  36. package/dist/chunks/index-6p56fpbe.js.map +12 -0
  37. package/dist/chunks/index-fgb3t64y.js +77 -0
  38. package/dist/chunks/index-fgb3t64y.js.map +10 -0
  39. package/dist/conformance/assert.d.ts +36 -0
  40. package/dist/conformance/assert.d.ts.map +1 -0
  41. package/dist/conformance/cases/outage.d.ts +3 -0
  42. package/dist/conformance/cases/outage.d.ts.map +1 -0
  43. package/dist/conformance/cases/sessions.d.ts +3 -0
  44. package/dist/conformance/cases/sessions.d.ts.map +1 -0
  45. package/dist/conformance/cases/tokens.d.ts +3 -0
  46. package/dist/conformance/cases/tokens.d.ts.map +1 -0
  47. package/dist/conformance/cases/users.d.ts +3 -0
  48. package/dist/conformance/cases/users.d.ts.map +1 -0
  49. package/dist/conformance/describe.d.ts +74 -0
  50. package/dist/conformance/describe.d.ts.map +1 -0
  51. package/dist/conformance/fixtures.d.ts +11 -0
  52. package/dist/conformance/fixtures.d.ts.map +1 -0
  53. package/dist/conformance/index.d.ts +33 -0
  54. package/dist/conformance/index.d.ts.map +1 -0
  55. package/dist/conformance/index.js +1113 -0
  56. package/dist/conformance/index.js.map +18 -0
  57. package/dist/conformance/reference.d.ts +13 -0
  58. package/dist/conformance/reference.d.ts.map +1 -0
  59. package/dist/conformance/relations.d.ts +67 -0
  60. package/dist/conformance/relations.d.ts.map +1 -0
  61. package/dist/conformance/types.d.ts +71 -0
  62. package/dist/conformance/types.d.ts.map +1 -0
  63. package/dist/errors/janus-error.d.ts +259 -0
  64. package/dist/errors/janus-error.d.ts.map +1 -0
  65. package/dist/ids/id.d.ts +55 -0
  66. package/dist/ids/id.d.ts.map +1 -0
  67. package/dist/index.d.ts +30 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +962 -0
  70. package/dist/index.js.map +19 -0
  71. package/dist/pagination/cursor-page.d.ts +53 -0
  72. package/dist/pagination/cursor-page.d.ts.map +1 -0
  73. package/dist/permissions/engine.d.ts +46 -0
  74. package/dist/permissions/engine.d.ts.map +1 -0
  75. package/dist/permissions/index.d.ts +24 -0
  76. package/dist/permissions/index.d.ts.map +1 -0
  77. package/dist/permissions/index.js +685 -0
  78. package/dist/permissions/index.js.map +15 -0
  79. package/dist/permissions/input.d.ts +29 -0
  80. package/dist/permissions/input.d.ts.map +1 -0
  81. package/dist/permissions/model.d.ts +368 -0
  82. package/dist/permissions/model.d.ts.map +1 -0
  83. package/dist/permissions/port/memory.d.ts +17 -0
  84. package/dist/permissions/port/memory.d.ts.map +1 -0
  85. package/dist/permissions/port/types.d.ts +84 -0
  86. package/dist/permissions/port/types.d.ts.map +1 -0
  87. package/dist/permissions/resolve.d.ts +59 -0
  88. package/dist/permissions/resolve.d.ts.map +1 -0
  89. package/dist/permissions/reverse.d.ts +53 -0
  90. package/dist/permissions/reverse.d.ts.map +1 -0
  91. package/dist/permissions/walk.d.ts +29 -0
  92. package/dist/permissions/walk.d.ts.map +1 -0
  93. package/dist/subjects/notation.d.ts +35 -0
  94. package/dist/subjects/notation.d.ts.map +1 -0
  95. package/dist/subjects/subject.d.ts +75 -0
  96. package/dist/subjects/subject.d.ts.map +1 -0
  97. package/dist/time/clock.d.ts +31 -0
  98. package/dist/time/clock.d.ts.map +1 -0
  99. package/dist/time/duration.d.ts +23 -0
  100. package/dist/time/duration.d.ts.map +1 -0
  101. package/docs/README.md +17 -0
  102. package/docs/guide/adapters.md +296 -0
  103. package/docs/guide/email-flows.md +146 -0
  104. package/docs/guide/errors.md +146 -0
  105. package/docs/guide/passwords.md +136 -0
  106. package/docs/guide/permissions.md +347 -0
  107. package/docs/guide/sessions.md +211 -0
  108. package/docs/guide/users.md +277 -0
  109. package/docs/guide/vocabulary.md +163 -0
  110. package/docs/roadmap.md +93 -0
  111. package/docs/troubleshooting.md +648 -0
  112. package/package.json +70 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Tsala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,554 @@
1
+ # @nxgt/janus
2
+
3
+ Authentication and permissions as an **embeddable** TypeScript library: your
4
+ process, your database, behind a port you can implement.
5
+
6
+ ```ts
7
+ import { z } from 'zod';
8
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
9
+
10
+ const auth = janus({
11
+ user: z.object({ email: z.email(), name: z.string() }),
12
+ password: { login: 'email' },
13
+ store: createMemoryStores(),
14
+ hasher: scryptHasher(),
15
+ });
16
+
17
+ const { user, token } = await auth.signUp({ email, name, password });
18
+ const current = await auth.authenticate(request); // { user, session, token, renewed } | null
19
+ ```
20
+
21
+ > **0.x.** A minor version may still change the surface. `.` is `janus()` and the vocabulary it shares with the
22
+ > permissions — errors, subjects, pagination, time, ids. `./permissions` is the
23
+ > ReBAC engine. `./conformance` is the suite an adapter runs. A subpath appears in `exports`
24
+ > only once it exports something you should call, because a published entry
25
+ > point is a promise.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ bun add @nxgt/janus
31
+ ```
32
+
33
+ No runtime dependency. `typescript` (6) is a required peer. Your tsconfig resolves as a
34
+ bundler does (`"moduleResolution": "bundler"`, which Bun and every bundler
35
+ use): the declarations import without extensions, so `nodenext` is not
36
+ supported.
37
+
38
+ ## Subpaths
39
+
40
+ | Import | What it holds |
41
+ | --- | --- |
42
+ | `@nxgt/janus` | `janus()`, the store port and its reference store (`createMemoryStores`), the hashers, and the vocabulary shared with the permissions: errors, subjects and the tuple notation, ids, pagination, time |
43
+ | `@nxgt/janus/permissions` | `defineModel`, `fromField`, `when`, `permissions()` — `can`, `list`, `grant`, `revoke` — the `RelationStore` port and `createMemoryRelations()` |
44
+ | `@nxgt/janus/conformance` | The suites an adapter runs — `describeJanusStores`, `describeRelationStores` — their cases as data, and the reference harnesses |
45
+
46
+ ## The one rule
47
+
48
+ **An absence is `null`. A failure throws.**
49
+
50
+ Everything else in this package is downstream of that sentence. A store that
51
+ cannot answer — a refused connection, a timeout, a primary stepping down, a bug
52
+ in the adapter — **throws**, and a caller answers 503. Mapping that to a 404, to
53
+ `null` or to `false` turns an outage into a silent lockout: everybody who has an
54
+ account is told they do not. That has been measured twice in this organisation,
55
+ two days apart, which is why it is a term of the port here rather than a note in
56
+ the documentation.
57
+
58
+ ## API
59
+
60
+ ### Errors
61
+
62
+ ```ts
63
+ import { JanusError, StoreFailure, NotFoundError, type JanusErrorCode } from '@nxgt/janus';
64
+
65
+ async function signIn(email: string, password: string): Promise<Response> {
66
+ try {
67
+ const { token } = await auth.signIn({ email, password });
68
+ return Response.json({ token });
69
+ } catch (error) {
70
+ if (error instanceof JanusError && error.code === 'CREDENTIALS_INVALID') {
71
+ return new Response(null, { status: 401 });
72
+ }
73
+ throw error; // STORE_FAILED included: that is your 503, never a 401
74
+ }
75
+ }
76
+ ```
77
+
78
+ `JanusError` is the base of everything thrown at call time. It extends `Error`,
79
+ so no consumer has to order their `catch` blocks. `code` is a union of sixteen
80
+ string literals, so a `switch` over it is exhaustive and adding a code breaks the
81
+ compilation of callers that exhaust it:
82
+
83
+ | Code | Answer it deserves |
84
+ | --- | --- |
85
+ | `STORE_FAILED` | **503.** Never a negative answer |
86
+ | `NOT_FOUND` | 404 |
87
+ | `LOGIN_TAKEN`, `VERSION_CONFLICT` | 409 |
88
+ | `USER_INVALID` | 400, field by field from `issues` |
89
+ | `PASSWORD_TOO_SHORT`, `HASH_UNSUPPORTED` | 400 |
90
+ | `CREDENTIALS_INVALID` | 401 — one code for an unknown login, no password and a wrong one |
91
+ | `USER_INACTIVE` | 403 |
92
+ | `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | 400 |
93
+ | `INVALID_CURSOR` | 400 |
94
+ | `UNSUPPORTED` | 501 — a wiring mistake, and the message names the store to change |
95
+ | `PERMISSION_DEPTH` | 500 — a permission check or list walked past `maxDepth`; not a denial |
96
+
97
+ Each code has its class, all exported: `StoreFailure`, `StoreConflict` (`on:
98
+ 'login' | 'version'`), `NotFoundError`, `UserInvalidError`, `CredentialError`,
99
+ `UserInactiveError`, `TokenError`, `InvalidCursorError`, `UnsupportedError`
100
+ and `PermissionDepthError`. `StoreFailure` and `StoreConflict` are exported
101
+ **because an adapter throws them**. An adapter defines no error class of its own, so `instanceof` holds
102
+ across the two packages.
103
+
104
+ **No message ever holds a secret** — not a password, not a hash, not a session
105
+ token, not a token's hash, and not a connection URI, because a connection string
106
+ holds a password. A `login` may appear in a `LOGIN_TAKEN` message, since the
107
+ caller just sent it.
108
+
109
+ A refusal that can only come from how you wired the library — a lifespan that is
110
+ not a duration, a store missing a method — throws a bare `TypeError` instead. No
111
+ request handler should ever answer one, so no handler needs to tell it apart.
112
+
113
+ ### Subjects
114
+
115
+ ```ts
116
+ import { type Subject, subjectOf, formatTuple, parseTuple, isSubjectSet } from '@nxgt/janus';
117
+
118
+ subjectOf(user); // { type: 'staff', id: '…' }: the user IS the subject
119
+ formatTuple({
120
+ object: { type: 'record', id: 'r1' },
121
+ relation: 'viewer',
122
+ subject: { type: 'team', id: 't1', relation: 'member' },
123
+ });
124
+ // 'record:r1#viewer@team:t1#member'
125
+ parseTuple('record:r1#viewer@staff:u1'); // the RelationTuple back
126
+ ```
127
+
128
+ `formatEntity`, `formatSubject` and `parseSubject` do the same for one part,
129
+ and `isSubjectSet` tells `{ type, id, relation }` from `{ type, id }`. The
130
+ types are `Entity`, `SubjectSet`, `Subject` (either) and `RelationTuple`.
131
+
132
+ In Ory, the equality between a Kratos identity id and Keto's `subject_id` is a
133
+ comment and a convention, restated in three repositories and enforced nowhere.
134
+ Here it is a type and a one-line function — and that shared vocabulary is the
135
+ reason users and permissions are one package rather than two.
136
+
137
+ **Subjects are typed**, unlike Keto's: `{ type, id }` for one entity, and
138
+ `{ type, id, relation }` for a subject set. One application has patients and
139
+ staff, and an object can hold a relation too, so a bare id does not say who.
140
+ `type` is the same word as a user's own.
141
+
142
+ ### Ids
143
+
144
+ ```ts
145
+ import { type Id, mintId, isId, mintedAt } from '@nxgt/janus';
146
+
147
+ const id: Id = mintId(); // '0199…': a UUIDv7
148
+ isId(id); // true — and false for anything this package could not have minted
149
+ mintedAt(id); // a Date, to the millisecond
150
+ ```
151
+
152
+ UUIDv7, **minted by the core and not by the store**. Ids sort in creation order
153
+ as strings, so the pagination cursor *is* the last id: one index, and the
154
+ ordering is already total. `insertUser` becomes idempotent under retry, and
155
+ every adapter reports the same shape. The price, stated plainly: an adapter
156
+ cannot reuse an existing numeric primary key.
157
+
158
+ ### Pagination and time
159
+
160
+ ```ts
161
+ import { fixedClock, parseDuration } from '@nxgt/janus';
162
+
163
+ let cursor: string | null = null;
164
+ do {
165
+ const page = await auth.list({ after: cursor, limit: 100 }); // CursorPage<User>
166
+ cursor = page.nextCursor;
167
+ } while (cursor);
168
+
169
+ const clock = fixedClock(Date.UTC(2026, 0, 1)); // .now(), .advance(ms), .set(at)
170
+ parseDuration('8h', 'session.lifespan'); // 28800000
171
+ ```
172
+
173
+ The types are `CursorPage<T>`, `Clock` and `Duration` (`'15m'`, `'8h'`, `'7d'`,
174
+ or milliseconds). `DEFAULT_PAGE_SIZE` (20), `MAX_PAGE_SIZE` (100), `pageLimit` and
175
+ `invalidCursor` are what an adapter uses to page the way the core does;
176
+ `systemClock` is the default `Clock`.
177
+
178
+ `CursorPage` has `items` and `nextCursor`, and **no `total`**: a count over a
179
+ cursor-paged collection is a second query whose answer is stale by the time you
180
+ read it. `nextCursor` is `string | null` with no `undefined`, so `while (cursor)`
181
+ is the loop.
182
+
183
+ `fixedClock` is **shipped, not test-only** — testing session expiry needs it, and
184
+ so do your own tests.
185
+
186
+ ### Users — `janus()`
187
+
188
+ ```ts
189
+ import { z } from 'zod';
190
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
191
+
192
+ // One kind of user
193
+ const auth = janus({
194
+ user: z.object({ email: z.email(), name: z.string() }),
195
+ password: { login: 'email' },
196
+ store: createMemoryStores(),
197
+ hasher: scryptHasher(),
198
+ });
199
+
200
+ await auth.signUp({ email, name, password }); // { user, session, token }
201
+ await auth.signIn({ email, password }); // { user, session, token }
202
+ await auth.authenticate(request); // { user, session, token, renewed } | null
203
+ await auth.signOut(request);
204
+ await auth.verifyEmail.send(user); // { token, email, expiresAt } — sending it is yours
205
+ await auth.verifyEmail.confirm(token);
206
+ await auth.resetPassword.request(email); // … | null
207
+ await auth.resetPassword.confirm(token, newPassword);
208
+
209
+ // Several kinds of user
210
+ const clinic = janus({
211
+ users: {
212
+ patient: { schema: Patient, password: { login: 'email' } },
213
+ staff: {
214
+ schema: Staff,
215
+ password: { login: 'username' },
216
+ session: { lifespan: '8h', renewAfter: false },
217
+ },
218
+ },
219
+ store,
220
+ hasher,
221
+ });
222
+
223
+ await clinic.staff.signIn({ username, password });
224
+ const current = await clinic.authenticate(request);
225
+ if (current?.user.type === 'staff') current.user.service; // narrowed by type
226
+ await clinic.authenticate(request, { type: 'staff' }); // a patient's session → null
227
+ ```
228
+
229
+ `janus()` assembles **synchronously and with no I/O**: it checks that every
230
+ store answers every method of the port, and connects to nothing. Everything
231
+ else reaches the store and is asynchronous.
232
+
233
+ - **A user is your schema's fields, at the top level**, plus what `janus` sets:
234
+ `id`, `type`, `emailVerified`, `active`, `hasPassword`, `version`,
235
+ `createdAt`, `updatedAt`. A schema declaring one of those, or a `password`, is
236
+ refused at compile time. The password hash never reaches a user.
237
+ - **Schemas** are any [Standard Schema](https://standardschema.dev) — Zod 4,
238
+ Valibot, ArkType. There is no validation peer. The output must be JSON, and a
239
+ schema producing a `Date` is refused at compile time.
240
+ - **Several user types** live in one instance: `auth.patient.*`, `auth.staff.*`,
241
+ and one `authenticate` whose answer is a union narrowed by `user.type`. A
242
+ login is unique **per type**: the same e-mail may hold a patient account and
243
+ a staff account.
244
+ - **`password.login`** names a top-level, required string field. A typo is a
245
+ compile error on `login`, and the message lists the fields you could have
246
+ meant. It is normalised with `'lowercaseTrim'` unless you say otherwise.
247
+ - **`email`** defaults to the field named `email`. A type without one has no
248
+ `verifyEmail` and no `resetPassword` — they are absent from its type, not
249
+ failing at run time. Changing the e-mail sets `emailVerified` back to `false`.
250
+ - **Per type**: `create`, `find` (or `null`), `get` (or `NOT_FOUND`), `list`,
251
+ `update(user, patch)` — merged over the stored fields, then validated whole —
252
+ `setActive` and `delete`; with a password, `signUp`, `signIn`, `findByLogin`,
253
+ `setPassword` and `changePassword`. Every write but `delete` takes an
254
+ optional `ifVersion`.
255
+ - **`delete(user)`** deletes the user together with every session and one-time
256
+ token they had, so nothing of theirs is kept: a token holds the e-mail it was
257
+ sent to. The user goes first, so an outage half-way leaves only sessions and
258
+ tokens that authenticate nobody. It is idempotent, and calling it again
259
+ finishes the job. It answers `false` for an unknown id, or for one of another
260
+ type, and leaves that user untouched.
261
+ - **Shared**: `authenticate`, `signOut`, `signOutEverywhere(user, { except })`,
262
+ `findUser` and `getUser` across types, `cookie.serialize(token, session)` and
263
+ `cookie.clear()` — `HttpOnly; SameSite=Lax; Secure` unless you say otherwise
264
+ — and `collectExpired`.
265
+ - **Sessions** last `'7d'` and slide: `authenticate` renews one once `renewAfter`
266
+ (`'1d'`) has passed, writing at most once per period, and says so with
267
+ `renewed`. The token is handed back once; the store only holds its `sha256`.
268
+
269
+ **Hashers.** `scryptHasher()` runs on Node and on Bun, with no dependency and
270
+ OWASP's parameters (N = 2^17, r = 8, p = 1). `bunHasher()` is argon2id through
271
+ `Bun.password`, on Bun only. There is no silent fallback: a user type with a
272
+ password and no `hasher` is refused at wiring. Hashes describe themselves
273
+ (`$scrypt$ln=17,r=8,p=1$…`, `$argon2id$…`). Wire the hashers a database was
274
+ written with as `verifiers`, and every one of them can verify while exactly one
275
+ hashes.
276
+
277
+ **Rehash on sign-in.** When a password matches a stale hash, `signIn` rewrites
278
+ it with `hasher`. A hash is stale when a `verifiers` hasher wrote it, or when
279
+ `hasher` wrote it with other parameters than it uses now: a raised scrypt
280
+ `cost`, or argon2id parameters other than the pinned `m=65536,t=2,p=1`. Moving
281
+ off a hasher, or raising its cost, therefore reaches every active user with no
282
+ migration to run. The password's `updatedAt` is kept, since the password did not
283
+ change; the user's `version` moves. The write happens only at the version just
284
+ read. If a concurrent update wins, the sign-in still succeeds and the next
285
+ sign-in tries again. An outage on that write still fails the sign-in.
286
+
287
+ **The port.** `JanusStores` is three stores — `UserStore`, `SessionStore`,
288
+ `TokenStore`, whose records are `UserRecord`, `SessionRecord` and
289
+ `TokenRecord` — in the slots `users`, `sessions`, `tokens`,
290
+ cut where atomicity is not required, so sessions can live in Redis while users
291
+ live in MongoDB. `createMemoryStores()` is the reference implementation. It is
292
+ shipped for your own tests, and it is what to compare against when writing an
293
+ adapter. `assertStores(store, where)` is the check `janus()` runs on it, for an
294
+ adapter that wants to fail as early. The six rules an adapter keeps are
295
+ written on the port's types.
296
+
297
+ ### Permissions — `@nxgt/janus/permissions`
298
+
299
+ ```ts
300
+ import { defineModel, fromField, when, permissions, createMemoryRelations } from '@nxgt/janus/permissions';
301
+
302
+ export const model = defineModel({
303
+ subjects: clinic.types, // 'patient' | 'staff': a user type is a subject type
304
+ types: {
305
+ team: {
306
+ relations: { member: ['staff', 'team#member'], lead: ['staff'] },
307
+ permissions: { manage: ['lead'], view: ['member', 'manage'] },
308
+ },
309
+ record: {
310
+ relations: {
311
+ doctor: fromField('doctorId', 'staff', { lookup: (id) => db.records.ids({ doctorId: id }) }),
312
+ team: ['team'],
313
+ },
314
+ permissions: {
315
+ view: ['doctor', 'team->view'],
316
+ edit: [when('doctor', (ctx: { onShift: boolean }) => ctx.onShift)],
317
+ },
318
+ },
319
+ },
320
+ });
321
+
322
+ const access = permissions({ model, store: createMemoryRelations() });
323
+ await access.grant({ type: 'team', id: 't1' }, 'member', staff);
324
+ await access.can(staff, 'edit', { type: 'record', ...record }, { ctx: { onShift } }); // boolean
325
+ await access.list(staff, 'view', 'record', { limit: 50 }); // CursorPage<string>; view reaches no condition
326
+ await access.revoke({ type: 'team', id: 't1' }, 'member', staff); // idempotent
327
+ ```
328
+
329
+ Zanzibar's model — relations between objects and subjects, permissions
330
+ computed from them — **without its infrastructure**: the tuples live in your
331
+ database, so a read follows a write and there is nothing to cache or to
332
+ sequence. Subject sets (`'team#member'`), arrows (`'team->view'`: whoever can
333
+ view the record's team) and permissions naming permissions are Zanzibar's. Two
334
+ things are not:
335
+
336
+ - **`fromField`** reads a relation from the object's own data — a record's
337
+ `doctorId` — instead of a tuple kept in sync with it. `can()` is given the
338
+ object, and the compiler requires every field a `fromField` of its type
339
+ reads. `list()` cannot read a field of objects it has not found, so it asks
340
+ the `lookup`;
341
+ - **`when`** puts a condition written in TypeScript on a rule. Its `ctx` is
342
+ what `can()` and `list()` then require — and only for the permissions whose
343
+ rules reach it.
344
+
345
+ **A denial is `false`, a failure throws.** A relation store that cannot answer
346
+ is `STORE_FAILED`; a walk that crosses more than `maxDepth` relations (`25`) is
347
+ `PERMISSION_DEPTH`. Neither is ever `false`, which would deny everybody
348
+ everything during an outage and say nothing. A cycle in the data — a team
349
+ member of itself — is cut, and is not an error. `null` is anonymous: `false`,
350
+ or an empty page, before any store call.
351
+
352
+ **Everything is typed from the model.** A relation naming a type that does not
353
+ exist, a rule naming nothing, an arrow to a permission its target lacks, a
354
+ permission asked of the wrong type, an object missing a field, a missing
355
+ `ctx`, a `grant` of a relation read from a field or to a holder it does not
356
+ admit, a `list()` through a `fromField` without a `lookup`: each is a compile
357
+ error, on the offending argument. `defineModel` refuses with a `TypeError`
358
+ what only running it can see: names that are not camelCase, a permission that
359
+ reaches itself without crossing a relation, a subject set or an arrow that
360
+ would have to read another object's field.
361
+
362
+ **Wire the relation store into `janus()` too** — `janus({ …, relations })` —
363
+ and deleting a user deletes every tuple naming them. Deleting an object's
364
+ tuples is `store.deleteEntity({ type, id })`, from your own code.
365
+
366
+ The port is `RelationStore`: six methods answering one-hop questions about
367
+ stored tuples (`write`, `has`, `findSubjectSets`, `findEntities`,
368
+ `findObjects`, `deleteEntity`). The traversal is the core's, written once.
369
+
370
+ ### Conformance — `@nxgt/janus/conformance`
371
+
372
+ If you write an adapter, you run this suite against it:
373
+
374
+ ```ts
375
+ import { describe, it } from 'bun:test';
376
+ import { describeJanusStores } from '@nxgt/janus/conformance';
377
+
378
+ describeJanusStores({
379
+ name: 'my adapter',
380
+ runner: { describe, it },
381
+ harness: {
382
+ async open() {
383
+ const db = await freshDatabase(); // one per case, never shared
384
+ return {
385
+ stores: myStores(db),
386
+ faults: { fail: (slot, method) => db.failNext(method) },
387
+ close: () => db.drop(),
388
+ };
389
+ },
390
+ },
391
+ });
392
+ ```
393
+
394
+ There are 37 cases. They cover:
395
+ - round-trip, byte for byte;
396
+ - uniqueness, as a constraint: of twenty concurrent inserts of one login,
397
+ exactly one is accepted — and a login is unique per user type;
398
+ - versions: a refused update writes nothing;
399
+ - **omission**, named after the Kratos `PUT` trap;
400
+ - pagination;
401
+ - sessions;
402
+ - one-time tokens: of twenty concurrent redemptions, exactly one succeeds;
403
+ - deletion: a user's logins are freed, and every session and token of theirs
404
+ goes, with a replay answering `false` or `0` rather than failing;
405
+ - **outages**, one case for each of the eleven methods whose honest answer can
406
+ be "nothing".
407
+
408
+ The suite imports no test framework and no assertion library. It runs under
409
+ `bun test`, vitest and jest. Its cases are also exported as data
410
+ (`allCases`, or by group: `userStoreCases`, `sessionStoreCases`,
411
+ `tokenStoreCases`, `outageCases`), with `runCase` to run one without any
412
+ runner. `skip: { [caseId]: reason }` skips a case and reports why;
413
+ `SKIP_REASONS` holds the reasons the suite gives itself.
414
+
415
+ **`faults` is optional, and its absence is reported, never passed over.**
416
+ Without it, the outage cases are skipped under the reason *"the outage
417
+ invariant is not proven for this adapter"*. Make your database fail the way it
418
+ really fails — for MongoDB, the `failCommand` failpoint with code 91. A wrapper
419
+ that throws in front of your adapter proves the wrapper, not the adapter.
420
+ Fail **only the method named**: `outage.write` reads the store back afterwards,
421
+ to prove the rejected write changed nothing.
422
+
423
+ `referenceHarness()` runs the suite against the reference store, and is the
424
+ example to copy.
425
+
426
+ A relation store has its own suite, `describeRelationStores({ name, harness })`
427
+ — 15 cases: round-trip, a subject whose `relation` is `undefined` read as its
428
+ entity, absence, idempotent writes, a tuple stored once, one write's removals
429
+ and additions applied together, the one-hop reads, the reverse index in pages,
430
+ `deleteEntity`, and an outage for each of the six methods — a write that
431
+ rejects must have changed nothing. `referenceRelationHarness()` is its
432
+ example; `allRelationCases`, `relationStoreCases`, `relationOutageCases` and
433
+ `runRelationCase` are the runner-less layer.
434
+
435
+ ## Traps
436
+
437
+ **Narrowing a model hides stored tuples; it does not delete them.** A tuple
438
+ the model no longer admits grants nothing, and `revoke()` refuses it — remove
439
+ it with `relations.write({ remove: [tuple] })`, or widening the model again
440
+ brings it back.
441
+
442
+ **The first session credential present wins, not the first valid one.**
443
+ `Authorization: Bearer`, then `X-Session-Token`, then the cookie. A client that
444
+ sends a lapsed bearer beside a live cookie is anonymous, and it should fix its
445
+ header rather than be rescued in silence.
446
+
447
+ **An outage is not anonymous.** `authenticate` rejects with `STORE_FAILED` when
448
+ the store cannot answer. Answer 503: a 401 would sign everybody out during an
449
+ outage, and send them to a sign-in page that cannot work either.
450
+
451
+ **Never put a `CREDENTIALS_INVALID`'s `reason` in a response body.**
452
+ `unknownLogin` is an account-enumeration oracle. `signIn` compares against a
453
+ dummy hash when nobody holds the login, so the hashing time does not tell.
454
+ **The store's own latency still does**, and that limit is stated rather than
455
+ denied. `resetPassword.request` answers `null` for an unknown e-mail for the
456
+ same reason: answer the visitor the same page either way.
457
+
458
+ **`resetPassword.confirm` signs the user out everywhere, and opens no session.**
459
+ Whoever had the old password loses their sessions; what the visitor does next is
460
+ your policy. A password refused for its length does not spend the token.
461
+
462
+ **A sign-in can move a user's `version`.** Rewriting a stale hash is a write. A
463
+ user object read before that sign-in, and then passed as `ifVersion`, gets
464
+ `VERSION_CONFLICT`. That is the conflict doing its job: read the user again.
465
+
466
+ **Expiry is decided by the core, not by the store.** A store may still hold a
467
+ lapsed session, and `authenticate` answers it as anonymous. A TTL index keeps
468
+ storage tidy; it is not the expiry mechanism.
469
+
470
+ **`update` merges, then validates the whole.** The patch is spread over the
471
+ stored fields and the result is checked against the schema, so a patch can
472
+ never leave a user that the schema would refuse. Pass `ifVersion` to make the
473
+ write conditional on what you read.
474
+
475
+ **Under `bun test`, pass `runner: { describe, it }`.** Measured: Bun gives a
476
+ test file `describe` and `it` as bare identifiers, not as properties of
477
+ `globalThis`. jest, and vitest with `globals: true`, are found without it.
478
+
479
+ **`undefined` is not an absence here.** Every method that can find nothing
480
+ answers `null`. `undefined` is what a missing property *and* a function with no
481
+ `return` both produce, so a store that forgot to answer would report "not found"
482
+ by accident. `null` has to be written on purpose.
483
+
484
+ **The notation is typed, and refuses Keto's untyped subject.**
485
+ `record:r1#viewer@staff:u1`, and `record:r1#viewer@team:t1#member` for a subject
486
+ set. No part may hold `@`, `#` or a parenthesis, and a type may not hold a `:`,
487
+ so every string reads one way. `parseTuple` refuses `record:r1#viewer@alice`,
488
+ and its message says what a subject is.
489
+
490
+ **`parseTuple` throws a bare `TypeError`, not a `JanusError`.** Nothing in this
491
+ package reads a tuple off the network, so a malformed string came from your own
492
+ code — a wiring mistake, and no handler should answer one.
493
+
494
+ **`mintedAt` is not `createdAt`.** The sequence may have borrowed a millisecond
495
+ and a clock that stepped backwards is held rather than followed, so it is
496
+ accurate to the millisecond and no further.
497
+
498
+ **`mintId(now)` steers ids forward, never back.** The last millisecond is
499
+ module state, so passing a `now` earlier than an id already minted in this
500
+ process does not produce an earlier id — it holds the last one and keeps counting,
501
+ because a decreasing id would break the pagination cursor, which is the whole
502
+ reason the core mints ids at all. A test that needs a fixed instant wants
503
+ `fixedClock`, not this argument.
504
+
505
+ **Error codes are `SCREAMING_SNAKE`, everything else is `camelCase`.** The codes
506
+ are data values, not keys. There is no `snake_case` key anywhere in this package,
507
+ unlike Ory — a Biome naming-convention rule holds it.
508
+
509
+ **`list()` costs what the subject can reach, every round.** It walks backwards
510
+ from the subject — every page of `findObjects` for every id each step reaches —
511
+ and repeats a round whenever a relation loops back on itself (a folder
512
+ viewable through its parent) until a round finds nothing new. Fine for what one
513
+ user can see; not for a subject set holding most of the database, which wants
514
+ a query of your own.
515
+
516
+ **`can()` wants the loaded object, spread.** `{ type: 'record', ...record }`: a
517
+ `fromField` reads its field there, and a field missing at run time is a
518
+ `TypeError`, never a denial. `null` in the field holds nobody.
519
+
520
+ **A `lookup` is your code, and not guarded.** A lookup that throws rejects
521
+ `list()` with its own error, as it threw. Never answer `[]` for a database that
522
+ could not answer: that is a denial made of an outage.
523
+
524
+ ## Documentation
525
+
526
+ - [Guides](docs/README.md) — one page per area, every option with an example
527
+ - [Troubleshooting](docs/troubleshooting.md) — by the error message you see
528
+ - [Roadmap](docs/roadmap.md) — what is next, and what is not planned
529
+
530
+ ## Type safety, counted
531
+
532
+ **Seventy-five plausible mistakes, seventy-five refused at compile time — and
533
+ one gap, named.**
534
+
535
+ The lists are typechecked and never run, with one `@ts-expect-error` per
536
+ mistake beside the shapes that must keep compiling:
537
+ `test/types/refusals.ts` (fourteen, on the shared vocabulary),
538
+ `test/types/port.ts` (fifteen, on the store port, from the side of the person
539
+ implementing it), `test/types/auth.ts` (twenty, on `janus()`, from the side
540
+ of the application) and `test/types/permissions.ts` (twenty-six, on the
541
+ permission model and the questions asked of it). The rule
542
+ comes from `nxgt-data`, and so does the reason to
543
+ distrust the claim without the files: when it was last measured on
544
+ `@nxgt/mongo`, *seven of twelve plausible mistakes still compiled*. A count
545
+ that goes down is a visible regression.
546
+
547
+ The gap, since a measurement that only reports wins is not a measurement:
548
+ `'30 m'` **satisfies `Duration`**, because TypeScript's `${number}` placeholder
549
+ tolerates trailing whitespace inside the number. `parseDuration` refuses it, and
550
+ `duration.spec.ts` asserts that. It is written down rather than omitted.
551
+
552
+ ## Licence
553
+
554
+ MIT