@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,136 @@
1
+ # Password hashing
2
+
3
+ This page is for choosing a password hasher, moving from one to another, and
4
+ importing hashes written by another system. The login and the length policy
5
+ are on the [users](users.md#options) page.
6
+
7
+ ```ts
8
+ import { z } from 'zod';
9
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
10
+
11
+ const User = z.object({ email: z.email() });
12
+ const store = createMemoryStores();
13
+
14
+ const auth = janus({
15
+ user: User,
16
+ password: { login: 'email' },
17
+ store,
18
+ hasher: scryptHasher(), // N = 2^17, r = 8, p = 1
19
+ });
20
+ ```
21
+
22
+ The examples below reuse `User` and `store`.
23
+
24
+ A user type with a password and no `hasher` is refused at wiring with a
25
+ `TypeError`. There is no silent fallback.
26
+
27
+ ## The two hashers
28
+
29
+ | Hasher | Runs on | Hash | Parameters |
30
+ | --- | --- | --- | --- |
31
+ | `scryptHasher({ cost? })` | Node and Bun, no dependency | `$scrypt$ln=17,r=8,p=1$<salt>$<key>` | OWASP's: `cost` is log2(N), `17` by default, `10` to `20`; about 128 MiB per hash |
32
+ | `bunHasher()` | Bun only, through `Bun.password` | `$argon2id$v=19$m=65536,t=2,p=1$…` | Pinned, so a change of default in a Bun release is not inherited silently |
33
+
34
+ `bunHasher()` throws a `TypeError` when called outside Bun; the package still
35
+ imports cleanly under Node, because nothing reads `Bun` until then.
36
+
37
+ Lower `cost` only in tests — `10` is fast and still exercises every line:
38
+
39
+ ```ts
40
+ const hasher = scryptHasher({ cost: 10 });
41
+ ```
42
+
43
+ ## Hashes describe themselves
44
+
45
+ Every hash starts with its hasher's `prefix`, and carries its parameters. So
46
+ **every wired hasher can verify, and exactly one hashes**: `hasher` writes new
47
+ hashes, and `verifiers` only read the ones a database was written with before.
48
+
49
+ ```ts
50
+ import { bunHasher, scryptHasher } from '@nxgt/janus';
51
+
52
+ const auth = janus({
53
+ user: User,
54
+ password: { login: 'email' },
55
+ store,
56
+ hasher: bunHasher(), // new hashes: argon2id
57
+ verifiers: [scryptHasher()], // existing scrypt hashes still verify
58
+ });
59
+ ```
60
+
61
+ Two hashers claiming the same prefix are refused at wiring: which one verified
62
+ would depend on the order they were listed in. A stored hash whose prefix no
63
+ wired hasher claims is `HASH_UNSUPPORTED` at sign-in, and the error reports
64
+ the prefix, never the hash.
65
+
66
+ ## Rehash on sign-in
67
+
68
+ When a password matches a **stale** hash, `signIn` rewrites it with `hasher`.
69
+ A hash is stale when:
70
+
71
+ - a `verifiers` hasher wrote it, or
72
+ - `hasher` wrote it with other parameters than it uses now — a raised scrypt
73
+ `cost`, or argon2id parameters other than the pinned ones.
74
+
75
+ Moving off a hasher, or raising its cost, therefore reaches every active user
76
+ with no migration to run. The password's `updatedAt` is kept, since the
77
+ password did not change; the user's `version` moves. The write is conditional
78
+ on the version just read: if a concurrent update wins, the sign-in still
79
+ succeeds and the next one tries again. An outage on that write fails the
80
+ sign-in with `STORE_FAILED`.
81
+
82
+ That `version` move is why a user object read **before** a sign-in, then passed
83
+ as `ifVersion`, can get `VERSION_CONFLICT`. Read the user again.
84
+
85
+ ## Importing hashes from another system
86
+
87
+ A `PasswordHasher` is four members, and a verifier for a foreign format is a
88
+ small object:
89
+
90
+ ```ts
91
+ interface PasswordHasher {
92
+ readonly prefix: string; // every hash it writes starts with this
93
+ hash(plain: string): Promise<string>;
94
+ verify(plain: string, hash: string): Promise<boolean>;
95
+ needsRehash?(hash: string): boolean; // its own hash, written with outdated parameters
96
+ }
97
+ ```
98
+
99
+ ```ts
100
+ import { type PasswordHasher, scryptHasher } from '@nxgt/janus';
101
+
102
+ // Your bcrypt library's compare — bcryptjs's `compare`, for one.
103
+ declare function compareBcrypt(plain: string, hash: string): Promise<boolean>;
104
+
105
+ const legacyBcrypt: PasswordHasher = {
106
+ prefix: '$2b$',
107
+ hash: () => Promise.reject(new Error('bcrypt only verifies here')), // never called: it is not `hasher`
108
+ verify: (plain, hash) => compareBcrypt(plain, hash),
109
+ };
110
+
111
+ const auth = janus({
112
+ user: User,
113
+ password: { login: 'email' },
114
+ store,
115
+ hasher: scryptHasher(),
116
+ verifiers: [legacyBcrypt],
117
+ });
118
+ ```
119
+
120
+ Write each imported user's hash as their `PasswordRecord` with your store's
121
+ `insertUser` — `janus()` has no call that takes a hash — and each one moves to
122
+ scrypt the first time they sign in. Wire one verifier
123
+ per prefix the old system wrote (`$2a$`, `$2b$`, `$2y$` for bcrypt).
124
+
125
+ ## What never happens
126
+
127
+ - The plain password is never stored, and never appears in an error message.
128
+ - The hash never reaches a `User` — `hasPassword` says whether there is one.
129
+ - `signIn` compares against a dummy hash when nobody holds the login, so the
130
+ time taken does not reveal which accounts exist. The store's own latency
131
+ still can, and that limit is stated rather than denied.
132
+
133
+ ## See also
134
+
135
+ - [Users](users.md) — `signIn`, `setPassword`, `changePassword`
136
+ - [Errors](errors.md) — `CREDENTIALS_INVALID` and its `reason`
@@ -0,0 +1,347 @@
1
+ # Permissions — `@nxgt/janus/permissions`
2
+
3
+ This page is for modelling who may do what, and asking: `defineModel`,
4
+ `fromField`, `when`, then `can`, `list`, `grant` and `revoke`. It is
5
+ Zanzibar's model — relations between objects and subjects, permissions
6
+ computed from them — **without its infrastructure**: the tuples live in your
7
+ database, so a read follows a write and there is nothing to cache.
8
+
9
+ ```ts
10
+ import { z } from 'zod';
11
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
12
+ import { createMemoryRelations, defineModel, permissions } from '@nxgt/janus/permissions';
13
+
14
+ const relations = createMemoryRelations();
15
+
16
+ const auth = janus({
17
+ users: {
18
+ staff: { schema: z.object({ username: z.string() }), password: { login: 'username' } },
19
+ },
20
+ store: createMemoryStores(),
21
+ relations, // deleting a user deletes every tuple naming them
22
+ hasher: scryptHasher(),
23
+ });
24
+
25
+ const model = defineModel({
26
+ subjects: auth.types, // 'staff': a user type is a subject type
27
+ types: {
28
+ team: {
29
+ relations: { member: ['staff', 'team#member'], lead: ['staff'] },
30
+ permissions: { manage: ['lead'], view: ['member', 'manage'] },
31
+ },
32
+ },
33
+ });
34
+
35
+ const access = permissions({ model, store: relations });
36
+
37
+ const grace = await auth.staff.create({ username: 'grace' });
38
+ const team = { type: 'team', id: 't1' } as const;
39
+
40
+ await access.grant(team, 'lead', grace);
41
+ await access.can(grace, 'view', team); // true: view includes manage, which includes lead
42
+ await access.revoke(team, 'lead', grace);
43
+ await access.can(grace, 'view', team); // false
44
+ ```
45
+
46
+ ## The model
47
+
48
+ ```ts
49
+ function defineModel<const C extends ModelConfig>(config: C & CheckedModel<C>): PermissionModel<C>;
50
+
51
+ interface ModelConfig {
52
+ readonly subjects: readonly string[]; // pass auth.types
53
+ readonly types: {
54
+ readonly [objectType: string]: {
55
+ readonly relations?: { readonly [name: string]: readonly string[] | FromField };
56
+ readonly permissions?: { readonly [name: string]: readonly (string | When)[] };
57
+ };
58
+ };
59
+ }
60
+ ```
61
+
62
+ **A relation** lists who may hold it:
63
+
64
+ | Holder | Means |
65
+ | --- | --- |
66
+ | `'staff'` | one user of type `staff` |
67
+ | `'team'` | one object of type `team` — what an arrow follows |
68
+ | `'team#member'` | a **subject set**: every member of a team |
69
+ | `fromField('doctorId', 'staff')` | read from the object's own data — see [`fromField`](#fromfield) |
70
+
71
+ **A permission** is the union of its rules:
72
+
73
+ | Rule | Means |
74
+ | --- | --- |
75
+ | `'member'` | a relation of the same object |
76
+ | `'manage'` | another permission of the same object |
77
+ | `'team->view'` | an **arrow**: whoever holds `view` on the object's `team` |
78
+ | `when('doctor', (ctx: { onShift: boolean }) => ctx.onShift)` | a rule under a condition — see [`when`](#when) |
79
+
80
+ A permission may be asked of `can` and `list` like a relation, and a relation
81
+ like a permission.
82
+
83
+ ### What the compiler refuses
84
+
85
+ A relation naming a type that does not exist, a rule naming nothing, an arrow
86
+ to a permission its target lacks, a name that is both a relation and a
87
+ permission: each is a compile error **on the offending key**, and the error
88
+ lists what you could have written.
89
+
90
+ ```ts
91
+ defineModel({
92
+ subjects: ['staff'],
93
+ types: {
94
+ team: {
95
+ // @ts-expect-error — '"staf" is not a subject type or a subject set; name one of' …
96
+ relations: { member: ['staf'] },
97
+ },
98
+ },
99
+ });
100
+ ```
101
+
102
+ ### What `defineModel` refuses at run time
103
+
104
+ With a `TypeError`, when the model is defined — never at a check:
105
+
106
+ - a name that is not camelCase;
107
+ - an object type named like a user type;
108
+ - a permission that reaches itself without crossing a relation (`view:
109
+ ['edit'], edit: ['view']`) — no data could ever end that loop;
110
+ - a subject set or an arrow that would have to read **another** object's
111
+ `fromField` — only the object passed to `can()` carries its data. Store that
112
+ relation instead.
113
+
114
+ A loop that crosses a relation — a folder viewable through its parent — is
115
+ fine: the data ends it.
116
+
117
+ ## `fromField`
118
+
119
+ ```ts
120
+ function fromField(field, subjectType): FromField;
121
+ function fromField(field, subjectType, { lookup }): ReversibleFromField;
122
+ type Lookup = (subjectId: string) => Promise<readonly string[]>;
123
+ ```
124
+
125
+ A relation read from the object's own data — a record's `doctorId` — rather
126
+ than a tuple kept in sync with it. Nothing is stored, and `grant` refuses it at
127
+ compile time. `can()` is given the object, and the compiler requires every
128
+ field a `fromField` of its type reads:
129
+
130
+ ```ts
131
+ const model = defineModel({
132
+ subjects: ['staff'],
133
+ types: {
134
+ record: {
135
+ relations: { doctor: fromField('doctorId', 'staff') },
136
+ permissions: { view: ['doctor'] },
137
+ },
138
+ },
139
+ });
140
+ const access = permissions({ model, store: createMemoryRelations() });
141
+
142
+ const record = { id: 'r1', doctorId: grace.id, title: 'Chart' }; // as your database answered it
143
+ await access.can(grace, 'view', { type: 'record', ...record });
144
+ ```
145
+
146
+ **Spread the loaded object.** A field missing at run time is a `TypeError`,
147
+ never a denial. `null` in the field holds nobody.
148
+
149
+ `list()` cannot read a field of objects it has not found, so it asks `lookup`
150
+ for the ids of the objects whose field names the subject. A `list()` that would
151
+ reach a `fromField` without one is a compile error:
152
+
153
+ ```ts
154
+ fromField('doctorId', 'staff', { lookup: (staffId) => db.records.ids({ doctorId: staffId }) });
155
+ ```
156
+
157
+ A lookup is your code, and it is not guarded: one that throws rejects `list()`
158
+ with its own error. **Never answer `[]` for a database that could not answer**
159
+ — that is a denial made of an outage.
160
+
161
+ ## `when`
162
+
163
+ ```ts
164
+ function when<const Rule extends string, Ctx>(rule: Rule, test: (ctx: Ctx) => boolean): When<Rule, Ctx>;
165
+ ```
166
+
167
+ Puts a condition written in TypeScript on a rule — any rule of the same type:
168
+ a relation, a permission, an arrow. The test is synchronous and pure: it
169
+ decides on what the caller passes, and reads nothing. Its `ctx` is what `can()`
170
+ and `list()` then **require**, and only for the permissions whose rules reach
171
+ it:
172
+
173
+ ```ts
174
+ const model = defineModel({
175
+ subjects: ['staff'],
176
+ types: {
177
+ record: {
178
+ relations: { doctor: fromField('doctorId', 'staff') },
179
+ permissions: {
180
+ edit: [when('doctor', (ctx: { onShift: boolean }) => ctx.onShift)],
181
+ },
182
+ },
183
+ },
184
+ });
185
+ const access = permissions({ model, store: createMemoryRelations() });
186
+
187
+ await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: true } });
188
+ // @ts-expect-error — ctx is required: edit reaches a condition
189
+ await access.can(grace, 'edit', { type: 'record', ...record });
190
+ ```
191
+
192
+ ## `permissions()`
193
+
194
+ ```ts
195
+ function permissions<C extends ModelConfig>(options: PermissionsOptions<C>): Permissions<C>;
196
+ ```
197
+
198
+ | Option | Type | Default | Effect |
199
+ | --- | --- | --- | --- |
200
+ | `model` | `PermissionModel` | — | What `defineModel` answered |
201
+ | `store` | `RelationStore` | — | Where tuples live: `createMemoryRelations()` in tests, `createMongoRelations(db)` from `@nxgt/janus-mongo` |
202
+ | `maxDepth` | positive integer | `25` | How many relations a check may cross. Past it: `PERMISSION_DEPTH` |
203
+
204
+ It connects to nothing, and refuses a store missing a method with a
205
+ `TypeError`.
206
+
207
+ ### `can`
208
+
209
+ ```ts
210
+ can(subject, permission, object, options?): Promise<boolean>;
211
+ ```
212
+
213
+ - `subject` is a user from `janus()` as it is — any object with `type` and
214
+ `id` — an object of the model, or `null` for anonymous, which answers `false`
215
+ before any store call.
216
+ - `permission` must be a relation or a permission of the object's type.
217
+ - `object` is `{ type, id }` plus every field its `fromField`s read.
218
+ - `options.ctx` is required exactly when a `when` is reachable.
219
+
220
+ ```ts
221
+ await access.can(grace, 'view', { type: 'record', ...record }); // true
222
+ await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: false } }); // false
223
+ await access.can(null, 'view', { type: 'record', ...record }); // false, no store call
224
+ ```
225
+
226
+ **A denial is `false`; a failure throws.** A store that cannot answer is
227
+ `STORE_FAILED`; a walk past `maxDepth` is `PERMISSION_DEPTH`. Neither is ever
228
+ `false`, which would deny everybody everything during an outage and say
229
+ nothing. A cycle in the data — a team member of itself — is cut, and is not an
230
+ error.
231
+
232
+ ### `list`
233
+
234
+ ```ts
235
+ list(subject, permission, objectType, options?): Promise<CursorPage<string>>;
236
+ ```
237
+
238
+ The ids of the objects of `objectType` on which `subject` holds `permission`,
239
+ ascending, by pages — what `can()` answers `true` for, found without naming
240
+ them.
241
+
242
+ ```ts
243
+ const page = await access.list(grace, 'view', 'record', { limit: 50 });
244
+ page.items; // readonly string[]
245
+ page.nextCursor; // string | null
246
+ await access.list(grace, 'view', 'record', { after: page.nextCursor, limit: 50 });
247
+ ```
248
+
249
+ `limit` is 20 by default and at most 100; `ctx` is required as for `can`.
250
+ `null` answers an empty page before any store call.
251
+
252
+ `list()` walks backwards from the subject, reading every page of the reverse
253
+ index for every id each step reaches. That is fine for what one user can see,
254
+ and not for a subject set holding most of the database — write a query of your
255
+ own for that.
256
+
257
+ ### `grant` and `revoke`
258
+
259
+ ```ts
260
+ grant(object, relation, holder): Promise<void>;
261
+ revoke(object, relation, holder): Promise<void>;
262
+ ```
263
+
264
+ Both are typed from the model: only a stored relation (never a `fromField`),
265
+ and only a holder the relation admits.
266
+
267
+ The same rule holds when reading: `can()` and `list()` follow only the holders
268
+ a relation admits. A tuple stored past `grant()` — by an older model, or by
269
+ hand — that the model does not admit grants nothing. `revoke()` refuses it as
270
+ it refuses to `grant()` it, so remove it with the store:
271
+
272
+ ```ts
273
+ await relations.write({
274
+ remove: [
275
+ {
276
+ object: { type: 'record', id: 'r1' },
277
+ relation: 'viewer',
278
+ subject: { type: 'team', id: 't1' },
279
+ },
280
+ ],
281
+ });
282
+ ```
283
+
284
+ Narrowing a model therefore hides the tuples it no longer admits; it does not
285
+ delete them, and widening it again brings them back.
286
+
287
+ ```ts
288
+ await access.grant({ type: 'team', id: 't1' }, 'member', grace);
289
+ await access.grant({ type: 'team', id: 't1' }, 'member', { type: 'team', id: 't2', relation: 'member' }); // a subject set
290
+ await access.grant({ type: 'record', id: 'r1' }, 'team', { type: 'team', id: 't1' }); // what 'team->view' follows
291
+ await access.revoke({ type: 'team', id: 't1' }, 'member', grace);
292
+ ```
293
+
294
+ Both are idempotent: granting what is held, or revoking what is not, is not an
295
+ error. Each writes one tuple.
296
+
297
+ ### Deleting
298
+
299
+ Wire the relation store into `janus({ relations })`, and deleting a user
300
+ deletes every tuple naming them. Deleting an object's tuples is yours, from
301
+ your own code, when you delete the object:
302
+
303
+ ```ts
304
+ await relations.deleteEntity({ type: 'record', id: 'r1' }); // answers how many tuples it removed
305
+ ```
306
+
307
+ ## A route guard
308
+
309
+ ```ts
310
+ import { PermissionDepthError, StoreFailure } from '@nxgt/janus';
311
+
312
+ export async function getRecord(request: Request, id: string): Promise<Response> {
313
+ try {
314
+ const current = await auth.authenticate(request, { type: 'staff' });
315
+ const record = await loadRecord(id);
316
+ if (record === null) return new Response(null, { status: 404 });
317
+
318
+ const allowed = await access.can(current?.user ?? null, 'view', { type: 'record', ...record });
319
+ if (!allowed) return new Response(null, { status: current ? 403 : 401 });
320
+ return Response.json(record);
321
+ } catch (error) {
322
+ if (error instanceof StoreFailure) return new Response(null, { status: 503 });
323
+ if (error instanceof PermissionDepthError) return new Response(null, { status: 500 });
324
+ throw error;
325
+ }
326
+ }
327
+ ```
328
+
329
+ ## Subjects and the notation
330
+
331
+ A user **is** a subject: `subjectOf(user)` from `@nxgt/janus` answers its
332
+ `{ type, id }`, and `can` takes the user as it is. Tuples print in Zanzibar's
333
+ notation, typed — see [the shared vocabulary](vocabulary.md#subjects-and-the-tuple-notation).
334
+
335
+ ## The relation store
336
+
337
+ `RelationStore` is six methods answering one-hop questions about stored tuples
338
+ — `write`, `has`, `findSubjectSets`, `findEntities`, `findObjects`,
339
+ `deleteEntity`. The traversal is the core's. `createMemoryRelations()` is the
340
+ reference implementation, and [Writing an adapter](adapters.md) covers the
341
+ rest.
342
+
343
+ ## See also
344
+
345
+ - [Users](users.md) — `janus({ relations })`, and user types as subject types
346
+ - [Errors](errors.md) — `STORE_FAILED` and `PERMISSION_DEPTH`
347
+ - [Writing an adapter](adapters.md) — `RelationStore` and its conformance suite
@@ -0,0 +1,211 @@
1
+ # Sessions
2
+
3
+ This page is for everything after a user has signed in: finding who a request
4
+ belongs to, the session cookie, renewal, signing out, and testing expiry.
5
+ Signing up and signing in are on the [users](users.md) page.
6
+
7
+ ```ts
8
+ import { z } from 'zod';
9
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
10
+
11
+ const auth = janus({
12
+ user: z.object({ email: z.email(), name: z.string() }),
13
+ password: { login: 'email' },
14
+ store: createMemoryStores(),
15
+ hasher: scryptHasher(),
16
+ });
17
+
18
+ export async function me(request: Request): Promise<Response> {
19
+ const current = await auth.authenticate(request);
20
+ if (current === null) return new Response(null, { status: 401 });
21
+
22
+ const headers = new Headers();
23
+ if (current.renewed) {
24
+ headers.set('Set-Cookie', auth.cookie.serialize(current.token, current.session));
25
+ }
26
+ return Response.json({ id: current.user.id, email: current.user.email }, { headers });
27
+ }
28
+ ```
29
+
30
+ ## `authenticate`
31
+
32
+ ```ts
33
+ authenticate<T extends User['type'] = User['type']>(
34
+ request: RequestLike,
35
+ options?: { readonly type?: T },
36
+ ): Promise<Authenticated<Extract<User, { type: T }>> | null>;
37
+
38
+ interface Authenticated<U> {
39
+ readonly user: U;
40
+ readonly session: Session; // the stored session, without its token hash
41
+ readonly token: string; // the token the request presented
42
+ readonly renewed: boolean; // true when this call moved session.expiresAt
43
+ }
44
+ ```
45
+
46
+ `request` is a `Request`, a `Headers`, anything with `headers` (a Node
47
+ `IncomingMessage`), or a plain header record:
48
+
49
+ ```ts
50
+ await auth.authenticate(new Headers({ authorization: `Bearer ${token}` }));
51
+ await auth.authenticate({ 'x-session-token': token });
52
+ await auth.authenticate({ headers: { cookie: `janus-session=${token}` } });
53
+ ```
54
+
55
+ It reads `Authorization: Bearer`, then `X-Session-Token`, then the cookie.
56
+ **The first credential present wins, not the first valid one**: a client that
57
+ sends a lapsed bearer beside a live cookie is anonymous. An `Authorization`
58
+ header of another scheme (`Basic`) is not a session credential.
59
+
60
+ It answers `null` — anonymous — for no credential, an unknown token, a lapsed
61
+ or revoked session, a user deleted or inactive, or a user of another type than
62
+ `options.type`:
63
+
64
+ ```ts
65
+ const staff = await clinic.authenticate(request, { type: 'staff' }); // a patient's session → null
66
+ staff?.user.username; // typed as staff
67
+ ```
68
+
69
+ **An outage is not anonymous.** When a store cannot answer, `authenticate`
70
+ rejects with `STORE_FAILED` — answer 503. A 401 would sign everybody out during
71
+ the outage and send them to a sign-in page that cannot work either:
72
+
73
+ ```ts
74
+ import { StoreFailure } from '@nxgt/janus';
75
+
76
+ export async function guarded(request: Request): Promise<Response> {
77
+ try {
78
+ const current = await auth.authenticate(request);
79
+ return current === null ? new Response(null, { status: 401 }) : Response.json(current.user);
80
+ } catch (error) {
81
+ if (error instanceof StoreFailure) return new Response(null, { status: 503 });
82
+ throw error;
83
+ }
84
+ }
85
+ ```
86
+
87
+ ## Lifespan and renewal
88
+
89
+ | Option | Type | Default | Effect |
90
+ | --- | --- | --- | --- |
91
+ | `session.lifespan` | `Duration` | `'7d'` | How long a session lives from when it was opened or last renewed |
92
+ | `session.renewAfter` | `Duration \| false` | `'1d'` | Once this much has passed, `authenticate` moves `expiresAt` to a whole `lifespan` from now and answers `renewed: true`. `false`: a fixed lifespan |
93
+
94
+ A sliding session is written **at most once per `renewAfter`**, not on every
95
+ request. When `renewed` is `true`, send the cookie again — its `Expires` moved.
96
+ With several user types, each type has its own `session`.
97
+
98
+ Expiry is decided by the core on every read, not by the store: a store may
99
+ still hold a lapsed session, and `authenticate` answers it as anonymous.
100
+
101
+ ## The cookie
102
+
103
+ ```ts
104
+ const auth = janus({
105
+ user: z.object({ email: z.email() }),
106
+ password: { login: 'email' },
107
+ store: createMemoryStores(),
108
+ hasher: scryptHasher(),
109
+ cookie: { name: 'sid', sameSite: 'strict', domain: 'example.com' },
110
+ });
111
+
112
+ const { session, token } = await auth.signUp({ email: 'ada@example.com', password: 'correct horse' });
113
+
114
+ auth.cookie.name; // 'sid'
115
+ auth.cookie.serialize(token, session); // 'sid=…; Expires=…; Path=/; Domain=example.com; HttpOnly; SameSite=Strict; Secure'
116
+ auth.cookie.clear(); // the same cookie, expired
117
+ ```
118
+
119
+ | Option | Type | Default | Effect |
120
+ | --- | --- | --- | --- |
121
+ | `name` | string | `'janus-session'` | A cookie-name token: no space, `;` or `=` |
122
+ | `domain` | string | none | `Domain=` |
123
+ | `path` | string | `'/'` | `Path=` |
124
+ | `sameSite` | `'lax' \| 'strict' \| 'none'` | `'lax'` | `SameSite=` |
125
+ | `secure` | boolean | `true` | `Secure`. `sameSite: 'none'` requires it |
126
+
127
+ `HttpOnly` is always set. The cookie methods are synchronous and write
128
+ nothing: they build a `Set-Cookie` value for you to send.
129
+
130
+ ## Signing in and out
131
+
132
+ ```ts
133
+ export async function signIn(request: Request): Promise<Response> {
134
+ const { email, password } = (await request.json()) as { email: string; password: string };
135
+ const { user, session, token } = await auth.signIn({ email, password });
136
+ return Response.json(
137
+ { id: user.id, token }, // a native client keeps the token and sends it as a bearer
138
+ { headers: { 'Set-Cookie': auth.cookie.serialize(token, session) } },
139
+ );
140
+ }
141
+
142
+ export async function signOut(request: Request): Promise<Response> {
143
+ await auth.signOut(request); // false when the request presents no session, or an unknown one
144
+ return new Response(null, { status: 204, headers: { 'Set-Cookie': auth.cookie.clear() } });
145
+ }
146
+ ```
147
+
148
+ `signOutEverywhere(user, { except })` revokes every standing session of a
149
+ user, but the one named, and answers how many it revoked — "sign out
150
+ everywhere else":
151
+
152
+ ```ts
153
+ const current = await auth.authenticate(request);
154
+ if (current !== null) {
155
+ await auth.signOutEverywhere(current.user, { except: current.session.id });
156
+ }
157
+ ```
158
+
159
+ `changePassword` leaves other sessions open; call this after it when that is
160
+ your policy. `resetPassword.confirm` signs out everywhere on its own.
161
+
162
+ ## `collectExpired`
163
+
164
+ ```ts
165
+ const removed: number = await auth.collectExpired();
166
+ ```
167
+
168
+ Deletes lapsed sessions, for a store that keeps them. It rejects with
169
+ `UNSUPPORTED` when the sessions store does not implement the optional
170
+ `deleteExpiredSessions` — a store with its own TTL, such as
171
+ `@nxgt/janus-mongo`'s, does not. The reference store implements it.
172
+
173
+ ## Testing expiry
174
+
175
+ `fixedClock` is shipped for this:
176
+
177
+ ```ts
178
+ import { expect, it } from 'bun:test';
179
+ import { z } from 'zod';
180
+ import { createMemoryStores, fixedClock, janus, scryptHasher } from '@nxgt/janus';
181
+
182
+ it('lapses after seven days', async () => {
183
+ const clock = fixedClock(Date.UTC(2026, 0, 1));
184
+ const auth = janus({
185
+ user: z.object({ email: z.email() }),
186
+ password: { login: 'email' },
187
+ store: createMemoryStores(),
188
+ hasher: scryptHasher({ cost: 10 }), // fast in tests; keep the default in production
189
+ clock,
190
+ });
191
+ const { token } = await auth.signUp({ email: 'ada@example.com', password: 'correct horse' });
192
+ const bearer = { authorization: `Bearer ${token}` };
193
+
194
+ clock.advance(8 * 24 * 60 * 60 * 1000);
195
+
196
+ expect(await auth.authenticate(bearer)).toBeNull();
197
+ });
198
+ ```
199
+
200
+ ## What is stored
201
+
202
+ The token is 32 random bytes, handed back once in `SignedIn` and
203
+ `Authenticated`. The store holds only its `sha256`, so a dump of the store
204
+ cannot be replayed. `Session` is the stored record without that hash: `id`,
205
+ `userId`, `authenticatedAt`, `expiresAt`, `revokedAt`, `createdAt`.
206
+
207
+ ## See also
208
+
209
+ - [Users](users.md) — `signUp`, `signIn`, the configuration
210
+ - [E-mail flows](email-flows.md) — verification and password reset
211
+ - [Errors](errors.md) — `STORE_FAILED`, `UNSUPPORTED` and the rest