@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,296 @@
1
+ # Writing an adapter — the store port and `@nxgt/janus/conformance`
2
+
3
+ This page is for putting `janus()` or `permissions()` on a database of your
4
+ choice: the two ports you implement, the rules they carry, and the conformance
5
+ suites that check an implementation keeps them. If you only use an existing
6
+ adapter, such as `@nxgt/janus-mongo`, you do not need it.
7
+
8
+ ```ts
9
+ import { describe, it } from 'bun:test';
10
+ import { describeJanusStores } from '@nxgt/janus/conformance';
11
+
12
+ // Yours: myStores(db) builds your adapter's stores, freshDatabase() an empty
13
+ // database with a way to make one command fail.
14
+ describeJanusStores({
15
+ name: 'my adapter',
16
+ runner: { describe, it },
17
+ harness: {
18
+ async open() {
19
+ const db = await freshDatabase(); // one per case, never shared
20
+ return {
21
+ stores: myStores(db),
22
+ faults: { fail: (slot, method) => db.failNext(method) },
23
+ close: () => db.drop(),
24
+ };
25
+ },
26
+ },
27
+ });
28
+ ```
29
+
30
+ ## The two ports
31
+
32
+ | Port | Taken by | Methods |
33
+ | --- | --- | --- |
34
+ | `JanusStores` — `{ users: UserStore, sessions: SessionStore, tokens: TokenStore }` | `janus({ store })` | 6 + 6 (+ 1 optional) + 3 |
35
+ | `RelationStore` | `permissions({ store })`, `janus({ relations })` | 6 |
36
+
37
+ They are separate on purpose: an application that only authenticates
38
+ implements nothing for permissions, and each of the three user slots may come
39
+ from a different adapter — users in one database, sessions and tokens in
40
+ another:
41
+
42
+ ```ts
43
+ janus({
44
+ user: User,
45
+ password: { login: 'email' },
46
+ store: { users: mongo.users, sessions: other.sessions, tokens: other.tokens },
47
+ hasher: scryptHasher(),
48
+ });
49
+ ```
50
+
51
+ The seam is where atomicity is not required: a user and their password are one
52
+ record, a session is derived state, a token is ephemeral.
53
+
54
+ ### `UserStore`
55
+
56
+ ```ts
57
+ interface UserStore {
58
+ insertUser(record: UserRecord): Promise<UserRecord>;
59
+ findUser(id: Id): Promise<UserRecord | null>;
60
+ findUserByLogin(type: string, login: string): Promise<UserRecord | null>;
61
+ listUsers(page: UserPageRequest): Promise<CursorPage<UserRecord>>;
62
+ updateUser(id: Id, patch: UserPatch, ifVersion: number): Promise<UserRecord>;
63
+ deleteUser(id: Id): Promise<boolean>;
64
+ }
65
+ ```
66
+
67
+ - `insertUser` is **idempotent under retry**: a user with this `id` already
68
+ stored is answered as stored. A login held by another user of the same type
69
+ rejects with `StoreConflict('login', …)`, from the database's own unique
70
+ constraint.
71
+ - `updateUser` writes **only if the stored version is exactly `ifVersion`**,
72
+ and never replaces a record whole: a field the patch does not name is left
73
+ as it is. It rejects with `NotFoundError` for an unknown id — the one
74
+ absence on the port that throws, because an update always follows a read —
75
+ and `StoreConflict('version', …)` when the version moved.
76
+ - `listUsers` pages in ascending id order; `after` is the last id of the
77
+ previous page, already checked by the core.
78
+
79
+ ### `SessionStore` and `TokenStore`
80
+
81
+ ```ts
82
+ interface SessionStore {
83
+ insertSession(record: SessionRecord): Promise<void>;
84
+ findSessionByTokenHash(tokenHash: string): Promise<SessionRecord | null>;
85
+ extendSession(id: SessionId, expiresAt: Date): Promise<SessionRecord | null>; // null once revoked
86
+ revokeSession(id: SessionId, at: Date): Promise<boolean>;
87
+ revokeUserSessions(userId: Id, at: Date, except?: SessionId): Promise<number>;
88
+ deleteUserSessions(userId: Id): Promise<number>;
89
+ deleteExpiredSessions?(before: Date): Promise<number>; // optional: omit it if the database expires on its own
90
+ }
91
+
92
+ interface TokenStore {
93
+ insertToken(record: TokenRecord): Promise<void>;
94
+ consumeToken(tokenHash: string, kind: TokenKind, at: Date): Promise<TokenRecord | null>;
95
+ deleteUserTokens(userId: Id): Promise<number>;
96
+ }
97
+ ```
98
+
99
+ `consumeToken` is the most important method on the port: it spends the token
100
+ and answers it **as it was before the call**, in **one conditional write**.
101
+ Twenty concurrent calls must produce exactly one answer with `spentAt: null`;
102
+ in MongoDB that is one `findOneAndUpdate` returning the document before the
103
+ update. A read followed by a write lets two requests redeem one reset code.
104
+
105
+ Expiry is the core's decision: a read answers a stored session verbatim,
106
+ lapsed or revoked, and never a record it has changed.
107
+
108
+ ### `RelationStore`
109
+
110
+ ```ts
111
+ interface RelationStore {
112
+ write(changes: { add?: readonly RelationTuple[]; remove?: readonly RelationTuple[] }): Promise<void>;
113
+ has(tuple: RelationTuple): Promise<boolean>;
114
+ findSubjectSets(object: Entity, relation: string): Promise<readonly SubjectSet[]>;
115
+ findEntities(object: Entity, relation: string): Promise<readonly Entity[]>;
116
+ findObjects(page: ObjectPageRequest): Promise<CursorPage<string>>;
117
+ deleteEntity(entity: Entity): Promise<number>;
118
+ }
119
+ ```
120
+
121
+ One-hop questions about stored tuples, never a permission: the traversal is
122
+ the core's. `write` is **all or nothing**, removals first, and idempotent.
123
+ `findObjects` is the reverse index `list()` walks, in ascending id order.
124
+ `deleteEntity` removes every tuple naming the entity as object, as subject,
125
+ and as the entity of a subject set.
126
+
127
+ ## The six rules
128
+
129
+ Written on the port's types, and checked by the suites:
130
+
131
+ 1. **An absence is `null`. A failure throws.** A method that can find nothing
132
+ answers `null`, `false`, `0` or an empty page; everything else throws,
133
+ preferably `StoreFailure` with the driver's error as `cause`. Never write
134
+ `try { … } catch { return null }` in an implementation.
135
+ 2. **`null`, not `undefined`.** A function that forgot to `return` produces
136
+ `undefined`; `null` has to be written on purpose.
137
+ 3. **Uniqueness is a constraint** — a unique index, never a read followed by a
138
+ write.
139
+ 4. **Bytes round-trip.** No normalising, trimming or retyping. The core
140
+ normalises logins before a store sees them.
141
+ 5. **Every method is atomic on its own.** The core opens no transaction; an
142
+ adapter may open one inside a method.
143
+ 6. **Schema management is not on the port.** Expose your own `sync`; the core
144
+ never calls it.
145
+
146
+ ```ts
147
+ import { type Id, StoreFailure, type UserRecord, type UserStore } from '@nxgt/janus';
148
+
149
+ export const findUser: UserStore['findUser'] = async (id: Id) => {
150
+ let found: UserRecord | undefined;
151
+ try {
152
+ found = await db.users.findOne({ _id: id });
153
+ } catch (cause) {
154
+ throw new StoreFailure('users.findUser: the store could not answer', {
155
+ slot: 'users',
156
+ operation: 'findUser',
157
+ cause,
158
+ });
159
+ }
160
+ return found ?? null; // an absence, written on purpose
161
+ };
162
+ ```
163
+
164
+ An adapter **defines no error class**. It throws `@nxgt/janus`'s own
165
+ `StoreFailure`, `StoreConflict` and `NotFoundError`, and declares
166
+ `@nxgt/janus` as a **peer dependency**, never a dependency, so there is one
167
+ copy of each class and `instanceof` holds in the application. A cursor it
168
+ cannot read is `invalidCursor(where, cursor)`. Records, patches and page
169
+ requests are exported as types: `UserRecord`, `UserPatch`, `UserPageRequest`,
170
+ `PasswordRecord`, `SessionRecord`, `TokenRecord`, `TokenKind`, `Json`,
171
+ `JsonObject`, and `ObjectPageRequest`, `RelationChanges` from
172
+ `@nxgt/janus/permissions`.
173
+
174
+ `createMemoryStores()` and `createMemoryRelations()` are the reference
175
+ implementations: read them when a rule is unclear. `janus()` runs
176
+ `assertStores` on what it is given, and a partially implemented store is a
177
+ compile error naming the missing method.
178
+
179
+ ## The conformance suites
180
+
181
+ | Suite | Cases | Harness opens |
182
+ | --- | --- | --- |
183
+ | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 37: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" | `{ stores, faults?, close? }` |
184
+ | `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 15: the relation store, and one outage per method | `{ store, faults?, close? }` |
185
+
186
+ `harness.open()` is called **once per case** and must answer fresh, empty
187
+ stores: a case that leaks into the next is the hardest failure to debug.
188
+ `close()` runs after the case, pass or fail.
189
+
190
+ | Option | Type | Default | Effect |
191
+ | --- | --- | --- | --- |
192
+ | `name` | string | — | The suite's title |
193
+ | `harness` | `ConformanceHarness` / `RelationHarness` | — | Opens fresh stores per case |
194
+ | `runner` | `{ describe, it }` | `globalThis` | **Required under `bun test`**: Bun does not put `describe` and `it` on `globalThis`. jest, and vitest with `globals: true`, are found without it |
195
+ | `faults` | boolean | — | Declare `false` up front and the report says so in the suite's title |
196
+ | `skip` | `{ [caseId]: reason }` | `{}` | Skips a case, reported with the reason — never silent |
197
+
198
+ The suites import no test framework and no assertion library.
199
+
200
+ ### `faults`: prove the outage invariant
201
+
202
+ `faults` is optional, and **its absence is reported, never passed over**:
203
+ without it the outage cases are skipped with the reason *"faults not provided:
204
+ the outage invariant is not proven for this adapter"*.
205
+
206
+ ```ts
207
+ import type { StoreFaults } from '@nxgt/janus/conformance';
208
+
209
+ const faults: StoreFaults = {
210
+ async fail(slot, method) {
211
+ await database.failNext(method); // make the DATABASE fail this call
212
+ },
213
+ };
214
+ ```
215
+
216
+ Make the database fail the way it really fails — for MongoDB, the
217
+ `failCommand` fail point with code 91 (`ShutdownInProgress`). A wrapper that
218
+ throws in front of your adapter proves the wrapper, not the adapter's
219
+ translation of a driver error. Fail **only the method named**: the write
220
+ outage cases read the store back afterwards, to prove a rejected write changed
221
+ nothing.
222
+
223
+ The relation suite's `faults` is `{ fail(method) }`, with no slot:
224
+
225
+ ```ts
226
+ import { describeRelationStores } from '@nxgt/janus/conformance';
227
+
228
+ describeRelationStores({
229
+ name: 'my adapter',
230
+ runner: { describe, it },
231
+ harness: {
232
+ async open() {
233
+ const database = await freshDatabase();
234
+ return {
235
+ store: myRelations(database),
236
+ faults: { fail: (method) => database.failNext(method) },
237
+ close: () => database.drop(),
238
+ };
239
+ },
240
+ },
241
+ });
242
+ ```
243
+
244
+ ### Skipping a case, and declaring no faults
245
+
246
+ ```ts
247
+ describeJanusStores({
248
+ name: 'my adapter',
249
+ runner: { describe, it },
250
+ faults: false,
251
+ skip: { 'users.omission': 'not yet: tracked in the issue tracker' },
252
+ harness: {
253
+ async open() {
254
+ const database = await freshDatabase();
255
+ return { stores: myStores(database), close: () => database.drop() };
256
+ },
257
+ },
258
+ });
259
+ ```
260
+
261
+ A skipped case still appears in the run, with its reason in its name. A case
262
+ that cannot run on the stores it was given — an outage case with no `faults`,
263
+ `collectExpired` on a store without `deleteExpiredSessions` — passes, and
264
+ emits a `JANUS_CONFORMANCE_SKIPPED` warning with the reason.
265
+
266
+ ### Without a test runner
267
+
268
+ The cases are data, and `runCase` runs one against a harness:
269
+
270
+ ```ts
271
+ import { allCases, referenceHarness, runCase } from '@nxgt/janus/conformance';
272
+
273
+ for (const conformanceCase of allCases) {
274
+ const outcome = await runCase(conformanceCase, referenceHarness());
275
+ console.log(conformanceCase.id, 'skipped' in outcome ? `skipped: ${outcome.skipped}` : 'passed');
276
+ }
277
+ ```
278
+
279
+ | Export | What it is |
280
+ | --- | --- |
281
+ | `allCases`, `userStoreCases`, `sessionStoreCases`, `tokenStoreCases`, `outageCases` | The user-port cases, as `ConformanceCase` objects with a stable `id` |
282
+ | `runCase(case, harness)` | Runs one; throws on failure, answers `{ passed: true }` or `{ skipped }` |
283
+ | `SKIP_REASONS` | The reasons the suite gives itself |
284
+ | `allRelationCases`, `relationStoreCases`, `relationOutageCases`, `runRelationCase` | The same for the relation port |
285
+ | `referenceHarness()`, `referenceRelationHarness()` | The suites against the reference stores: the examples to copy |
286
+
287
+ Types: `ConformanceHarness`, `OpenedStores`, `StoreFaults`, `ConformanceCase`,
288
+ `CaseContext`, `ConformanceRunner`, `PortMethod`, and `RelationHarness`,
289
+ `OpenedRelations`, `RelationFaults`, `RelationCase`, `RelationContext`,
290
+ `RelationMethod`.
291
+
292
+ ## See also
293
+
294
+ - [Errors](errors.md) — `StoreFailure`, `StoreConflict` and the rule behind them
295
+ - [Vocabulary](vocabulary.md) — ids, cursors and `invalidCursor`
296
+ - `@nxgt/janus-mongo` — an adapter that passes both suites against a real mongod, outages included
@@ -0,0 +1,146 @@
1
+ # E-mail verification and password reset
2
+
3
+ This page is for the two one-time-token flows: proving a user holds their
4
+ e-mail, and resetting a forgotten password. `janus` issues and redeems the
5
+ tokens; **sending the e-mail is yours**.
6
+
7
+ ```ts
8
+ import { z } from 'zod';
9
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
10
+
11
+ async function sendMail(to: string, link: string): Promise<void> {
12
+ // your mailer
13
+ }
14
+
15
+ const auth = janus({
16
+ user: z.object({ email: z.email(), name: z.string() }),
17
+ password: { login: 'email' },
18
+ store: createMemoryStores(),
19
+ hasher: scryptHasher(),
20
+ });
21
+
22
+ const { user } = await auth.signUp({ email: 'ada@example.com', name: 'Ada', password: 'correct horse' });
23
+
24
+ const sent = await auth.verifyEmail.send(user); // { token, email, expiresAt }
25
+ await sendMail(sent.email, `https://app.example/verify?token=${sent.token}`);
26
+
27
+ // when the link is followed: the token comes back from the query string
28
+ const verified = await auth.verifyEmail.confirm(sent.token);
29
+ verified.emailVerified; // true
30
+ ```
31
+
32
+ ## Which types have these flows
33
+
34
+ `verifyEmail` exists on a type with an e-mail field: the one `email` names, or
35
+ a required string field called `email`. `resetPassword` needs an e-mail **and**
36
+ a password. On a type without them the flows are **absent from its type**, not
37
+ failing at run time:
38
+
39
+ ```ts
40
+ const clinic = janus({
41
+ users: {
42
+ staff: { schema: z.object({ username: z.string() }), password: { login: 'username' } },
43
+ patient: { schema: z.object({ contact: z.email() }), password: { login: 'contact' }, email: 'contact' },
44
+ },
45
+ store: createMemoryStores(),
46
+ hasher: scryptHasher(),
47
+ });
48
+
49
+ clinic.patient.verifyEmail.send; // exists: `email` names the field
50
+ // @ts-expect-error — staff has no e-mail, so no verifyEmail
51
+ clinic.staff.verifyEmail;
52
+ ```
53
+
54
+ ## Options
55
+
56
+ | Option | Type | Default | Effect |
57
+ | --- | --- | --- | --- |
58
+ | `email` | a field name | `'email'` | The field the tokens are sent to, per type |
59
+ | `tokens.verifyEmail` | `Duration` | `'24h'` | How long a verification token lives |
60
+ | `tokens.resetPassword` | `Duration` | `'1h'` | How long a reset token lives |
61
+
62
+ ## `verifyEmail`
63
+
64
+ ```ts
65
+ readonly verifyEmail: {
66
+ send(user: UserRef): Promise<IssuedToken>; // { token, email, expiresAt }
67
+ confirm(token: string): Promise<User>;
68
+ };
69
+ ```
70
+
71
+ `send` issues a token for the user's **current** e-mail. `confirm` redeems it
72
+ and sets `emailVerified`. A token sent to an e-mail the user has since changed
73
+ is `TOKEN_STALE`: confirming it would verify an address nobody holds any more.
74
+ Changing the e-mail with `update` sets `emailVerified` back to `false`.
75
+
76
+ ## `resetPassword`
77
+
78
+ ```ts
79
+ readonly resetPassword: {
80
+ request(email: string): Promise<(IssuedToken & { user: User }) | null>;
81
+ confirm(token: string, password: string): Promise<User>;
82
+ };
83
+ ```
84
+
85
+ `request` answers `null` for an e-mail nobody holds. **Never tell the visitor
86
+ which**: answer the same page either way.
87
+
88
+ ```ts
89
+ export async function forgotPassword(request: Request): Promise<Response> {
90
+ const { email } = (await request.json()) as { email: string };
91
+ const issued = await auth.resetPassword.request(email);
92
+ if (issued !== null) {
93
+ await sendMail(issued.email, `https://app.example/reset?token=${issued.token}`);
94
+ }
95
+ return new Response(null, { status: 202 }); // the same answer either way
96
+ }
97
+ ```
98
+
99
+ `confirm` sets the password, marks the e-mail verified — the link proved it —
100
+ and **signs the user out everywhere**. It opens no session: call `signIn` next
101
+ if that is your policy. A password refused for its length does not spend the
102
+ token, so the visitor can try again with the same link.
103
+
104
+ ```ts
105
+ import { JanusError } from '@nxgt/janus';
106
+
107
+ export async function resetPassword(request: Request): Promise<Response> {
108
+ const { token, password } = (await request.json()) as { token: string; password: string };
109
+ try {
110
+ await auth.resetPassword.confirm(token, password);
111
+ return new Response(null, { status: 204 });
112
+ } catch (error) {
113
+ if (!(error instanceof JanusError)) throw error;
114
+ switch (error.code) {
115
+ case 'TOKEN_UNKNOWN':
116
+ case 'TOKEN_SPENT':
117
+ case 'TOKEN_EXPIRED':
118
+ case 'TOKEN_STALE':
119
+ return Response.json({ error: 'link' }, { status: 400 });
120
+ case 'PASSWORD_TOO_SHORT':
121
+ return Response.json({ minLength: error.minLength }, { status: 400 });
122
+ default:
123
+ throw error; // STORE_FAILED: your 503
124
+ }
125
+ }
126
+ }
127
+ ```
128
+
129
+ ## What a token refusal means
130
+
131
+ | Code | When |
132
+ | --- | --- |
133
+ | `TOKEN_UNKNOWN` | No token holds that secret — or it was issued for the other flow: a verification token is not a reset token |
134
+ | `TOKEN_SPENT` | Already redeemed. Every token is single use |
135
+ | `TOKEN_EXPIRED` | Its lifespan passed. It is spent all the same, so it cannot be retried |
136
+ | `TOKEN_STALE` | Sent to an e-mail the user no longer has |
137
+
138
+ Of twenty concurrent redemptions of one token, exactly one succeeds: the store
139
+ spends it in one conditional write. The store holds the token's `sha256`,
140
+ never the token, and no refusal's message contains it.
141
+
142
+ ## See also
143
+
144
+ - [Users](users.md) — `email`, `update`, and the other per-type methods
145
+ - [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
146
+ - [Errors](errors.md) — every code, and the status it deserves
@@ -0,0 +1,146 @@
1
+ # Errors
2
+
3
+ This page is for turning what `@nxgt/janus` throws into a response: the error
4
+ classes, every code, what each carries, and the one rule behind them. When you
5
+ have an error message in hand and want its cause, see
6
+ [troubleshooting](../troubleshooting.md).
7
+
8
+ ```ts
9
+ import { JanusError, type JanusErrorCode } from '@nxgt/janus';
10
+
11
+ export function statusOf(code: JanusErrorCode): number {
12
+ switch (code) {
13
+ case 'STORE_FAILED':
14
+ return 503;
15
+ case 'NOT_FOUND':
16
+ return 404;
17
+ case 'LOGIN_TAKEN':
18
+ case 'VERSION_CONFLICT':
19
+ return 409;
20
+ case 'USER_INVALID':
21
+ case 'PASSWORD_TOO_SHORT':
22
+ case 'HASH_UNSUPPORTED':
23
+ case 'INVALID_CURSOR':
24
+ case 'TOKEN_UNKNOWN':
25
+ case 'TOKEN_SPENT':
26
+ case 'TOKEN_EXPIRED':
27
+ case 'TOKEN_STALE':
28
+ return 400;
29
+ case 'CREDENTIALS_INVALID':
30
+ return 401;
31
+ case 'USER_INACTIVE':
32
+ return 403;
33
+ case 'UNSUPPORTED':
34
+ return 501;
35
+ case 'PERMISSION_DEPTH':
36
+ return 500;
37
+ }
38
+ }
39
+
40
+ export function toResponse(error: unknown): Response {
41
+ if (!(error instanceof JanusError)) throw error;
42
+ return Response.json({ code: error.code }, { status: statusOf(error.code) });
43
+ }
44
+ ```
45
+
46
+ `JanusErrorCode` is a union of sixteen string literals, so that `switch` is
47
+ exhaustive: when a code is added, a function like `statusOf` stops compiling
48
+ instead of answering `undefined`.
49
+
50
+ ## The one rule
51
+
52
+ **An absence is `null`. A failure throws.** A call that can find nothing
53
+ answers `null`, `false` or an empty page. A store that cannot answer — a
54
+ refused connection, a timeout, a primary stepping down, a bug in the adapter —
55
+ rejects with `STORE_FAILED`. Answer 503. Mapping it to a 404, to `null` or to
56
+ `false` turns an outage into a silent lockout: everybody who has an account is
57
+ told they do not.
58
+
59
+ ## Two kinds of refusal
60
+
61
+ | Thrown | When | Class |
62
+ | --- | --- | --- |
63
+ | At **call** time, on a value that could have come from a request | a taken login, a wrong password, a spent token, an outage | a `JanusError` subclass, with a `code` |
64
+ | At **wiring** time, from how you called the library | a lifespan that is not a duration, a store missing a method, a model with a loop, a malformed tuple string | a bare `TypeError` |
65
+
66
+ No request handler should ever answer a `TypeError` — it is a bug in the code
67
+ that wired the library, so no handler needs to tell it apart.
68
+
69
+ ## The codes
70
+
71
+ | Code | Class | Status | When | Carries |
72
+ | --- | --- | --- | --- | --- |
73
+ | `STORE_FAILED` | `StoreFailure` | 503 | A store could not answer. **Never a negative answer** | `slot`, `operation`, `cause` |
74
+ | `NOT_FOUND` | `NotFoundError` | 404 | `get`, `getUser`, or a write to a user who is gone. `find*` answers `null` instead | `userId` |
75
+ | `LOGIN_TAKEN` | `StoreConflict` (`on: 'login'`) | 409 | Another user of the same type holds the login | `login`, `userType` |
76
+ | `VERSION_CONFLICT` | `StoreConflict` (`on: 'version'`) | 409 | `ifVersion` no longer matches; nothing was written | `expectedVersion`, `actualVersion` |
77
+ | `USER_INVALID` | `UserInvalidError` | 400 | The fields failed the schema | `issues`, field by field |
78
+ | `PASSWORD_TOO_SHORT` | `CredentialError` | 400 | Below `password.minLength` | `minLength` — never the password |
79
+ | `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** | `reason`, for your logs only |
80
+ | `HASH_UNSUPPORTED` | `CredentialError` | 400 | A stored hash no wired hasher reads | `hashPrefix` — never the hash |
81
+ | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password | `userId` |
82
+ | `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means) | |
83
+ | `INVALID_CURSOR` | `InvalidCursorError` | 400 | A cursor this store did not mint. Never a silent first page | |
84
+ | `UNSUPPORTED` | `UnsupportedError` | 501 | The wired store lacks an optional capability — `collectExpired` without `deleteExpiredSessions` | `slot`, `operation` |
85
+ | `PERMISSION_DEPTH` | `PermissionDepthError` | 500 | A check or list walked past `maxDepth`. **Not a denial** | `permission`, `maxDepth` |
86
+
87
+ Every class extends `JanusError`, which extends `Error`, so no `catch` block
88
+ needs ordering. Every field listed above is on `JanusError` itself, `undefined`
89
+ when it does not apply.
90
+
91
+ ## Handling the ones that need care
92
+
93
+ ```ts
94
+ import { CredentialError, JanusError, StoreFailure } from '@nxgt/janus';
95
+
96
+ async function signIn(email: string, password: string): Promise<Response> {
97
+ try {
98
+ const { token } = await auth.signIn({ email, password });
99
+ return Response.json({ token });
100
+ } catch (error) {
101
+ if (error instanceof CredentialError && error.code === 'CREDENTIALS_INVALID') {
102
+ console.warn('sign-in refused', error.reason); // 'unknownLogin' | 'noPassword' | 'wrongPassword'
103
+ return Response.json({ error: 'invalid' }, { status: 401 });
104
+ }
105
+ if (error instanceof StoreFailure) return new Response(null, { status: 503 });
106
+ throw error;
107
+ }
108
+ }
109
+ ```
110
+
111
+ - **Never put `reason` in a response body.** `unknownLogin` tells an attacker
112
+ which accounts exist.
113
+ - **`STORE_FAILED` is never a 401 or a 404.** Test for it before anything that
114
+ would read as "no".
115
+ - **`VERSION_CONFLICT` is a retry**: read the user again, reapply, write with
116
+ the new `version`.
117
+ - **`USER_INVALID`'s `issues`** have the schema's own paths
118
+ (`['address', 'city']`), so a form can show each next to its field.
119
+
120
+ ## No message holds a secret
121
+
122
+ Not a password, not a hash, not a session token, not a token's hash, and not a
123
+ connection URI — a connection string holds a password. A `login` may appear in
124
+ a `LOGIN_TAKEN` message, because the caller just sent it. A message names the
125
+ call you wrote (`signIn`, `users.findUser`) so you know where to look.
126
+
127
+ ## For adapter authors
128
+
129
+ `StoreFailure` and `StoreConflict` are exported **because an adapter throws
130
+ them**. An adapter defines no error class of its own, so `instanceof` holds
131
+ across the two packages:
132
+
133
+ ```ts
134
+ import { StoreConflict, StoreFailure } from '@nxgt/janus';
135
+
136
+ throw new StoreFailure('users.findUser: the store could not answer', { slot: 'users', operation: 'findUser', cause });
137
+ throw new StoreConflict('login', 'users.insertUser: the login is taken', { login, userType: 'user' });
138
+ throw new StoreConflict('version', 'users.updateUser: the version moved', { expectedVersion: 3, actualVersion: 4 });
139
+ ```
140
+
141
+ See [Writing an adapter](adapters.md).
142
+
143
+ ## See also
144
+
145
+ - [Troubleshooting](../troubleshooting.md) — by the message you see
146
+ - [Users](users.md) and [Sessions](sessions.md) — which method rejects with what