@nxgt/janus 0.1.3 → 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,32 +87,38 @@ 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) |
88
120
 
89
- A rule may name another permission of the same type — `view: ['member',
121
+ A rule may name another permission of the same type — `view: ['members',
90
122
  'manage']` — but never the permission itself: `view: ['view']` adds nothing
91
123
  and is a loop no relation ends, so your editor does not offer it and the
92
124
  compiler refuses it. A loop through another permission (`view: ['edit'],
@@ -98,121 +130,256 @@ like a permission.
98
130
  ### What the compiler refuses
99
131
 
100
132
  A relation naming a type that does not exist, a rule naming nothing, an arrow
101
- to a permission its target lacks, a name that is both a relation and a
102
- permission, a key other than `relations` and `permissions` — `permission:`,
103
- singular — on an object type: each is a compile error **on the offending name** — on the whole
104
- `fromField(…)` or `when(…)` call for those two. Except for that last one,
105
- which says to rename one, the error lists what you could have written, with
106
- "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.
107
140
 
108
- Your editor offers those names as you type — subject types and subject sets
109
- in a relation, subject types in `fromField`, relations, permissions and arrows
110
- in a rule and in `when` — because `defineModel` types its `types` with a
111
- constraint an editor reads, not only with a check. A spec asks the TypeScript
112
- 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:
113
155
 
114
156
  ```ts
115
157
  defineModel({
116
158
  subjects: ['staff'],
117
159
  types: {
118
160
  team: {
119
- // @ts-expect-error — Type '"staf"' is not assignable to type '"staff" | "team" | "team#member"'. Did you mean '"staff"'?
120
- 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'] },
121
163
  },
122
164
  },
123
165
  });
124
166
  ```
125
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
+
126
177
  ### What `defineModel` refuses at run time
127
178
 
128
- 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`:
129
181
 
182
+ - `relations` or `permissions` — the keys before 0.2 — and any key other than
183
+ `related` and `permits`;
130
184
  - a name that is not camelCase;
131
- - 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);
132
187
  - a permission that reaches itself without crossing a relation (`view:
133
188
  ['edit'], edit: ['view']`) — no data could ever end that loop;
134
189
  - a subject set or an arrow that would have to read **another** object's
135
190
  `fromField` — only the object passed to `can()` carries its data. Store that
136
191
  relation instead.
137
192
 
138
- A loop that crosses a relation — a folder viewable through its parent — is
139
- 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
140
203
 
141
- ## `fromField`
204
+ A relation is what you store: `grant` writes it, `can` reads it.
142
205
 
143
206
  ```ts
144
- function fromField(field, subjectType): FromField;
145
- function fromField(field, subjectType, { lookup }): ReversibleFromField;
146
- 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
147
210
  ```
148
211
 
149
- A relation read from the object's own data — a record's `doctorId` — rather
150
- than a tuple kept in sync with it. Nothing is stored, and `grant` refuses it at
151
- compile time. `can()` is given the object, and the compiler requires every
152
- 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.
153
216
 
154
217
  ```ts
155
- const model = defineModel({
156
- subjects: ['staff'],
157
- types: {
158
- record: {
159
- relations: { doctor: fromField('doctorId', 'staff') },
160
- 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
+ },
161
277
  },
162
- },
278
+ }),
279
+ store: createMemoryRelations(),
163
280
  });
164
- const access = permissions({ model, store: createMemoryRelations() });
165
281
 
166
- const record = { id: 'r1', doctorId: grace.id, title: 'Chart' }; // as your database answered it
167
- 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
168
287
  ```
169
288
 
170
- **Spread the loaded object.** A field missing at run time is a `TypeError`,
171
- 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`.
291
+
292
+ ### A relation read from the object
172
293
 
173
- `list()` cannot read a field of objects it has not found, so it asks `lookup`
174
- for the ids of the objects whose field names the subject. A `list()` that would
175
- reach a `fromField` without one is a compile error:
294
+ ```ts
295
+ function fromField(field, subjectType): FromField;
296
+ function fromField(field, subjectType, { lookup }): ReversibleFromField;
297
+ type Lookup = (subjectId: string) => Promise<readonly string[]>;
298
+ ```
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`:
176
305
 
177
306
  ```ts
178
- fromField('doctorId', 'staff', { lookup: (staffId) => db.records.ids({ doctorId: staffId }) });
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);
179
310
  ```
180
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
+
181
321
  A lookup is your code, and it is not guarded: one that throws rejects `list()`
182
322
  with its own error. **Never answer `[]` for a database that could not answer**
183
323
  — that is a denial made of an outage.
184
324
 
185
- ## `when`
325
+ ### A condition
186
326
 
187
327
  ```ts
188
328
  function when<const Rule extends string, Ctx>(rule: Rule, test: (ctx: Ctx) => boolean): When<Rule, Ctx>;
189
329
  ```
190
330
 
191
- Puts a condition written in TypeScript on a rule — any rule of the same type:
192
- a relation, a permission, an arrow. The test is synchronous and pure: it
193
- decides on what the caller passes, and reads nothing. Its `ctx` is what `can()`
194
- and `list()` then **require**, and only for the permissions whose rules reach
195
- 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:
196
337
 
197
338
  ```ts
198
- const model = defineModel({
199
- subjects: ['staff'],
200
- types: {
201
- record: {
202
- relations: { doctor: fromField('doctorId', 'staff') },
203
- permissions: {
204
- 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'] },
205
366
  },
206
367
  },
207
- },
368
+ }),
369
+ store: relations,
208
370
  });
209
- const access = permissions({ model, store: createMemoryRelations() });
210
371
 
211
- await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: true } });
212
- // @ts-expect-error — ctx is required: edit reaches a condition
213
- 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
214
378
  ```
215
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
+
216
383
  ## `permissions()`
217
384
 
218
385
  ```ts
@@ -243,14 +410,12 @@ can(subject, permission, object, options?): Promise<boolean>;
243
410
 
244
411
  Your editor completes `permission` with the names of the object's type once
245
412
  the object is written. **Before, it offers the names of every type**: the
246
- permission comes before the object, and nothing yet says which type it is. A
247
- relation named after a type — `team: ['team']` — is offered as a name; it is
248
- the relation, not the type.
413
+ permission comes before the object, and nothing yet says which type it is.
249
414
 
250
415
  ```ts
251
- await access.can(grace, 'view', { type: 'record', ...record }); // true
252
- await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: false } }); // false
253
- 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
254
419
  ```
255
420
 
256
421
  **A denial is `false`; a failure throws.** A store that cannot answer is
@@ -267,19 +432,30 @@ list(subject, permission, objectType, options?): Promise<CursorPage<string>>;
267
432
 
268
433
  The ids of the objects of `objectType` on which `subject` holds `permission`,
269
434
  ascending, by pages — what `can()` answers `true` for, found without naming
270
- them.
435
+ them. Page with `limit` and `after`, passing each `nextCursor` back as it
436
+ came, until it is `null`:
271
437
 
272
438
  ```ts
273
439
  const page = await access.list(grace, 'view', 'record', { limit: 50 });
274
- page.items; // readonly string[]
440
+ page.items; // readonly string[]: the records grace can view — hers, and her teams'
275
441
  page.nextCursor; // string | null
276
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);
277
451
  ```
278
452
 
453
+ `CursorPage` is exported from `@nxgt/janus`.
454
+
279
455
  `limit` is 20 by default and at most 100 — a larger one is capped, and one
280
456
  that is not a positive integer is a `TypeError`, so parse a limit read from a
281
457
  request first ([troubleshooting](../troubleshooting.md#call-limit-must-be-an-integer-of-at-least-1-or-absent)).
282
- `ctx` is required as for `can`.
458
+ `ctx` is required as for `can` — `list(grace, 'edit', 'record', { ctx })`.
283
459
  Your editor completes `permission` with what `list()` can answer only: a name
284
460
  reaching a `fromField` with no `lookup` is neither offered nor accepted.
285
461
  `null` answers an empty page before any store call.
@@ -297,7 +473,18 @@ revoke(object, relation, subject): Promise<void>;
297
473
  ```
298
474
 
299
475
  Both are typed from the model: only a stored relation (never a `fromField`),
300
- 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.
301
488
 
302
489
  The same rule holds when reading: `can()` and `list()` follow only the holders
303
490
  a relation admits. A tuple stored past `grant()` — by an older model, or by
@@ -306,36 +493,21 @@ it refuses to `grant()` it, so remove it with the store:
306
493
 
307
494
  ```ts
308
495
  await relations.write({
309
- remove: [
310
- {
311
- object: { type: 'record', id: 'r1' },
312
- relation: 'viewer',
313
- subject: { type: 'team', id: 't1' },
314
- },
315
- ],
496
+ remove: [{ object: { type: 'record', id: 'r1' }, relation: 'teams', subject: { type: 'team', id: 't1' } }],
316
497
  });
317
498
  ```
318
499
 
319
500
  Narrowing a model therefore hides the tuples it no longer admits; it does not
320
501
  delete them, and widening it again brings them back.
321
502
 
322
- ```ts
323
- await access.grant({ type: 'team', id: 't1' }, 'member', grace);
324
- await access.grant({ type: 'team', id: 't1' }, 'member', { type: 'team', id: 't2', relation: 'member' }); // a subject set
325
- await access.grant({ type: 'record', id: 'r1' }, 'team', { type: 'team', id: 't1' }); // what 'team->view' follows
326
- await access.revoke({ type: 'team', id: 't1' }, 'member', grace);
327
- ```
328
-
329
- Both are idempotent: granting what is held, or revoking what is not, is not an
330
- error. Each writes one tuple.
331
-
332
503
  ### Deleting
333
504
 
334
- Wire the relation store into `janus({ relations })`, and deleting a user
335
- deletes every tuple naming them. Deleting an object's tuples is yours, from
336
- 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:
337
508
 
338
509
  ```ts
510
+ await auth.staff.delete(grace); // grace, her sessions, and every tuple naming her
339
511
  await relations.deleteEntity({ type: 'record', id: 'r1' }); // answers how many tuples it removed
340
512
  ```
341
513
 
@@ -361,11 +533,16 @@ export async function getRecord(request: Request, id: string): Promise<Response>
361
533
  }
362
534
  ```
363
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
+
364
540
  ## Subjects and the notation
365
541
 
366
542
  A user **is** a subject: `subjectOf(user)` from `@nxgt/janus` answers its
367
543
  `{ type, id }`, and `can` takes the user as it is. Tuples print in Zanzibar's
368
- 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).
369
546
 
370
547
  ## The relation store
371
548
 
@@ -380,3 +557,4 @@ rest.
380
557
  - [Users](users.md) — `janus({ relations })`, and user types as subject types
381
558
  - [Errors](errors.md) — `STORE_FAILED` and `PERMISSION_DEPTH`
382
559
  - [Writing an adapter](adapters.md) — `RelationStore` and its conformance suite
560
+ - [Troubleshooting](../troubleshooting.md#definemodel-) — every `defineModel` message