@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.
- package/LICENSE +21 -0
- package/README.md +554 -0
- package/dist/auth/config.d.ts +201 -0
- package/dist/auth/config.d.ts.map +1 -0
- package/dist/auth/context.d.ts +87 -0
- package/dist/auth/context.d.ts.map +1 -0
- package/dist/auth/hashers.d.ts +35 -0
- package/dist/auth/hashers.d.ts.map +1 -0
- package/dist/auth/index.d.ts +9 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/janus.d.ts +38 -0
- package/dist/auth/janus.d.ts.map +1 -0
- package/dist/auth/outage.d.ts +61 -0
- package/dist/auth/outage.d.ts.map +1 -0
- package/dist/auth/port/assert-stores.d.ts +16 -0
- package/dist/auth/port/assert-stores.d.ts.map +1 -0
- package/dist/auth/port/memory.d.ts +25 -0
- package/dist/auth/port/memory.d.ts.map +1 -0
- package/dist/auth/port/types.d.ts +393 -0
- package/dist/auth/port/types.d.ts.map +1 -0
- package/dist/auth/secrets.d.ts +17 -0
- package/dist/auth/secrets.d.ts.map +1 -0
- package/dist/auth/sessions.d.ts +31 -0
- package/dist/auth/sessions.d.ts.map +1 -0
- package/dist/auth/standard-schema.d.ts +41 -0
- package/dist/auth/standard-schema.d.ts.map +1 -0
- package/dist/auth/types.d.ts +353 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/users.d.ts +9 -0
- package/dist/auth/users.d.ts.map +1 -0
- package/dist/chunks/index-658b6mr2.js +269 -0
- package/dist/chunks/index-658b6mr2.js.map +11 -0
- package/dist/chunks/index-6dytvy3h.js +93 -0
- package/dist/chunks/index-6dytvy3h.js.map +11 -0
- package/dist/chunks/index-6p56fpbe.js +147 -0
- package/dist/chunks/index-6p56fpbe.js.map +12 -0
- package/dist/chunks/index-fgb3t64y.js +77 -0
- package/dist/chunks/index-fgb3t64y.js.map +10 -0
- package/dist/conformance/assert.d.ts +36 -0
- package/dist/conformance/assert.d.ts.map +1 -0
- package/dist/conformance/cases/outage.d.ts +3 -0
- package/dist/conformance/cases/outage.d.ts.map +1 -0
- package/dist/conformance/cases/sessions.d.ts +3 -0
- package/dist/conformance/cases/sessions.d.ts.map +1 -0
- package/dist/conformance/cases/tokens.d.ts +3 -0
- package/dist/conformance/cases/tokens.d.ts.map +1 -0
- package/dist/conformance/cases/users.d.ts +3 -0
- package/dist/conformance/cases/users.d.ts.map +1 -0
- package/dist/conformance/describe.d.ts +74 -0
- package/dist/conformance/describe.d.ts.map +1 -0
- package/dist/conformance/fixtures.d.ts +11 -0
- package/dist/conformance/fixtures.d.ts.map +1 -0
- package/dist/conformance/index.d.ts +33 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +1113 -0
- package/dist/conformance/index.js.map +18 -0
- package/dist/conformance/reference.d.ts +13 -0
- package/dist/conformance/reference.d.ts.map +1 -0
- package/dist/conformance/relations.d.ts +67 -0
- package/dist/conformance/relations.d.ts.map +1 -0
- package/dist/conformance/types.d.ts +71 -0
- package/dist/conformance/types.d.ts.map +1 -0
- package/dist/errors/janus-error.d.ts +259 -0
- package/dist/errors/janus-error.d.ts.map +1 -0
- package/dist/ids/id.d.ts +55 -0
- package/dist/ids/id.d.ts.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +962 -0
- package/dist/index.js.map +19 -0
- package/dist/pagination/cursor-page.d.ts +53 -0
- package/dist/pagination/cursor-page.d.ts.map +1 -0
- package/dist/permissions/engine.d.ts +46 -0
- package/dist/permissions/engine.d.ts.map +1 -0
- package/dist/permissions/index.d.ts +24 -0
- package/dist/permissions/index.d.ts.map +1 -0
- package/dist/permissions/index.js +685 -0
- package/dist/permissions/index.js.map +15 -0
- package/dist/permissions/input.d.ts +29 -0
- package/dist/permissions/input.d.ts.map +1 -0
- package/dist/permissions/model.d.ts +368 -0
- package/dist/permissions/model.d.ts.map +1 -0
- package/dist/permissions/port/memory.d.ts +17 -0
- package/dist/permissions/port/memory.d.ts.map +1 -0
- package/dist/permissions/port/types.d.ts +84 -0
- package/dist/permissions/port/types.d.ts.map +1 -0
- package/dist/permissions/resolve.d.ts +59 -0
- package/dist/permissions/resolve.d.ts.map +1 -0
- package/dist/permissions/reverse.d.ts +53 -0
- package/dist/permissions/reverse.d.ts.map +1 -0
- package/dist/permissions/walk.d.ts +29 -0
- package/dist/permissions/walk.d.ts.map +1 -0
- package/dist/subjects/notation.d.ts +35 -0
- package/dist/subjects/notation.d.ts.map +1 -0
- package/dist/subjects/subject.d.ts +75 -0
- package/dist/subjects/subject.d.ts.map +1 -0
- package/dist/time/clock.d.ts +31 -0
- package/dist/time/clock.d.ts.map +1 -0
- package/dist/time/duration.d.ts +23 -0
- package/dist/time/duration.d.ts.map +1 -0
- package/docs/README.md +17 -0
- package/docs/guide/adapters.md +296 -0
- package/docs/guide/email-flows.md +146 -0
- package/docs/guide/errors.md +146 -0
- package/docs/guide/passwords.md +136 -0
- package/docs/guide/permissions.md +347 -0
- package/docs/guide/sessions.md +211 -0
- package/docs/guide/users.md +277 -0
- package/docs/guide/vocabulary.md +163 -0
- package/docs/roadmap.md +93 -0
- package/docs/troubleshooting.md +648 -0
- 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
|