@nxgt/janus 0.1.2 → 0.2.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.
@@ -11,16 +11,23 @@ it to `janus()` so the user types become the subjects, but `subjects` takes
11
11
  any list of names — `defineModel({ subjects: ['user'], … })` — when your users
12
12
  live elsewhere. Importing `@nxgt/janus/permissions` loads no identity code.
13
13
 
14
+ ## The running example: a clinic
15
+
16
+ Every snippet on this page uses this model, and only its names. Two sections
17
+ need something else and say so: a [folder tree](#a-hierarchy) and
18
+ [permissions on a user](#permissions-on-a-user).
19
+
14
20
  ```ts
15
21
  import { z } from 'zod';
16
- import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
17
- import { createMemoryRelations, defineModel, permissions } from '@nxgt/janus/permissions';
22
+ import { createMemoryStores, type CursorPage, janus, scryptHasher } from '@nxgt/janus';
23
+ import { createMemoryRelations, defineModel, fromField, permissions, when } from '@nxgt/janus/permissions';
18
24
 
19
25
  const relations = createMemoryRelations();
20
26
 
21
27
  const auth = janus({
22
28
  users: {
23
29
  staff: { schema: z.object({ username: z.string() }), password: { login: 'username' } },
30
+ patient: { schema: z.object({ email: z.email() }), password: { login: 'email' } },
24
31
  },
25
32
  store: createMemoryStores(),
26
33
  relations, // deleting a user deletes every tuple naming them
@@ -28,26 +35,45 @@ const auth = janus({
28
35
  });
29
36
 
30
37
  const model = defineModel({
31
- subjects: auth.types, // 'staff': a user type is a subject type
38
+ subjects: auth.types, // 'staff' | 'patient': a user type is a subject type
32
39
  types: {
33
40
  team: {
34
- relations: { member: ['staff', 'team#member'], lead: ['staff'] },
35
- permissions: { manage: ['lead'], view: ['member', 'manage'] },
41
+ related: { members: ['staff', 'team#members'], leads: ['staff'] },
42
+ permits: { manage: ['leads'], view: ['members', 'manage'] },
43
+ },
44
+ record: {
45
+ related: {
46
+ doctors: fromField('doctorId', 'staff', { lookup: (staffId) => db.records.idsByDoctor(staffId) }),
47
+ patients: fromField('patientId', 'patient', { lookup: (patientId) => db.records.idsByPatient(patientId) }),
48
+ teams: ['team'],
49
+ },
50
+ permits: {
51
+ view: ['doctors', 'patients', 'teams->view'],
52
+ review: ['teams->leads'],
53
+ edit: [when('doctors', (ctx: { onShift: boolean }) => ctx.onShift)],
54
+ },
36
55
  },
37
56
  },
38
57
  });
39
58
 
40
59
  const access = permissions({ model, store: relations });
41
60
 
42
- const grace = await auth.staff.create({ username: 'grace' });
61
+ const grace = await auth.staff.create({ username: 'grace' }); // a doctor
62
+ const ada = await auth.staff.create({ username: 'ada' }); // a team lead
43
63
  const team = { type: 'team', id: 't1' } as const;
44
-
45
- await access.grant(team, 'lead', grace);
46
- await access.can(grace, 'view', team); // true: view includes manage, which includes lead
47
- await access.revoke(team, 'lead', grace);
48
- await access.can(grace, 'view', team); // false
64
+ const record = { id: 'r1', doctorId: grace.id, patientId: 'p1', title: 'Chart' }; // as your database answered it
49
65
  ```
50
66
 
67
+ `db` is your database; the two `lookup`s answer the ids of the records whose
68
+ field names the subject — see [`fromField`](#a-relation-read-from-the-object).
69
+
70
+ In words: a team has **members** — staff, or every member of another team —
71
+ and **leads**; whoever leads it may **manage** it, and members and managers
72
+ may **view** it. A record's **doctors** and **patients** are read from its own
73
+ fields, its **teams** are stored; its doctors, its patients and whoever can
74
+ view one of its teams may **view** it, the leads of its teams may **review**
75
+ it, and its doctors may **edit** it while on shift.
76
+
51
77
  ## The model
52
78
 
53
79
  ```ts
@@ -61,30 +87,42 @@ interface ModelConfig {
61
87
  readonly subjects: readonly string[]; // pass auth.types
62
88
  readonly types: {
63
89
  readonly [objectType: string]: {
64
- readonly relations?: { readonly [name: string]: readonly string[] | FromField };
65
- readonly permissions?: { readonly [name: string]: readonly (string | When)[] };
90
+ readonly related?: { readonly [relation: string]: readonly string[] | FromField };
91
+ readonly permits?: { readonly [permission: string]: readonly (string | When)[] };
66
92
  };
67
93
  };
68
94
  }
69
95
  ```
70
96
 
97
+ An object type has two keys, Keto's words: **`related`**, who may hold each
98
+ relation, and **`permits`**, what each permission is made of. Relation names
99
+ are plural by convention — `members`, `doctors`, `teams` — because a relation
100
+ holds many; `defineModel` does not enforce it.
101
+
71
102
  **A relation** lists who may hold it:
72
103
 
73
104
  | Holder | Means |
74
105
  | --- | --- |
75
106
  | `'staff'` | one user of type `staff` |
76
107
  | `'team'` | one object of type `team` — what an arrow follows |
77
- | `'team#member'` | a **subject set**: every member of a team |
78
- | `fromField('doctorId', 'staff')` | read from the object's own data — see [`fromField`](#fromfield) |
108
+ | `'team#members'` | a **subject set**: every member of a team |
109
+ | `fromField('doctorId', 'staff')` | read from the object's own data — see [`fromField`](#a-relation-read-from-the-object) |
79
110
 
80
111
  **A permission** is the union of its rules:
81
112
 
82
113
  | Rule | Means |
83
114
  | --- | --- |
84
- | `'member'` | a relation of the same object |
115
+ | `'members'` | a relation of the same object |
85
116
  | `'manage'` | another permission of the same object |
86
- | `'team->view'` | an **arrow**: whoever holds `view` on the object's `team` |
87
- | `when('doctor', (ctx: { onShift: boolean }) => ctx.onShift)` | a rule under a condition — see [`when`](#when) |
117
+ | `'teams->view'` | an **arrow**: whoever holds `view` on one of the object's `teams` |
118
+ | `'teams->leads'` | an arrow to a relation: whoever leads one of the object's `teams` |
119
+ | `when('doctors', (ctx: { onShift: boolean }) => ctx.onShift)` | a rule under a condition — see [`when`](#a-condition) |
120
+
121
+ A rule may name another permission of the same type — `view: ['members',
122
+ 'manage']` — but never the permission itself: `view: ['view']` adds nothing
123
+ and is a loop no relation ends, so your editor does not offer it and the
124
+ compiler refuses it. A loop through another permission (`view: ['edit'],
125
+ edit: ['view']`) is refused by `defineModel` when it runs.
88
126
 
89
127
  A permission may be asked of `can` and `list` like a relation, and a relation
90
128
  like a permission.
@@ -92,120 +130,256 @@ like a permission.
92
130
  ### What the compiler refuses
93
131
 
94
132
  A relation naming a type that does not exist, a rule naming nothing, an arrow
95
- to a permission its target lacks, a name that is both a relation and a
96
- permission: each is a compile error **on the offending name** — on the whole
97
- `fromField(…)` or `when(…)` call for those two. Except for that last one,
98
- which says to rename one, the error lists what you could have written, with
99
- "Did you mean" when one is close.
133
+ to a permission its target lacks, an arrow through a relation that can hold a
134
+ subject set, a name that is both a relation and a permission, a key other than
135
+ `related` and `permits` — `permit:`, singular — on an object type: each is a
136
+ compile error **on the offending name** — on the whole `fromField(…)` or
137
+ `when(…)` call for those two. Except for the name used twice, which says to
138
+ rename one, the error lists what you could have written, with "Did you mean"
139
+ when one is close.
100
140
 
101
- Your editor offers those names as you type — subject types and subject sets
102
- in a relation, subject types in `fromField`, relations, permissions and arrows
103
- in a rule and in `when` — because `defineModel` types its `types` with a
104
- constraint an editor reads, not only with a check. A spec asks the TypeScript
105
- language service what it completes, so a change that loses it fails.
141
+ ```ts
142
+ defineModel({
143
+ subjects: ['staff'],
144
+ types: {
145
+ team: {
146
+ // @ts-expect-error — Type '"staf"' is not assignable to type '"staff" | "team" | "team#members"'. Did you mean '"staff"'?
147
+ related: { members: ['staf'] },
148
+ },
149
+ },
150
+ });
151
+ ```
152
+
153
+ **The keys before 0.2**, `relations` and `permissions`, are refused with the
154
+ name that replaced them — rename the key, nothing else changes:
106
155
 
107
156
  ```ts
108
157
  defineModel({
109
158
  subjects: ['staff'],
110
159
  types: {
111
160
  team: {
112
- // @ts-expect-error — Type '"staf"' is not assignable to type '"staff" | "team" | "team#member"'. Did you mean '"staff"'?
113
- relations: { member: ['staf'] },
161
+ // @ts-expect-error — 'members' does not exist in type 'Refusal<"team.relations is now related: rename the key", never>'
162
+ relations: { members: ['staff'] },
114
163
  },
115
164
  },
116
165
  });
117
166
  ```
118
167
 
168
+ `permissions:` is refused the same way: `team.permissions is now permits:
169
+ rename the key`.
170
+
171
+ Your editor offers those names as you type — subject types and subject sets
172
+ in a relation, subject types in `fromField`, relations, permissions and arrows
173
+ in a rule and in `when` — because `defineModel` types its `types` with a
174
+ constraint an editor reads, not only with a check. A spec asks the TypeScript
175
+ language service what it completes, so a change that loses it fails.
176
+
119
177
  ### What `defineModel` refuses at run time
120
178
 
121
- With a `TypeError`, when the model is defined — never at a check:
179
+ With a `TypeError`, when the model is defined — never at a check. Each names
180
+ the key you wrote, `types.record.permits.view` or `types.team.related.members`:
122
181
 
182
+ - `relations` or `permissions` — the keys before 0.2 — and any key other than
183
+ `related` and `permits`;
123
184
  - a name that is not camelCase;
124
- - an object type named like a user type;
185
+ - an object type named like a user type — see
186
+ [permissions on a user](#permissions-on-a-user);
125
187
  - a permission that reaches itself without crossing a relation (`view:
126
188
  ['edit'], edit: ['view']`) — no data could ever end that loop;
127
189
  - a subject set or an arrow that would have to read **another** object's
128
190
  `fromField` — only the object passed to `can()` carries its data. Store that
129
191
  relation instead.
130
192
 
131
- A loop that crosses a relation — a folder viewable through its parent — is
132
- fine: the data ends it.
193
+ A loop that crosses a relation — a folder viewable through its parents — is
194
+ fine: the data ends it. See [a hierarchy](#a-hierarchy).
195
+
196
+ ## Use cases
197
+
198
+ Each case below runs against the clinic above, in order — except the two that
199
+ say otherwise: a folder tree for a hierarchy, and an `account` for
200
+ permissions on a user.
201
+
202
+ ### A direct relation
133
203
 
134
- ## `fromField`
204
+ A relation is what you store: `grant` writes it, `can` reads it.
135
205
 
136
206
  ```ts
137
- function fromField(field, subjectType): FromField;
138
- function fromField(field, subjectType, { lookup }): ReversibleFromField;
139
- type Lookup = (subjectId: string) => Promise<readonly string[]>;
207
+ await access.grant(team, 'leads', ada);
208
+ await access.can(ada, 'leads', team); // true: a relation may be asked like a permission
209
+ await access.can(grace, 'leads', team); // false
140
210
  ```
141
211
 
142
- A relation read from the object's own data — a record's `doctorId` — rather
143
- than a tuple kept in sync with it. Nothing is stored, and `grant` refuses it at
144
- compile time. `can()` is given the object, and the compiler requires every
145
- field a `fromField` of its type reads:
212
+ ### A permission naming a permission
213
+
214
+ `view: ['members', 'manage']` includes whoever holds `manage`, which is
215
+ `['leads']` — so a lead can view the team without being a member.
146
216
 
147
217
  ```ts
148
- const model = defineModel({
149
- subjects: ['staff'],
150
- types: {
151
- record: {
152
- relations: { doctor: fromField('doctorId', 'staff') },
153
- permissions: { view: ['doctor'] },
218
+ await access.can(ada, 'manage', team); // true: ada leads t1
219
+ await access.can(ada, 'view', team); // true: view includes manage
220
+ ```
221
+
222
+ ### A subject set
223
+
224
+ `members: ['staff', 'team#members']` admits a staff member, or **every member
225
+ of another team** at once. Grant the set with its `relation`:
226
+
227
+ ```ts
228
+ const cardiology = { type: 'team', id: 't2' } as const;
229
+ await access.grant(cardiology, 'members', grace);
230
+ await access.grant(team, 'members', { type: 'team', id: 't2', relation: 'members' });
231
+ await access.can(grace, 'view', team); // true: grace is a member of t2, whose members are members of t1
232
+ ```
233
+
234
+ The set is followed as the data stands: revoke grace from `t2`, and she no
235
+ longer views `t1`. A team member of itself — a cycle in the data — is cut, and
236
+ is not an error.
237
+
238
+ ### An arrow
239
+
240
+ `'teams->view'` is **whoever can view one of the record's teams**; the arrow
241
+ follows the objects the `teams` relation holds, and asks them `view`. Grant
242
+ the record its team, then everyone who views the team views the record:
243
+
244
+ ```ts
245
+ await access.grant({ type: 'record', id: record.id }, 'teams', team);
246
+ await access.can(ada, 'view', { type: 'record', ...record }); // true: ada views t1 (she leads it)
247
+ ```
248
+
249
+ An arrow may end on a relation too: `review: ['teams->leads']` is whoever
250
+ **leads** one of the record's teams — not its members.
251
+
252
+ ```ts
253
+ await access.can(ada, 'review', { type: 'record', ...record }); // true: ada leads t1
254
+ await access.can(grace, 'review', { type: 'record', ...record }); // false: grace is a member, not a lead
255
+ ```
256
+
257
+ An arrow follows **object types only**: through a relation that can hold a
258
+ subject set (`teams: ['team', 'team#members']`), `'teams->view'` is a compile
259
+ error.
260
+
261
+ ### A hierarchy
262
+
263
+ A folder tree, one of the two models on this page besides the clinic: a folder is
264
+ viewable by its owners and by whoever views one of its parents. The arrow
265
+ names the folder's own `view` — a loop in the model, which **the data ends**:
266
+ the walk stops at a folder with no parents.
267
+
268
+ ```ts
269
+ const files = permissions({
270
+ model: defineModel({
271
+ subjects: auth.types,
272
+ types: {
273
+ folder: {
274
+ related: { owners: ['staff'], parents: ['folder'] },
275
+ permits: { view: ['owners', 'parents->view'] },
276
+ },
154
277
  },
155
- },
278
+ }),
279
+ store: createMemoryRelations(),
156
280
  });
157
- const access = permissions({ model, store: createMemoryRelations() });
158
281
 
159
- const record = { id: 'r1', doctorId: grace.id, title: 'Chart' }; // as your database answered it
160
- await access.can(grace, 'view', { type: 'record', ...record });
282
+ const root = { type: 'folder', id: 'root' } as const;
283
+ const reports = { type: 'folder', id: 'reports' } as const;
284
+ await files.grant(root, 'owners', grace);
285
+ await files.grant(reports, 'parents', root);
286
+ await files.can(grace, 'view', reports); // true: she owns its parent
161
287
  ```
162
288
 
163
- **Spread the loaded object.** A field missing at run time is a `TypeError`,
164
- never a denial. `null` in the field holds nobody.
289
+ A deep tree crosses one relation per level; past `maxDepth` (`25`) the check
290
+ is `PERMISSION_DEPTH`, never `false`.
165
291
 
166
- `list()` cannot read a field of objects it has not found, so it asks `lookup`
167
- for the ids of the objects whose field names the subject. A `list()` that would
168
- reach a `fromField` without one is a compile error:
292
+ ### A relation read from the object
169
293
 
170
294
  ```ts
171
- fromField('doctorId', 'staff', { lookup: (staffId) => db.records.ids({ doctorId: staffId }) });
295
+ function fromField(field, subjectType): FromField;
296
+ function fromField(field, subjectType, { lookup }): ReversibleFromField;
297
+ type Lookup = (subjectId: string) => Promise<readonly string[]>;
172
298
  ```
173
299
 
300
+ `doctors: fromField('doctorId', 'staff')` reads the record's own `doctorId`
301
+ rather than a tuple kept in sync with it. Nothing is stored, and `grant`
302
+ refuses it at compile time. `can()` is given the object, and the compiler
303
+ requires every field a `fromField` of its type reads — here `doctorId` and
304
+ `patientId`:
305
+
306
+ ```ts
307
+ await access.can(grace, 'view', { type: 'record', ...record }); // true: record.doctorId is grace's id
308
+ // @ts-expect-error — doctors is read from a field: there is nothing to grant
309
+ await access.grant({ type: 'record', id: record.id }, 'doctors', grace);
310
+ ```
311
+
312
+ **Spread the loaded object.** A field missing at run time is a `TypeError`,
313
+ never a denial. `null` in the field holds nobody.
314
+
315
+ **`list()` needs a `lookup`.** It cannot read a field of objects it has not
316
+ found, so it asks `lookup` for the ids of the objects whose field names the
317
+ subject — in the clinic, `db.records.idsByDoctor(staffId)`. A `list()` that
318
+ would reach a `fromField` without one is a compile error, and your editor does
319
+ not offer that permission.
320
+
174
321
  A lookup is your code, and it is not guarded: one that throws rejects `list()`
175
322
  with its own error. **Never answer `[]` for a database that could not answer**
176
323
  — that is a denial made of an outage.
177
324
 
178
- ## `when`
325
+ ### A condition
179
326
 
180
327
  ```ts
181
328
  function when<const Rule extends string, Ctx>(rule: Rule, test: (ctx: Ctx) => boolean): When<Rule, Ctx>;
182
329
  ```
183
330
 
184
- Puts a condition written in TypeScript on a rule — any rule of the same type:
185
- a relation, a permission, an arrow. The test is synchronous and pure: it
186
- decides on what the caller passes, and reads nothing. Its `ctx` is what `can()`
187
- and `list()` then **require**, and only for the permissions whose rules reach
188
- it:
331
+ `edit: [when('doctors', (ctx: { onShift: boolean }) => ctx.onShift)]` grants
332
+ the record's doctors, **while on shift**. The rule is any rule of the same
333
+ type — a relation, a permission, an arrow. The test is synchronous and pure:
334
+ it decides on what the caller passes, and reads nothing. Its `ctx` is what
335
+ `can()` and `list()` then **require** — and only for the permissions whose
336
+ rules reach it:
189
337
 
190
338
  ```ts
191
- const model = defineModel({
192
- subjects: ['staff'],
193
- types: {
194
- record: {
195
- relations: { doctor: fromField('doctorId', 'staff') },
196
- permissions: {
197
- edit: [when('doctor', (ctx: { onShift: boolean }) => ctx.onShift)],
339
+ await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: true } }); // true
340
+ await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: false } }); // false
341
+ // @ts-expect-error — ctx is required: edit reaches a condition
342
+ await access.can(grace, 'edit', { type: 'record', ...record });
343
+
344
+ await access.can(grace, 'view', { type: 'record', ...record }); // no ctx: view reaches no condition
345
+ await access.list(grace, 'edit', 'record', { ctx: { onShift: true } });
346
+ ```
347
+
348
+ ### Permissions on a user
349
+
350
+ A user type cannot also be an object type: `defineModel` refuses `staff` in
351
+ `types` — `"staff" names a user type and an object type`. To decide who may
352
+ edit a staff member's account, declare an object type **whose id is the
353
+ user's id**, and read it with `fromField('id', …)`:
354
+
355
+ ```ts
356
+ const accounts = permissions({
357
+ model: defineModel({
358
+ subjects: auth.types,
359
+ types: {
360
+ account: {
361
+ related: {
362
+ self: fromField('id', 'staff', { lookup: async (staffId) => [staffId] }),
363
+ managers: ['staff'],
364
+ },
365
+ permits: { edit: ['self', 'managers'] },
198
366
  },
199
367
  },
200
- },
368
+ }),
369
+ store: relations,
201
370
  });
202
- const access = permissions({ model, store: createMemoryRelations() });
203
371
 
204
- await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: true } });
205
- // @ts-expect-error — ctx is required: edit reaches a condition
206
- await access.can(grace, 'edit', { type: 'record', ...record });
372
+ const bob = await auth.staff.create({ username: 'bob' });
373
+ await accounts.can(bob, 'edit', { type: 'account', id: bob.id }); // true: his own account
374
+ await accounts.can(ada, 'edit', { type: 'account', id: bob.id }); // false
375
+ await accounts.grant({ type: 'account', id: bob.id }, 'managers', ada);
376
+ await accounts.can(ada, 'edit', { type: 'account', id: bob.id }); // true: she manages it
377
+ await accounts.list(ada, 'edit', 'account'); // her own account, and bob's
207
378
  ```
208
379
 
380
+ The `lookup` answers the one account whose id is the subject's, so `list()`
381
+ finds a user's own account too.
382
+
209
383
  ## `permissions()`
210
384
 
211
385
  ```ts
@@ -234,10 +408,14 @@ can(subject, permission, object, options?): Promise<boolean>;
234
408
  - `object` is `{ type, id }` plus every field its `fromField`s read.
235
409
  - `options.ctx` is required exactly when a `when` is reachable.
236
410
 
411
+ Your editor completes `permission` with the names of the object's type once
412
+ the object is written. **Before, it offers the names of every type**: the
413
+ permission comes before the object, and nothing yet says which type it is.
414
+
237
415
  ```ts
238
- await access.can(grace, 'view', { type: 'record', ...record }); // true
239
- await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: false } }); // false
240
- await access.can(null, 'view', { type: 'record', ...record }); // false, no store call
416
+ await access.can(grace, 'view', { type: 'record', ...record }); // true
417
+ await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: false } }); // false
418
+ await access.can(null, 'view', { type: 'record', ...record }); // false, no store call
241
419
  ```
242
420
 
243
421
  **A denial is `false`; a failure throws.** A store that cannot answer is
@@ -254,16 +432,32 @@ list(subject, permission, objectType, options?): Promise<CursorPage<string>>;
254
432
 
255
433
  The ids of the objects of `objectType` on which `subject` holds `permission`,
256
434
  ascending, by pages — what `can()` answers `true` for, found without naming
257
- them.
435
+ them. Page with `limit` and `after`, passing each `nextCursor` back as it
436
+ came, until it is `null`:
258
437
 
259
438
  ```ts
260
439
  const page = await access.list(grace, 'view', 'record', { limit: 50 });
261
- page.items; // readonly string[]
440
+ page.items; // readonly string[]: the records grace can view — hers, and her teams'
262
441
  page.nextCursor; // string | null
263
442
  await access.list(grace, 'view', 'record', { after: page.nextCursor, limit: 50 });
443
+
444
+ // Every page — annotate the page, or TypeScript cannot type the loop (TS7022):
445
+ let after: string | null = null;
446
+ do {
447
+ const next: CursorPage<string> = await access.list(grace, 'view', 'record', { after, limit: 100 });
448
+ for (const id of next.items) console.log(id);
449
+ after = next.nextCursor;
450
+ } while (after);
264
451
  ```
265
452
 
266
- `limit` is 20 by default and at most 100; `ctx` is required as for `can`.
453
+ `CursorPage` is exported from `@nxgt/janus`.
454
+
455
+ `limit` is 20 by default and at most 100 — a larger one is capped, and one
456
+ that is not a positive integer is a `TypeError`, so parse a limit read from a
457
+ request first ([troubleshooting](../troubleshooting.md#call-limit-must-be-an-integer-of-at-least-1-or-absent)).
458
+ `ctx` is required as for `can` — `list(grace, 'edit', 'record', { ctx })`.
459
+ Your editor completes `permission` with what `list()` can answer only: a name
460
+ reaching a `fromField` with no `lookup` is neither offered nor accepted.
267
461
  `null` answers an empty page before any store call.
268
462
 
269
463
  `list()` walks backwards from the subject, reading every page of the reverse
@@ -279,7 +473,18 @@ revoke(object, relation, subject): Promise<void>;
279
473
  ```
280
474
 
281
475
  Both are typed from the model: only a stored relation (never a `fromField`),
282
- and only a holder the relation admits.
476
+ and only a holder the relation admits — a staff member, or a team's members
477
+ where `'team#members'` is declared.
478
+
479
+ ```ts
480
+ await access.grant(team, 'members', grace); // a staff member
481
+ await access.grant(team, 'members', { type: 'team', id: 't2', relation: 'members' }); // a subject set
482
+ await access.grant({ type: 'record', id: 'r1' }, 'teams', team); // what 'teams->view' follows
483
+ await access.revoke(team, 'members', grace);
484
+ ```
485
+
486
+ Both are idempotent: granting what is held, or revoking what is not, is not an
487
+ error. Each writes one tuple.
283
488
 
284
489
  The same rule holds when reading: `can()` and `list()` follow only the holders
285
490
  a relation admits. A tuple stored past `grant()` — by an older model, or by
@@ -288,36 +493,21 @@ it refuses to `grant()` it, so remove it with the store:
288
493
 
289
494
  ```ts
290
495
  await relations.write({
291
- remove: [
292
- {
293
- object: { type: 'record', id: 'r1' },
294
- relation: 'viewer',
295
- subject: { type: 'team', id: 't1' },
296
- },
297
- ],
496
+ remove: [{ object: { type: 'record', id: 'r1' }, relation: 'teams', subject: { type: 'team', id: 't1' } }],
298
497
  });
299
498
  ```
300
499
 
301
500
  Narrowing a model therefore hides the tuples it no longer admits; it does not
302
501
  delete them, and widening it again brings them back.
303
502
 
304
- ```ts
305
- await access.grant({ type: 'team', id: 't1' }, 'member', grace);
306
- await access.grant({ type: 'team', id: 't1' }, 'member', { type: 'team', id: 't2', relation: 'member' }); // a subject set
307
- await access.grant({ type: 'record', id: 'r1' }, 'team', { type: 'team', id: 't1' }); // what 'team->view' follows
308
- await access.revoke({ type: 'team', id: 't1' }, 'member', grace);
309
- ```
310
-
311
- Both are idempotent: granting what is held, or revoking what is not, is not an
312
- error. Each writes one tuple.
313
-
314
503
  ### Deleting
315
504
 
316
- Wire the relation store into `janus({ relations })`, and deleting a user
317
- deletes every tuple naming them. Deleting an object's tuples is yours, from
318
- your own code, when you delete the object:
505
+ Wire the relation store into `janus({ relations })`, as the clinic does, and
506
+ deleting a user deletes every tuple naming them. Deleting an object's tuples
507
+ is yours, from your own code, when you delete the object:
319
508
 
320
509
  ```ts
510
+ await auth.staff.delete(grace); // grace, her sessions, and every tuple naming her
321
511
  await relations.deleteEntity({ type: 'record', id: 'r1' }); // answers how many tuples it removed
322
512
  ```
323
513
 
@@ -343,11 +533,16 @@ export async function getRecord(request: Request, id: string): Promise<Response>
343
533
  }
344
534
  ```
345
535
 
536
+ Load the record first: `can()` needs its `doctorId` and `patientId`. An
537
+ anonymous caller is `null`, which `can()` answers `false` without a store call
538
+ — a `401` here, a `403` for a signed-in one.
539
+
346
540
  ## Subjects and the notation
347
541
 
348
542
  A user **is** a subject: `subjectOf(user)` from `@nxgt/janus` answers its
349
543
  `{ type, id }`, and `can` takes the user as it is. Tuples print in Zanzibar's
350
- notation, typed — see [the shared vocabulary](vocabulary.md#subjects-and-the-tuple-notation).
544
+ notation, typed — `record:r1#teams@team:t1`, `team:t1#members@team:t2#members`
545
+ — see [the shared vocabulary](vocabulary.md#subjects-and-the-tuple-notation).
351
546
 
352
547
  ## The relation store
353
548
 
@@ -362,3 +557,4 @@ rest.
362
557
  - [Users](users.md) — `janus({ relations })`, and user types as subject types
363
558
  - [Errors](errors.md) — `STORE_FAILED` and `PERMISSION_DEPTH`
364
559
  - [Writing an adapter](adapters.md) — `RelationStore` and its conformance suite
560
+ - [Troubleshooting](../troubleshooting.md#definemodel-) — every `defineModel` message