@uniweb/api 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,8 +53,9 @@ function Account() {
53
53
 
54
54
  `viewer` is flat — `viewer.uuid`, `viewer.username`, `viewer.handle`, and `viewer.roles`,
55
55
  a list of `{ role, scope_unit_id }`. A member holds no roles (`[]`); the site's operator
56
- holds `system_admin`. ⚠️ `viewer.actingUnitId` is the same for every member of a site, so
57
- it cannot tell an operator from a member. To decide whether to show an edit control for
56
+ holds `system_admin`. ⚠️ `viewer.workspace` — the site service's home org,
57
+ `{ handle: 'home', … }` — is the same for every member, so it cannot tell an operator
58
+ from a member. To decide whether to show an edit control for
58
59
  a particular record, read that record: `entity.can_edit` is the backend's own answer.
59
60
 
60
61
  **Signing up** — `useSignUp().signUp({ username, email, password })` answers
@@ -83,9 +84,10 @@ live source — no `api` service, or nobody signed in — so render your site's
83
84
  Showing "nothing yet" for the first case tells a visitor their content is missing
84
85
  when it is simply not being asked for.
85
86
 
86
- ⚠️ **Whose records.** The default `scope` is everything the viewer may read, and on a
87
- site's `api` service **members can read each other's entities**. For "my submissions",
88
- "my progress", pass `scope: 'mine'`.
87
+ **Whose records.** A member reads their own entities and what was shared with them;
88
+ other members' are private unless the site's service was set up to let members read
89
+ each other's. The site's operator reads everything. `scope: 'mine'` lists only the
90
+ viewer's own.
89
91
 
90
92
  A record is a summary: `record.brief` holds the fields of the Model's brief section. It
91
93
  carries no items — read the entity for those. `hasMore` is true when a page came back
@@ -202,9 +204,11 @@ will in production, and your site's configuration is identical either way.
202
204
  refusals, for every route this package uses. It enforces what your schemas declare —
203
205
  who may create entries, which sections are insert-only, the fields a section takes —
204
206
  and it is as strict as the backend about requests, so a mistake fails here rather
205
- than in front of a user. Members read each other's entities and write their own; the
206
- operator writes everything. A new sign-up must be verified: the link is in
207
- `mock.outbox` (the standalone server prints it). State is in memory: restart to reset.
207
+ than in front of a user. Members read and write their own entities and nothing of each
208
+ other's; the operator reads and writes everything (`memberFloor: 'read'` in the seed
209
+ models a service set up to let members read each other's). A new sign-up must be
210
+ verified: the link is in `mock.outbox` (the standalone server prints it). State is in
211
+ memory: restart to reset.
208
212
 
209
213
  There is also a standalone server, for a frontend that is not a Uniweb site:
210
214
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/api",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "The client for a Uniweb site's own backend — session, records, entities — imported by a foundation the way it imports @uniweb/kit.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,7 +34,7 @@
34
34
  "node": ">=20.19"
35
35
  },
36
36
  "dependencies": {
37
- "@uniweb/core": "^0.29.4"
37
+ "@uniweb/core": "^0.29.8"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "react": "^19.0.0"
package/src/client.js CHANGED
@@ -93,6 +93,16 @@ const LOADING = Object.freeze({ status: 'loading', viewer: null, error: null })
93
93
  * from `website.config` on each call, so the editor's `Website.rebuild()`
94
94
  * needs no hook here.
95
95
  */
96
+
97
+ /**
98
+ * `/auth/me`'s `workspace` — `{ unit_uuid, handle }`, both null when none — as the
99
+ * viewer carries it: `{ unitUuid, handle }`, or `null` for none (`VIEWER`).
100
+ */
101
+ function viewerWorkspace(ws) {
102
+ if (!ws || typeof ws !== 'object' || (ws.unit_uuid == null && ws.handle == null)) return null
103
+ return Object.freeze({ unitUuid: ws.unit_uuid ?? null, handle: ws.handle ?? null })
104
+ }
105
+
96
106
  export class ApiClient {
97
107
  /**
98
108
  * @param {object} uniweb - the page's `Uniweb` singleton
@@ -307,7 +317,7 @@ export class ApiClient {
307
317
  const viewer = Object.freeze({
308
318
  ...account,
309
319
  roles: Array.isArray(me?.roles) ? me.roles : [],
310
- actingUnitId: me?.acting_unit_id ?? null,
320
+ workspace: viewerWorkspace(me?.workspace),
311
321
  })
312
322
  if (this._session.viewer && this._session.viewer.uuid !== viewer.uuid) this.forgetViewer()
313
323
  return this.setSession({ status: 'authenticated', viewer, error: null })
@@ -567,9 +577,9 @@ export class ApiClient {
567
577
  * there is nothing to list — the backend answers `401`, and a lapsed session is
568
578
  * a `401` too, never an empty list (so `records: []` means empty).
569
579
  *
570
- * ⚠️ **`scope` decides whose.** The default, `accessible`, is everything the
571
- * viewer may read — on a site's `api` service that is every member's entities
572
- * of the Model, not only the viewer's. `mine` is only what the viewer owns.
580
+ * `scope` decides whose: the default, `accessible`, is everything the viewer may
581
+ * read — for a member of a site, their own entities and what was shared with them;
582
+ * for the operator, everything. `mine` is only what the viewer owns.
573
583
  *
574
584
  * ## Paging, as far as it can honestly be absorbed
575
585
  *
@@ -701,9 +711,11 @@ export class ApiClient {
701
711
  * error is thrown. Retrying automatically would succeed by overwriting a change
702
712
  * nobody looked at. ⇒ We remove the *bookkeeping* and leave the *decision*.
703
713
  *
704
- * ⚠️ **The stale `409` does not say which item was stale.** For one guarded op
705
- * that is the op's item; in a batch of several it is unknowable, and rebasing a
706
- * guessed item would put one item's token on another — so a batch is not rebased.
714
+ * The stale `409` names its item (`item_id`), and that is the item rebased — in a
715
+ * batch, which stops at its first stale op, the one that was stale. ⚠️ **A backend
716
+ * from before 2026-09-18 names none.** Then a single guarded op is rebased on its
717
+ * own item, and a batch is not rebased at all: a guessed item would put one item's
718
+ * token on another.
707
719
  *
708
720
  * @param {object} args
709
721
  * @param {string} args.schema - the entity's Model
@@ -34,8 +34,9 @@ const DISABLED = Object.freeze({ status: 'absent', records: NONE, matched: 0, ha
34
34
  * entity for those. ⚠️ `matched` counts the records in this answer, not a total:
35
35
  * `hasMore` is true when the page came back full.
36
36
  *
37
- * ⚠️ The default scope is everything the viewer may read, which on a site's `api`
38
- * service includes other members' entities. `scope: 'mine'` is only the viewer's.
37
+ * The default scope is everything the viewer may read — for a member, their own
38
+ * entities and what was shared with them; for the operator, everything.
39
+ * `scope: 'mine'` is only the viewer's own.
39
40
  *
40
41
  * Cached under a key scoped to the viewer, so a sign-in re-reads the list for who
41
42
  * is now looking, and a write through `useEntityWriter` drops it.
package/src/mock/index.js CHANGED
@@ -36,8 +36,9 @@ import { DEFAULT_SEED } from './seed.js'
36
36
  *
37
37
  * ## What it models, and what it does not
38
38
  *
39
- * One operator (`operator: true` on a seeded account) and members; every
40
- * signed-in account reads every entity, the owner and the operator write it;
39
+ * One operator (`operator: true` on a seeded account) and members; a member reads
40
+ * and writes their own entities and nothing of another's (unless the seed's
41
+ * `memberFloor` says the service lets them), the operator reads and writes all;
41
42
  * `creatable_by: 'unit_members'` Models are created by the operator only; sign-up
42
43
  * leaves an account unverified until the link in `mock.outbox` is followed. It
43
44
  * does not model entitlements behind `via`, nested sections, or second factors.
@@ -323,7 +324,8 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
323
324
  if (!b || !Array.isArray(b.uuids)) return badRequest('invalid batch body: missing field `uuids`')
324
325
  const invalid = b.uuids.find((u) => !isUuid(u))
325
326
  if (invalid) return badRequest(`invalid uuid: ${invalid}`)
326
- const entities = b.uuids.map((u) => store.entities.get(u)).filter(Boolean)
327
+ // Unreadable ones drop, like missing ones — the batch shows what the caller can see.
328
+ const entities = b.uuids.map((u) => store.entities.get(u)).filter((e) => e && store.mayRead(e))
327
329
  return json(200, { entities: entities.map((e) => store.read(e, { canEdit: false })) })
328
330
  }
329
331
 
@@ -369,8 +371,9 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
369
371
  if (via && !isUuid(via)) return badRequest(`invalid via uuid: ${via}`)
370
372
  const found = target(one[1], q.get(PARAM.model))
371
373
  if (found.response) return found.response
372
- // The mock models no entitlements: a `via` read is the same read, once the
373
- // container exists. Not found and not permitted are one answer, as ever.
374
+ // Not found and not permitted are one answer. The mock models no entitlements:
375
+ // a `via` read is the same read, once the container exists.
376
+ if (!store.mayRead(found.entity)) return notFound('entity', one[1])
374
377
  if (via && !store.entities.has(via)) return notFound('entity', one[1])
375
378
  return json(200, store.read(found.entity, { locales: locales(q), withItems: depth === 'shallow' || depth === 'deep' }))
376
379
  }
package/src/mock/store.js CHANGED
@@ -13,13 +13,16 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
13
13
  * is the entity's `brief`, which the backend maintains — derived here on read.
14
14
  * - **Ids are integers, entities are addressed by UUID.** A section, an item, an
15
15
  * entity and an account each have an integer id; an entity's path id is its UUID.
16
- * - **Every signed-in account reads every entity.** On a site's `api` service
17
- * every member acts in the site's one unit, and a member of it may read what is
18
- * in it — so members read each other's entities. Writing is for the owner and
19
- * the operator.
16
+ * - **A member reads and writes their own entities, and nothing of another's.**
17
+ * Every member of a site acts in the site's one unit, and membership of it
18
+ * conveys nothing by itself: another member's entity is private to its owner
19
+ * unless shared. A service can be set up to let members read (or edit) each
20
+ * other's — the seed's `memberFloor` models that choice. *(Until 2026-09-18 the
21
+ * backend gave every member read access to every other member's entities.)*
20
22
  * - **The operator** — the account that runs the site's service — holds
21
23
  * `system_admin`, creates the Models only the operator may, and may write
22
- * anything. A seeded account is the operator with `operator: true`.
24
+ * anything. It is a member of the site's unit like everyone else, so what it
25
+ * makes is its own. A seeded account is the operator with `operator: true`.
23
26
  *
24
27
  * ⛔ **Seeded fixtures plus in-memory mutation, and deliberately not a database.**
25
28
  * A mock that grows a schema and migrations becomes a second implementation that
@@ -29,9 +32,20 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
29
32
  * same read), nested sections, second factors, reference fields.
30
33
  */
31
34
 
32
- /** The unit every account of a site acts in — `acting_unit_id` on `/auth/me`. */
35
+ /** The unit every account of a site acts in — an entity's `unit_id`. */
33
36
  export const SITE_UNIT = 1
34
37
 
38
+ /**
39
+ * The service's home org — `workspace` on `/auth/me`. A site's service is founded with
40
+ * one, handle `home`, and enrolls every account in it, the owner included, so a request
41
+ * that names no workspace lands there. Its own value: it is minted in the service's
42
+ * database, and has nothing to do with the workspace the site was authored in.
43
+ */
44
+ export const HOME_ORG = Object.freeze({
45
+ unit_uuid: '01926d5e-0000-7000-8000-0000000000a0',
46
+ handle: 'home',
47
+ })
48
+
35
49
  /** The gap between neighbouring items' order numbers, as the backend spaces them. */
36
50
  const GAP = 1_000_000
37
51
 
@@ -85,6 +99,8 @@ export class MockStore {
85
99
  * seed's items and `append_only` use.
86
100
  * @param {object[]} [seed.entities] - `{ uuid?, model, owner?, items?: [{ section, data, parent? }] }`.
87
101
  * `uuid` must be a UUID. (`data`, the older spelling, becomes the brief section's item.)
102
+ * @param {'read'|'edit'} [seed.memberFloor] - what membership lets one member do to
103
+ * ANOTHER member's entities. Absent — the default — nothing: each member's are private.
88
104
  * @param {object} [options]
89
105
  * @param {string} [options.signedInAs] - start with this account already signed in.
90
106
  */
@@ -95,6 +111,11 @@ export class MockStore {
95
111
  for (const a of seed.accounts || []) this.addAccount(a, { verified: true })
96
112
 
97
113
  this.schemas = seed.schemas || {}
114
+ if (seed.memberFloor != null && !['read', 'edit'].includes(seed.memberFloor)) {
115
+ throw new Error(`[uniweb/api mock] memberFloor is 'read' or 'edit', or absent — got ${JSON.stringify(seed.memberFloor)}`)
116
+ }
117
+ /** What membership lets one member do to another's entities: `null` (nothing), `read` or `edit`. */
118
+ this.memberFloor = seed.memberFloor ?? null
98
119
  this.models = new Map()
99
120
  const itemSections = new Map()
100
121
  for (const e of seed.entities || []) {
@@ -159,7 +180,9 @@ export class MockStore {
159
180
  return {
160
181
  account: this.identity(account),
161
182
  roles: account.operator ? [{ role: 'system_admin', scope_unit_id: null }] : [],
162
- acting_unit_id: SITE_UNIT,
183
+ // A request that names no workspace — as this package's never do — lands in the
184
+ // home org, which every account belongs to. Both null would mean a non-member.
185
+ workspace: { ...HOME_ORG },
163
186
  }
164
187
  }
165
188
 
@@ -406,16 +429,29 @@ export class MockStore {
406
429
  return siblings[siblings.length - 1].order_number + GAP
407
430
  }
408
431
 
432
+ /** The operator, the owner — or any member, when the service lets members read each other's. */
433
+ mayRead(entity) {
434
+ const me = this.account
435
+ return !!me && (me.operator || entity.owner_id === me.id || this.memberFloor != null)
436
+ }
437
+
409
438
  mayEdit(entity) {
410
439
  const me = this.account
411
- return !!me && (me.operator || entity.owner_id === me.id)
440
+ return !!me && (me.operator || entity.owner_id === me.id || this.memberFloor === 'edit')
412
441
  }
413
442
 
414
- /** Which branch lets the viewer read a row, as the backend names it. */
443
+ /**
444
+ * Which branch lets the viewer read a row, as the backend names it — the first to
445
+ * answer in its order: `owner`, `grant`, `rbac`, `unit_member`. The operator's own rows
446
+ * are `owner` like anyone's, and the rest it reads as `system_admin`. *(Until
447
+ * 2026-09-18 the operator was not a member of the site's unit, and ownership speaks
448
+ * only for a member — so its own rows answered `rbac` too.)*
449
+ */
415
450
  via(entity) {
416
451
  const me = this.account
452
+ if (entity.owner_id === me?.id) return 'owner'
417
453
  if (me?.operator) return 'rbac'
418
- return entity.owner_id === me?.id ? 'owner' : 'unit_member'
454
+ return 'unit_member'
419
455
  }
420
456
 
421
457
  /** The entity's summary: its brief section's item, projected like any read. */
@@ -506,7 +542,7 @@ export class MockStore {
506
542
  list(model, { scope, limit, offset, paginate, locales }) {
507
543
  const me = this.account
508
544
  const rows = [...this.entities.values()]
509
- .filter((e) => e.model === model.name && (scope !== 'mine' || e.owner_id === me?.id))
545
+ .filter((e) => e.model === model.name && this.mayRead(e) && (scope !== 'mine' || e.owner_id === me?.id))
510
546
  .sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : b.id - a.id))
511
547
  const page = paginate ? rows.slice(offset, offset + limit) : rows
512
548
  // `matched` counts the rows in THIS answer — the page, when paging.
@@ -620,6 +656,7 @@ export class MockStore {
620
656
  const expected = op[FIELD.precondition]
621
657
  if (expected != null && expected !== item.updated_at) {
622
658
  return refuse(409, 'Conflict', 'item changed since your last read — refetch and retry', {
659
+ [FIELD.item]: item.id,
623
660
  [FIELD.conflictToken]: item.updated_at,
624
661
  })
625
662
  }
package/src/wire.js CHANGED
@@ -35,9 +35,10 @@
35
35
  * | `matched` was read as the total before paging | it counts the rows in THIS answer — the page when paging. A full page is the only "maybe more" |
36
36
  * | a read's items did not seed the concurrency ledger | they carry `updated_at`, the token the first write of an item is guarded by |
37
37
  * | every `409` was reported as an edit conflict | only the one carrying `current_updated_at` is; the rest are rules (insert-only, one-item section, a reference) |
38
- * | a `409` was rebased onto "the first op with an item id" | the stale `409` names no item — a batch cannot be rebased by guesswork |
38
+ * | a `409` was rebased onto "the first op with an item id" | only the item the stale `409` names (`item_id`, since 2026-09-18); an older backend names none, and a batch is then not rebased — never by guesswork |
39
39
  * | a sign-up answered with the account | it answers `202 { status: 'verification_required', email }`; the account cannot sign in until verified (`403 Email Not Verified`) |
40
40
  * | `acting_unit_id` was a membership signal | every signed-in member of a site acts in the same unit — it cannot tell an operator from a member; `roles` can |
41
+ * | the viewer carried `actingUnitId` | it carries `workspace` — where the request landed: on a site's service, its home org (2026-09-23; `acting_unit_id` leaves the wire) |
41
42
  *
42
43
  * @module @uniweb/api/wire
43
44
  */
@@ -113,7 +114,7 @@ export const AUTH = {
113
114
  * | reset request | `{ email }` | `202 { status: 'reset_requested' }` whether or not the address is known |
114
115
  * | reset confirm | `{ token, new_password, code? }` | `200 { reset: true }`; a bad token is `400 Validation`; `code` is required when a second factor is enrolled |
115
116
  * | logout | none | `204`; with no session to end, `401` |
116
- * | me | none | `200 { account: { uuid, username, handle }, roles, acting_unit_id }` · `401` |
117
+ * | me | none | `200 { account: { uuid, username, handle }, roles, workspace: { unit_uuid, handle } }` · `401` |
117
118
  */
118
119
  export const AUTH_BODY = {
119
120
  login: ['username', 'password'],
@@ -136,16 +137,24 @@ export const TOTP = {
136
137
  * `roles` is a list of `{ role, scope_unit_id }` — `role` one of `system_admin`,
137
138
  * `unit_admin`, `content_editor`, `user`. An ordinary member holds none: `[]`.
138
139
  *
139
- * ⛔ **`acting_unit_id` is NOT a membership or operator signal.** Every signed-in
140
- * member of a site acts in the same unit, so it reads the same for the operator
141
- * and for someone who signed up a minute ago. The operator of a site's `api`
142
- * service holds `system_admin`. Better than either: ask about the thing — a
143
- * single-entity read carries `can_edit`, the write gate's own answer.
140
+ * `workspace` is `{ unit_uuid, handle }` — **the workspace the request works in**. This
141
+ * package's requests name none, and on a site's `api` service such a request lands in
142
+ * the service's **home org**, `handle: 'home'`, in which every account is enrolled.
143
+ * Both `null` there means an account that is not a member — a service account, a
144
+ * revoked membership. (On a backend with no home org, naming none is personal: both
145
+ * `null`.) The viewer carries it as `{ unitUuid, handle }`, or `null` when both are
146
+ * null. *(It replaced `acting_unit_id` on 2026-09-23. MEASURED by backend at the
147
+ * function the route serializes, 2026-09-23.)*
148
+ *
149
+ * ⛔ **Neither is an operator signal.** Every member of a site's service works in the
150
+ * same home org, so it reads the same for the operator and for someone who signed up a
151
+ * minute ago. The operator holds `system_admin`. Better than either: ask about the
152
+ * thing — a single-entity read carries `can_edit`, the write gate's own answer.
144
153
  */
145
154
  export const VIEWER = {
146
155
  account: 'account',
147
156
  roles: 'roles',
148
- actingUnit: 'acting_unit_id',
157
+ workspace: 'workspace',
149
158
  }
150
159
 
151
160
  /**
@@ -212,10 +221,14 @@ export const PARAM = {
212
221
  }
213
222
 
214
223
  /**
215
- * The list's `scope`. **MEASURED.** Default `accessible` — everything the viewer
216
- * may read. ⚠️ On a site's `api` service that is **every member's** entities of
217
- * the Model, not only the viewer's own: members of a site read each other's
218
- * content. `mine` is only what the viewer owns.
224
+ * The list's `scope`. Default `accessible` — everything the viewer may read; `mine`
225
+ * — only what the viewer owns.
226
+ *
227
+ * What a member of a site may read: their own entities, and what was shared with
228
+ * them. Other members' entities are private to their owners unless the site's
229
+ * service was set up to let members read each other's. The operator reads
230
+ * everything. *(A site's service let every member read every other member's
231
+ * entities until 2026-09-18 — measured, and changed on the backend.)*
219
232
  */
220
233
  export const SCOPE = {
221
234
  mine: 'mine',
@@ -269,7 +282,11 @@ export const GUARDED_OPS = new Set([OP.update, OP.delete, OP.move])
269
282
  * known only from the op that named it.
270
283
  */
271
284
  export const FIELD = {
272
- /** Names the target item on an op (an integer), and the item on a write result. */
285
+ /**
286
+ * Names the target item on an op (an integer), the item on a write result, and
287
+ * the stale item on a `409` — the last since 2026-09-18; an older backend's `409`
288
+ * carries only `conflictToken`.
289
+ */
273
290
  item: 'item_id',
274
291
  /** The new item's uuid, on a create's result. */
275
292
  itemUuid: 'item_uuid',
@@ -325,8 +342,10 @@ export const CREATE = {
325
342
  * paging; a page that came back full is the only sign there may be more.
326
343
  *
327
344
  * `via` names why the viewer can read the row — `owner`, `grant`, `rbac`,
328
- * `unit_member`, `entitlement`; treat an unknown value as "no opinion". ⚠️ An
329
- * empty list means empty: a lapsed session is a `401`, never `200 []`.
345
+ * `unit_member`, `entitlement`; treat an unknown value as "no opinion". The
346
+ * operator's own rows are `owner` and the rest `rbac` — since 2026-09-18; an older
347
+ * backend answers `rbac` on the operator's own rows too. ⚠️ An empty list means
348
+ * empty: a lapsed session is a `401`, never `200 []`.
330
349
  */
331
350
  export const LIST = {
332
351
  records: 'entities',
@@ -343,7 +362,10 @@ export const LIST = {
343
362
  * section by id only; the Model's definition maps ids to names.
344
363
  *
345
364
  * `absent` is one word for not-found and not-permitted, by design: both are
346
- * `404 { kind: 'entity' }`.
365
+ * `404 { kind: 'entity' }`. So is an entity of ANOTHER Model: `?model=` says what
366
+ * the caller takes the entity to be, and a read or a write naming the wrong one is
367
+ * the same `404` — since 2026-09-18; before that the backend answered, stamped with
368
+ * the Model named.
347
369
  */
348
370
  export const READ = {
349
371
  hydrated: 'hydrated',
@@ -381,7 +403,7 @@ export const SCHEMA = {
381
403
  *
382
404
  * | answer | means |
383
405
  * |---|---|
384
- * | `409 Conflict` + `current_updated_at` | the item changed since this viewer's token — the ONLY stale-token answer |
406
+ * | `409 Conflict` + `item_id`, `current_updated_at` | that item changed since this viewer's token — the ONLY stale-token answer. `item_id` since 2026-09-18; an older backend sends the token alone |
385
407
  * | `409 Append-Only Section` + `section` | an insert-only section: its items can be added and moved, not edited or deleted |
386
408
  * | `409 Schema Rule Violation` | a rule of the Model: a second item in a one-item section, a section of another Model, a reference that still points here |
387
409
  * | `403 Forbidden` + `op`, `target` | may read it, may not do this to it — `edit`, `delete`, `use_model` (create) |