@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
@@ -0,0 +1,277 @@
1
+ # Users — `janus()`
2
+
3
+ This page is for wiring `janus()` and managing users with it: the
4
+ configuration, one or several kinds of user, and every method a user type
5
+ answers. Sessions have [their own page](sessions.md), and so do the
6
+ [e-mail flows](email-flows.md) and [password hashing](passwords.md).
7
+
8
+ ```ts
9
+ import { z } from 'zod';
10
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
11
+
12
+ const User = z.object({ email: z.email(), name: z.string() });
13
+
14
+ export const auth = janus({
15
+ user: User,
16
+ password: { login: 'email' },
17
+ store: createMemoryStores(),
18
+ hasher: scryptHasher(),
19
+ });
20
+
21
+ const { user, session, token } = await auth.signUp({
22
+ email: 'ada@example.com',
23
+ name: 'Ada Lovelace',
24
+ password: 'correct horse',
25
+ });
26
+ user.email; // the schema's own field, at the top level
27
+ user.type; // 'user'
28
+ session.expiresAt; // Date
29
+ token; // handed back once: the store holds only its sha256
30
+ ```
31
+
32
+ `createMemoryStores()` is the reference store, for tests and for trying the
33
+ package. In production, pass an adapter's stores, such as
34
+ `createMongoStores(db)` from `@nxgt/janus-mongo`.
35
+
36
+ ```ts
37
+ function janus<const C extends JanusConfig>(config: C & Checked<C>): Janus<C>;
38
+ ```
39
+
40
+ `janus()` is **synchronous and does no I/O**. It checks that every store
41
+ answers every method of the port, and connects to nothing. A configuration it
42
+ cannot use is refused with a bare `TypeError` at that call, before any request
43
+ arrives.
44
+
45
+ ## What a user is
46
+
47
+ A user is **your schema's fields at the top level**, plus what `janus` sets:
48
+
49
+ ```ts
50
+ interface UserBase<Type extends string = string> {
51
+ readonly id: Id; // a UUIDv7 the core minted
52
+ readonly type: Type; // 'user', or the name you gave the type
53
+ readonly emailVerified: boolean;
54
+ readonly active: boolean;
55
+ readonly hasPassword: boolean; // the hash itself never reaches a user
56
+ readonly version: number; // one more on every write
57
+ readonly createdAt: Date;
58
+ readonly updatedAt: Date;
59
+ }
60
+ type User<Type extends string = string, Fields = object> = Readonly<Fields> & UserBase<Type>;
61
+ ```
62
+
63
+ A schema that declares one of those keys, or `password`, is refused at compile
64
+ time. A schema is any [Standard Schema](https://standardschema.dev) (Zod 4,
65
+ Valibot, ArkType), and its output must be JSON: a schema that produces a
66
+ `Date` is refused at compile time, because a `Date` round-trips through one
67
+ database and not the next. An optional field left `undefined` is dropped
68
+ before the store sees it.
69
+
70
+ ## Options
71
+
72
+ | Option | Type | Default | Effect |
73
+ | --- | --- | --- | --- |
74
+ | `user` | Standard Schema | — | One kind of user, named `'user'`. Exactly one of `user` and `users` |
75
+ | `users` | `{ [type]: UserTypeConfig }` | — | Several kinds of user. See [Several kinds of user](#several-kinds-of-user) |
76
+ | `password.login` | a field name | — | The field users sign in with: a **top-level, required string** field. A typo is a compile error |
77
+ | `password.normalize` | `'none' \| 'lowercase' \| 'lowercaseTrim' \| 'nfkcLowercaseTrim' \| (value) => string` | `'lowercaseTrim'` | Applied to the login before any store sees it, at sign-up and at sign-in alike |
78
+ | `password.minLength` | integer ≥ 1 | `8` | Below it: `PASSWORD_TOO_SHORT` |
79
+ | `email` | a field name | `'email'` | The field `verifyEmail` and `resetPassword` send to. Without one, those flows are absent from the type |
80
+ | `session.lifespan` | `Duration` | `'7d'` | How long a session lives |
81
+ | `session.renewAfter` | `Duration \| false` | `'1d'` | When `authenticate` slides the session. `false` for a fixed lifespan |
82
+ | `schemaVersion` | string | `'1'` | Recorded on every user written. Bump it when the schema tightens |
83
+ | `store` | `JanusStores` | — | Required. `createMemoryStores()` or an adapter's |
84
+ | `relations` | `RelationStore` | — | The permission store. Wired here, deleting a user deletes every tuple naming them |
85
+ | `hasher` | `PasswordHasher` | — | Required as soon as a type has a password. No silent fallback |
86
+ | `verifiers` | `PasswordHasher[]` | `[]` | Hashers that only verify: those older hashes were written with |
87
+ | `clock` | `Clock` | `systemClock` | `fixedClock()` in tests |
88
+ | `cookie` | `CookieConfig` | strict | See [sessions](sessions.md#the-cookie) |
89
+ | `tokens.verifyEmail` | `Duration` | `'24h'` | How long a verification token lives |
90
+ | `tokens.resetPassword` | `Duration` | `'1h'` | How long a reset token lives |
91
+
92
+ A `Duration` is `'500ms'`, `'30s'`, `'15m'`, `'8h'`, `'7d'`, or a number of
93
+ milliseconds.
94
+
95
+ ### `password.login` is checked at compile time
96
+
97
+ ```ts
98
+ janus({
99
+ user: z.object({ email: z.email(), nickname: z.string().optional() }),
100
+ // @ts-expect-error — on login: '"emial" is not a required string field; name one of' 'email'
101
+ password: { login: 'emial' },
102
+ store: createMemoryStores(),
103
+ hasher: scryptHasher(),
104
+ });
105
+ ```
106
+
107
+ `nickname` is not offered either: a login read from an optional field is a user
108
+ who may have no way to sign in.
109
+
110
+ ### `password.normalize`
111
+
112
+ ```ts
113
+ janus({
114
+ user: z.object({ username: z.string() }),
115
+ password: { login: 'username', normalize: 'none' }, // 'Grace' and 'grace' are two users
116
+ store: createMemoryStores(),
117
+ hasher: scryptHasher(),
118
+ });
119
+ ```
120
+
121
+ A function is accepted, and must be deterministic: the same rule normalises
122
+ at sign-up and at sign-in. An e-mail used by the e-mail flows is always
123
+ compared lower-cased and trimmed, whatever the login's rule.
124
+
125
+ ## Several kinds of user
126
+
127
+ ```ts
128
+ const Patient = z.object({ email: z.email(), birthDate: z.string() });
129
+ const Staff = z.object({ username: z.string(), service: z.string() });
130
+
131
+ export const clinic = janus({
132
+ users: {
133
+ patient: { schema: Patient, password: { login: 'email' } },
134
+ staff: {
135
+ schema: Staff,
136
+ password: { login: 'username', normalize: 'none', minLength: 12 },
137
+ session: { lifespan: '8h', renewAfter: false },
138
+ },
139
+ },
140
+ store: createMemoryStores(),
141
+ hasher: scryptHasher(),
142
+ });
143
+
144
+ await clinic.patient.signUp({ email: 'p@example.com', birthDate: '1990-01-01', password: 'correct horse' });
145
+ await clinic.staff.signIn({ username: 'grace', password: 'a long passphrase' });
146
+ clinic.types; // readonly ('patient' | 'staff')[]
147
+
148
+ const current = await clinic.authenticate(request);
149
+ if (current?.user.type === 'staff') current.user.service; // narrowed by type
150
+ ```
151
+
152
+ Each type carries `schema` and, optionally, `password`, `email`, `session`
153
+ and `schemaVersion`, with the defaults above. A login is unique **per type**:
154
+ the same e-mail may hold a patient account and a staff account. A type name is
155
+ camelCase, and may not be one of `janus()`'s own methods (`authenticate`,
156
+ `signOut`, `signOutEverywhere`, `findUser`, `getUser`, `cookie`,
157
+ `collectExpired`, `types`) — a compile error, and a `TypeError` for a
158
+ JavaScript caller.
159
+
160
+ ## What each user type answers
161
+
162
+ In the one-type form these are on `auth` itself; with several types, on
163
+ `clinic.patient`, `clinic.staff`.
164
+
165
+ | Method | Answers | Rejects with |
166
+ | --- | --- | --- |
167
+ | `create(fields & { password?, active? })` | the user; no session | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
168
+ | `find(id)` | the user, or `null` — for a malformed id, an unknown one, or one of another type | |
169
+ | `get(id)` | the user | `NOT_FOUND` |
170
+ | `list({ after?, limit? })` | `CursorPage<User>`, in creation order | `INVALID_CURSOR` |
171
+ | `update(user, patch, { ifVersion? })` | the user as written | `USER_INVALID`, `LOGIN_TAKEN`, `VERSION_CONFLICT`, `NOT_FOUND` |
172
+ | `setActive(user, active, { ifVersion? })` | the user as written | `VERSION_CONFLICT`, `NOT_FOUND` |
173
+ | `delete(user)` | `true`, or `false` for an unknown id or one of another type | |
174
+
175
+ With a `password`, besides:
176
+
177
+ | Method | Answers | Rejects with |
178
+ | --- | --- | --- |
179
+ | `signUp(fields & { password })` | `{ user, session, token }` | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
180
+ | `signIn({ [login]: string, password })` | `{ user, session, token }` | `CREDENTIALS_INVALID`, `USER_INACTIVE`, `HASH_UNSUPPORTED` |
181
+ | `findByLogin(login)` | the user, or `null`; the login is normalised first | |
182
+ | `setPassword(user, password, { ifVersion? })` | the user — an admin's call | `PASSWORD_TOO_SHORT` |
183
+ | `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
184
+
185
+ Every method may also reject with `STORE_FAILED`. A `user` argument is a user
186
+ or its id (`UserRef = string | { id: string }`).
187
+
188
+ ```ts
189
+ const grace = await auth.create({ email: 'grace@example.com', name: 'Grace', active: false });
190
+ await auth.find(grace.id); // User | null
191
+ await auth.get(grace.id); // User, or NOT_FOUND
192
+ const renamed = await auth.update(grace, { name: 'Grace Hopper' }, { ifVersion: grace.version });
193
+ await auth.setActive(renamed, true);
194
+ await auth.findByLogin(' GRACE@example.com '); // found: normalised as sign-up normalised it
195
+ await auth.setPassword(renamed, 'a new password');
196
+ await auth.changePassword(renamed, { current: 'a new password', next: 'another password' });
197
+ await auth.delete(renamed); // true
198
+ ```
199
+
200
+ ### `update` merges, then validates the whole
201
+
202
+ The patch is spread over the stored fields and the result is checked against
203
+ the schema, so a patch can never leave a user the schema would refuse.
204
+ Changing the e-mail sets `emailVerified` back to `false`, and moves the login
205
+ with it when the e-mail is the login.
206
+
207
+ ### `ifVersion`
208
+
209
+ Every write but `delete` takes `{ ifVersion }`. A user who changed since you
210
+ read them is `VERSION_CONFLICT`, and nothing is written: read again and retry.
211
+ A sign-in that rewrites a stale password hash moves `version` too, so an object
212
+ read before that sign-in conflicts — see [passwords](passwords.md#rehash-on-sign-in).
213
+
214
+ ### `delete`
215
+
216
+ Deletes the user **with every session and one-time token they had**, and
217
+ every tuple naming them when `relations` is wired. The user goes first, so an
218
+ outage half-way leaves only sessions and tokens that authenticate nobody. It is
219
+ idempotent: calling it again finishes the job.
220
+
221
+ ### Paging every user
222
+
223
+ ```ts
224
+ let cursor: string | null = null;
225
+ do {
226
+ const page = await auth.list({ after: cursor, limit: 100 });
227
+ for (const user of page.items) console.log(user.email);
228
+ cursor = page.nextCursor;
229
+ } while (cursor);
230
+ ```
231
+
232
+ `limit` defaults to 20 and is capped at 100. There is no `total`.
233
+
234
+ ### Across types
235
+
236
+ `findUser(id)` and `getUser(id)` are on the instance itself and find a user
237
+ whatever their type; the answer is a union narrowed by `user.type`.
238
+
239
+ ## A sign-up route
240
+
241
+ A fetch-style handler — the shape Bun, Hono and most frameworks hand you:
242
+
243
+ ```ts
244
+ import { JanusError } from '@nxgt/janus';
245
+
246
+ export async function signUpRoute(request: Request): Promise<Response> {
247
+ const body = (await request.json()) as { email: string; name: string; password: string };
248
+ try {
249
+ const { user, session, token } = await auth.signUp(body);
250
+ return Response.json(
251
+ { id: user.id },
252
+ { status: 201, headers: { 'Set-Cookie': auth.cookie.serialize(token, session) } },
253
+ );
254
+ } catch (error) {
255
+ if (!(error instanceof JanusError)) throw error;
256
+ switch (error.code) {
257
+ case 'USER_INVALID':
258
+ return Response.json({ issues: error.issues }, { status: 400 });
259
+ case 'PASSWORD_TOO_SHORT':
260
+ return Response.json({ minLength: error.minLength }, { status: 400 });
261
+ case 'LOGIN_TAKEN':
262
+ return Response.json({ error: 'taken' }, { status: 409 });
263
+ default:
264
+ throw error; // STORE_FAILED: your 503
265
+ }
266
+ }
267
+ }
268
+ ```
269
+
270
+ Validate the body's shape yourself in a real route; `janus` validates the
271
+ fields against your schema, not that `password` is a string.
272
+
273
+ ## See also
274
+
275
+ - [Sessions](sessions.md) — `authenticate`, the cookie, signing out
276
+ - [Errors](errors.md) — every code, and the status it deserves
277
+ - [Writing an adapter](adapters.md) — the store port behind `store`
@@ -0,0 +1,163 @@
1
+ # The shared vocabulary
2
+
3
+ This page is for the small pieces `@nxgt/janus` exports beside `janus()` and
4
+ the permissions, because both use them: subjects and the tuple notation, ids,
5
+ pagination, durations and clocks. All of it is imported from `@nxgt/janus`.
6
+
7
+ ```ts
8
+ import { formatTuple, mintId, parseDuration, subjectOf } from '@nxgt/janus';
9
+
10
+ const user = { type: 'staff', id: mintId(), username: 'grace' };
11
+
12
+ subjectOf(user); // { type: 'staff', id: '…' }: the user IS the subject
13
+ formatTuple({
14
+ object: { type: 'record', id: 'r1' },
15
+ relation: 'viewer',
16
+ subject: { type: 'team', id: 't1', relation: 'member' },
17
+ }); // 'record:r1#viewer@team:t1#member'
18
+ parseDuration('8h', 'session.lifespan'); // 28800000
19
+ ```
20
+
21
+ ## Subjects and the tuple notation
22
+
23
+ ```ts
24
+ interface Entity { readonly type: string; readonly id: string }
25
+ interface SubjectSet extends Entity { readonly relation: string } // every `relation` of an entity
26
+ type Subject = Entity | SubjectSet;
27
+ interface RelationTuple { readonly object: Entity; readonly relation: string; readonly subject: Subject }
28
+ ```
29
+
30
+ **Subjects are typed**: `{ type, id }` for one entity, `{ type, id, relation }`
31
+ for a subject set — every `member` of `team:t1`. `type` is the same word as a
32
+ user's own, so a user id and a subject id are the same thing, and
33
+ `subjectOf(user)` is the one-line join between the two halves of the package.
34
+ It copies `type` and `id` only, so none of the user's own fields ever reaches
35
+ a tuple.
36
+
37
+ | Function | Answers |
38
+ | --- | --- |
39
+ | `subjectOf(user)` | `{ type, id }` of any object carrying both |
40
+ | `isSubjectSet(subject)` | whether `relation` is a string |
41
+ | `formatEntity(entity)` | `'record:r1'` |
42
+ | `formatSubject(subject)` | `'staff:u1'`, or `'team:t1#member'` |
43
+ | `formatTuple(tuple)` | `'record:r1#viewer@team:t1#member'` |
44
+ | `parseSubject(text)` | the `Subject` back |
45
+ | `parseTuple(text)` | the `RelationTuple` back |
46
+
47
+ ```ts
48
+ import { isSubjectSet, parseSubject, parseTuple } from '@nxgt/janus';
49
+
50
+ parseTuple('record:r1#viewer@staff:u1');
51
+ // { object: { type: 'record', id: 'r1' }, relation: 'viewer', subject: { type: 'staff', id: 'u1' } }
52
+
53
+ const subject = parseSubject('team:t1#member');
54
+ if (isSubjectSet(subject)) subject.relation; // 'member'
55
+ ```
56
+
57
+ The notation is for messages, logs and tests, not a wire format. No part may
58
+ hold `@`, `#` or a parenthesis, and a type may not hold a `:`, so every string
59
+ reads one way. `parseTuple` and `parseSubject` refuse anything else with a
60
+ **bare `TypeError`** — including Keto's untyped subject, `record:r1#viewer@alice`,
61
+ and the message says what a subject is. Nothing in this package reads a tuple
62
+ off the network, so a malformed string came from your own code.
63
+
64
+ ## Ids
65
+
66
+ ```ts
67
+ import { type Id, isId, mintedAt, mintId } from '@nxgt/janus';
68
+
69
+ const id: Id = mintId(); // a UUIDv7
70
+ isId(id); // true
71
+ isId('42'); // false: not an id this package could have minted
72
+ mintedAt(id); // the Date it was minted, to the millisecond
73
+ ```
74
+
75
+ Ids are **minted by the core, not by the store**. They sort in creation order
76
+ as strings, so a pagination cursor is simply the last id of a page. Ids are
77
+ strictly increasing within a process: inside one millisecond a sequence orders
78
+ them, and past 4096 in a millisecond the next millisecond is borrowed.
79
+
80
+ - **`mintedAt` is not `createdAt`.** A borrowed millisecond, or a clock that
81
+ stepped backwards and was held rather than followed, makes it accurate to
82
+ the millisecond and no further.
83
+ - **`mintId(now)` steers ids forward, never back.** Passing a `now` earlier
84
+ than an id already minted in this process holds the last one and keeps
85
+ counting, because a decreasing id would break the cursor. A test that needs
86
+ a fixed instant wants `fixedClock`, not this argument.
87
+
88
+ The price for an adapter: it cannot reuse an existing numeric primary key. It
89
+ stores a `uuid` column, or a 36-character string.
90
+
91
+ ## Pagination
92
+
93
+ ```ts
94
+ import { type CursorPage, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, invalidCursor, pageLimit } from '@nxgt/janus';
95
+
96
+ interface CursorPage<T> {
97
+ readonly items: readonly T[];
98
+ readonly nextCursor: string | null; // null on the last page, never undefined
99
+ }
100
+
101
+ pageLimit(undefined, 'listThings'); // 20 — DEFAULT_PAGE_SIZE
102
+ pageLimit(500, 'listThings'); // 100 — MAX_PAGE_SIZE, the cap
103
+ pageLimit(0, 'listThings'); // TypeError: listThings: limit must be an integer of at least 1, or absent
104
+ ```
105
+
106
+ There is **no `total`**: a count over a cursor-paged collection is a second
107
+ query whose answer is stale by the time you read it. `while (cursor)` is the
108
+ loop:
109
+
110
+ ```ts
111
+ let cursor: string | null = null;
112
+ do {
113
+ const page = await auth.list({ after: cursor, limit: 100 });
114
+ cursor = page.nextCursor;
115
+ } while (cursor);
116
+ ```
117
+
118
+ `invalidCursor(where, cursor)` builds the `INVALID_CURSOR` error an adapter
119
+ throws for a cursor it did not mint — never a silent first page, which would
120
+ make a caller paging a list loop for ever. The message holds the cursor's
121
+ length, not its bytes.
122
+
123
+ ## Durations
124
+
125
+ ```ts
126
+ type Duration = number | `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`;
127
+ ```
128
+
129
+ ```ts
130
+ import { parseDuration } from '@nxgt/janus';
131
+
132
+ parseDuration('15m', 'tokens.resetPassword'); // 900000
133
+ parseDuration(1500, 'session.renewAfter'); // 1500 — a number is milliseconds
134
+ ```
135
+
136
+ Every duration option of `janus()` takes this. `'2w'` is a compile error; a
137
+ value the type cannot see through — from an environment variable, say — is a
138
+ `TypeError` at run time, and the second argument names the option in its
139
+ message. One gap is known and written down: `'30 m'`
140
+ satisfies the type — TypeScript's `${number}` tolerates the space — and
141
+ `parseDuration` refuses it at run time.
142
+
143
+ ## Clocks
144
+
145
+ ```ts
146
+ import { type Clock, fixedClock, systemClock } from '@nxgt/janus';
147
+
148
+ interface Clock { now(): Date }
149
+
150
+ systemClock.now(); // the default
151
+ const clock = fixedClock(Date.UTC(2026, 0, 1)); // a Date or milliseconds; 0 when absent
152
+ clock.advance(60_000);
153
+ clock.set(new Date('2026-02-01T00:00:00Z'));
154
+ ```
155
+
156
+ `fixedClock` is **shipped, not test-only**: testing session expiry needs it,
157
+ and so do your own tests. Pass it as `janus({ clock })` — see
158
+ [sessions](sessions.md#testing-expiry).
159
+
160
+ ## See also
161
+
162
+ - [Permissions](permissions.md) — where subjects and tuples are used
163
+ - [Errors](errors.md) — the error classes, also exported from `@nxgt/janus`
@@ -0,0 +1,93 @@
1
+ # Roadmap
2
+
3
+ Where `@nxgt/janus` is heading. A direction, not a commitment: there are no
4
+ dates here, and the version something shipped in is the only number.
5
+
6
+ ## Now
7
+
8
+ Nothing in progress.
9
+
10
+ ## Next
11
+
12
+ Nothing yet.
13
+
14
+ ## Later
15
+
16
+ - **More official adapters** — the store port is cut where atomicity is not
17
+ required, so users, sessions and permission tuples can each live in the
18
+ database that suits them. MongoDB is the first adapter
19
+ ([`@nxgt/janus-mongo`](https://www.npmjs.com/package/@nxgt/janus-mongo)).
20
+ - **A Redis adapter for sessions and tokens** — both are ephemeral and read on
21
+ every request, which is why the port gives them slots of their own: they can
22
+ live in Redis, with a native expiry, while users stay in another store.
23
+
24
+ ## Not planned
25
+
26
+ - **`moduleResolution: "nodenext"`** — the package, its sources and its
27
+ emitted declarations import without extensions, and resolve as Bun and every
28
+ bundler do. Rewriting the declarations for `nodenext` was tried and reverted:
29
+ it breaks the same contract one step later. Use `"moduleResolution":
30
+ "bundler"`.
31
+ - **A Kratos-shaped surface** — no `identity.traits`, no identifier derived
32
+ from a schema annotation. A user is your schema's fields at the top level,
33
+ and the flows are calls (`signUp`, `signIn`, `authenticate`).
34
+ - **`snake_case` keys** — every key, option and record field is `camelCase`,
35
+ and a lint rule holds it. Error codes are `SCREAMING_SNAKE` because they are
36
+ values, not keys.
37
+ - **Emitting a Kratos identity schema** — it would bring Ory's `snake_case`
38
+ vocabulary into this package. If it ever exists, it is a separate package
39
+ whose job is to speak that format.
40
+ - **A `total` on `CursorPage`** — a count over a cursor-paged collection is a
41
+ second query, stale by the time you read it. `nextCursor` is the loop.
42
+ - **Store-assigned or numeric ids** — ids are UUIDv7 minted by the core, so
43
+ they sort in creation order, the cursor is the last id, and an insert is
44
+ idempotent under retry. An adapter cannot reuse an existing numeric key.
45
+ - **A required validation library** — schemas are any Standard Schema (Zod 4,
46
+ Valibot, ArkType); none is imposed as a peer.
47
+ - **A silent hasher fallback** — a user type with a password and no `hasher`
48
+ is refused at wiring, rather than hashed with something you did not choose.
49
+ - **Answering `null` or `false` on an outage** — a store that cannot answer
50
+ throws `STORE_FAILED`, and a permission walk past `maxDepth` throws
51
+ `PERMISSION_DEPTH`. Neither will become a denial: that turns an outage into
52
+ a silent lockout.
53
+ - **Zanzibar's infrastructure** — no consistency tokens, no distributed
54
+ cache. The tuples live in your own database, so a read already follows a
55
+ write.
56
+ - **Deciding between 404 and 403** — `can()` answers one question; what a
57
+ route reveals about an object it refuses is the application's decision.
58
+
59
+ ## Shipped
60
+
61
+ The first public release, v0.1.
62
+
63
+ - **The model decides what a stored tuple grants** — `can()` and `list()` follow
64
+ only the holders a relation admits, as `grant()` writes only those: a tuple
65
+ stored past `grant()`, by an older model or by hand, grants nothing. — v0.1
66
+ - **Guides and troubleshooting pages** — a `docs/` folder shipped in the
67
+ package: detailed guides with examples, and the errors you can meet, each
68
+ with its cause and fix. — v0.1
69
+ - **Permissions at `@nxgt/janus/permissions`** — and `janus({ relations })`,
70
+ so deleting a user also deletes every tuple naming them. — v0.1
71
+ - **`list()`** — the ids of every object a subject holds a permission on, as a
72
+ cursor page, `fromField` relations included through their `lookup`. — v0.1
73
+ - **`can()`, `grant()` and `revoke()`** — a permission check that answers
74
+ `true` or `false` and throws on an outage, and tuple writes refused at
75
+ compile time when the model does not admit them. — v0.1
76
+ - **Typed subjects and the `RelationStore` port** — `{ type, id }` subjects,
77
+ the tuple notation, `createMemoryRelations()`, and
78
+ `describeRelationStores` for adapter authors. — v0.1
79
+ - **A permission model typed from itself** — `defineModel` with subject sets,
80
+ arrows, `fromField` relations read from your data, and `when` conditions
81
+ written in TypeScript. — v0.1
82
+ - **Delete a user, and everything of theirs** — `delete(user)` removes the
83
+ user with every session and one-time token they had, idempotently. — v0.1
84
+ - **Rehash a stale password on sign-in** — moving hashers, or raising a cost,
85
+ reaches every active user with no migration to run. — v0.1
86
+ - **`janus()`** — sign-up, sign-in, sessions, e-mail verification and password
87
+ reset, with several user types in one instance, typed from your schemas.
88
+ — v0.1
89
+ - **The conformance suite** — `@nxgt/janus/conformance`, the suite an adapter
90
+ runs, outages included. — v0.1
91
+ - **The store port and its in-memory reference** — `JanusStores` and
92
+ `createMemoryStores()`, for your tests and as the model for an adapter.
93
+ — v0.1