@uniweb/api 0.3.6 → 0.4.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/src/wire.js CHANGED
@@ -3,59 +3,85 @@
3
3
  *
4
4
  * ## Why this module exists
5
5
  *
6
- * Framework is building this client **ahead of backend's per-operation spec**, on
7
- * purpose: a client that has actually been built finds things a spec review does
8
- * not, and backend asked for exactly this — *"whatever the package asserts about
9
- * our responses becomes a consumer we must not break silently. Tell us what you
10
- * pin, and we will treat it as a contract."*
11
- *
12
- * ⛔ **The risk in building ahead is not guessing a shape — that is cheap to fix.
13
- * It is guessing a shape, spreading it across a dozen files, and writing it into a
14
- * doc as fact.** A wrong assumption that lives in one module is a one-file diff. The
15
- * same assumption inlined into six hooks is an archaeology exercise, and by then
16
- * something will be citing it as though it were measured.
17
- *
18
- * ⇒ **So every route, parameter name and response field this package depends on is
19
- * declared here, with where it came from.** Three provenances, and the difference
20
- * between them is the whole point:
6
+ * A wrong assumption that lives in one module is a one-file diff. The same
7
+ * assumption inlined into six hooks is an archaeology exercise, and by then
8
+ * something will be citing it as though it were measured. ⇒ **Every route,
9
+ * parameter, body key and response field this package depends on is declared
10
+ * here, with where it came from.** Three provenances, and the difference between
11
+ * them is the whole point:
21
12
  *
22
13
  * | | means |
23
14
  * |---|---|
24
15
  * | **RULED** | a person with the authority decided it. Not a measurement — a decision |
25
- * | **MEASURED** | observed in a working client of THIS route, or in backend's own source |
16
+ * | **MEASURED** | observed in a real backend's responses, or read in its source |
26
17
  * | **ASSUMED** | ⛔ **we are building ahead. Nobody has confirmed this.** |
27
18
  *
28
- * ⚠️ **`MEASURED` is not `MEASURED HERE`.** A shape read off a *different route* of
29
- * the same daemon is `ASSUMED` for ours, however identical it looks — the site
30
- * lane and the entity lane are two routes in one binary and may answer
31
- * differently. That distinction is the one this file exists to keep, because it is
32
- * exactly the one that erodes.
19
+ * ⚠️ **`MEASURED` is not `MEASURED HERE`.** A shape read off a *different route*
20
+ * of the same backend is `ASSUMED` for ours, however identical it looks.
33
21
  *
34
- * ## What to do with it
22
+ * ## ⭐ MEASURED 2026-09-18 — every shape below, in literal responses
35
23
  *
36
- * `ASSUMPTIONS` below is the list to hand backend. When one is confirmed, move its
37
- * note to MEASURED and delete its entry — the test on that array makes the change
38
- * deliberate and visible rather than a quiet edit.
24
+ * The whole surface this package touches was exercised against a real backend set
25
+ * up the way a site's `api` service is: one operator account, members who sign up
26
+ * themselves, and the site's own Models. Where this module quotes a shape it was
27
+ * copied from a response, not described. That pass corrected the package in these
28
+ * places, each now fixed and pinned by a test:
29
+ *
30
+ * | was | is |
31
+ * |---|---|
32
+ * | requests went to `${base}/api/<route>` | the base IS the API route space: `${base}/<route>` — on a hosted site `/_api/entities` reaches the backend's `/api/entities`, and `/_api/api/…` is a 404 |
33
+ * | `createEntity` sent its content as the body | content is items, by section: `{ items: [{ section, data }] }`. Unknown top-level keys are ignored — silently, `201` and an empty entity |
34
+ * | an item create sent `section: '<name>'` | the item route takes the numeric `section_id` (a `400` otherwise); names are resolved from the Model's definition |
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
+ * | 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
+ * | 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 |
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
+ * | `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 |
39
41
  *
40
42
  * @module @uniweb/api/wire
41
43
  */
42
44
 
43
45
  /**
44
- * The lane. ⭐ **RULED** *(Diego, 2026-09-01)*: this package reads and writes
45
- * **entities**, and touches nothing under `/api/sites/*`.
46
- *
47
- * Those routes are not merely unnecessary — they **create sites**, which is the
48
- * app's job, not a foundation's, and on a site's own service-provider backend they
49
- * have nothing to address anyway: no site of that id lives in that database. *"Our
50
- * recursion ends there, right before creating sites."*
46
+ * The lane. ⭐ **RULED** *(2026-09-01)*: this package reads and writes
47
+ * **entities**, reads the definitions of their Models, and touches nothing under
48
+ * `/sites/*`. Those routes create sites, which is the app's job, not a
49
+ * foundation's, and a site's own `api` service has no site to address.
51
50
  *
52
- * ⛔ Do not add a route here that does not begin `/entities`, or an auth route.
51
+ * ⛔ Do not add a route that is not under `/entities`, `/models` or `/auth`.
53
52
  */
54
53
  export const ENTITIES = '/entities'
55
54
 
55
+ /** The Models lane — read-only, one route: a Model's definition. MEASURED. */
56
+ export const MODELS = '/models'
57
+
58
+ /**
59
+ * The base, and how a route joins it. **MEASURED.**
60
+ *
61
+ * The base the site is handed for its `api` service is the address of the
62
+ * backend's API route space, and every route here is relative to it:
63
+ * `${base}/auth/me`, `${base}/entities`. On a hosted site that is `/_api` on the
64
+ * site's own origin, which reaches the backend's `/api/…`; wherever a page reaches
65
+ * a backend directly it is that backend's absolute `…/api`.
66
+ *
67
+ * ⛔ **The backend does not percent-decode path segments.** `/models/%40scope/name`
68
+ * is a `404` where `/models/@scope/name` answers — so `@` goes out raw.
69
+ */
70
+ function segment(value) {
71
+ return encodeURIComponent(String(value)).replace(/%40/g, '@')
72
+ }
73
+
74
+ /** `@scope/name` → `@scope/name`, each half a raw path segment. */
75
+ function modelPath(model) {
76
+ const [scope, ...rest] = String(model).split('/')
77
+ return rest.length ? `${segment(scope)}/${segment(rest.join('/'))}` : segment(scope)
78
+ }
79
+
56
80
  /**
57
- * Auth. **MEASURED** — shipped in `@uniweb/api@0.1.0` and exercised by the live
58
- * suite (`tests/live/`) against a real `uniwebd`.
81
+ * Auth routes. **MEASURED.**
82
+ *
83
+ * `/auth/me` is strict about its parameters: `?locale=` on it is a
84
+ * `400 "Unexpected parameters: locale"`. Every route here is.
59
85
  */
60
86
  export const AUTH = {
61
87
  me: '/auth/me',
@@ -63,42 +89,112 @@ export const AUTH = {
63
89
  challenge: '/auth/login/challenge',
64
90
  logout: '/auth/logout',
65
91
  register: '/auth/register',
92
+ /**
93
+ * `GET /auth/verify?token=` → `{ verified: true }`; a bad token is `400 Validation`.
94
+ * ⚠️ This package does not call it: the link in the verification email is the
95
+ * backend's own, not a page of the site. The mock serves it so a sign-up can be
96
+ * finished locally.
97
+ */
98
+ verify: '/auth/verify',
66
99
  resetRequest: '/auth/reset/request',
67
100
  resetConfirm: '/auth/reset/confirm',
68
101
  }
69
102
 
70
103
  /**
71
- * Entity routes. **MEASURED** — every one of these is called by a working client
72
- * of this exact lane, which verified them against the daemon's own controller.
104
+ * The bodies of the auth routes. **MEASURED.** This package hands a caller's
105
+ * fields to the backend unchanged, so these are the names a caller must use — a
106
+ * missing one is a `400` naming it (`missing field \`email\``). `?` marks optional.
73
107
  *
74
- * ⚠️ Measured means *the route exists and answers*. It does **not** mean this
75
- * package has confirmed the response bodies — see `ASSUMPTIONS`.
108
+ * | route | body | answer |
109
+ * |---|---|---|
110
+ * | login | `{ username, password }` | `200 { token, expires_at, account }` + the session cookie · `200 { status: 'totp_required', challenge_token }` · `401` wrong credential · `403 Email Not Verified` |
111
+ * | challenge | `{ challenge_token, code }` | as login's `200`; a bad token or code is `401` |
112
+ * | register | `{ username, email, password }` | `202 { status: 'verification_required', email }` — the same answer for an address already taken; a taken **username** is `409` |
113
+ * | reset request | `{ email }` | `202 { status: 'reset_requested' }` whether or not the address is known |
114
+ * | 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
+ * | logout | none | `204`; with no session to end, `401` |
116
+ * | me | none | `200 { account: { uuid, username, handle }, roles, acting_unit_id }` · `401` |
117
+ */
118
+ export const AUTH_BODY = {
119
+ login: ['username', 'password'],
120
+ challenge: ['challenge_token', 'code'],
121
+ register: ['username', 'email', 'password'],
122
+ resetRequest: ['email'],
123
+ resetConfirm: ['token', 'new_password', 'code?'],
124
+ }
125
+
126
+ /** A sign-in that needs a second factor. MEASURED. */
127
+ export const TOTP = {
128
+ status: 'totp_required',
129
+ token: 'challenge_token',
130
+ code: 'code',
131
+ }
132
+
133
+ /**
134
+ * The viewer, as `/auth/me` answers. **MEASURED.**
135
+ *
136
+ * `roles` is a list of `{ role, scope_unit_id }` — `role` one of `system_admin`,
137
+ * `unit_admin`, `content_editor`, `user`. An ordinary member holds none: `[]`.
138
+ *
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.
144
+ */
145
+ export const VIEWER = {
146
+ account: 'account',
147
+ roles: 'roles',
148
+ actingUnit: 'acting_unit_id',
149
+ }
150
+
151
+ /**
152
+ * Entity and Model routes. **MEASURED.**
153
+ *
154
+ * Entity ids in a path are UUIDs — anything else is `400 "invalid uuid: …"`.
76
155
  */
77
156
  export const ROUTES = {
78
- /** `GET /entities?model=…` — the door. RULED: this one, not the site door. */
157
+ /** `GET /entities?model=…` — the list. RULED: this one, not a site's. */
79
158
  list: () => ENTITIES,
80
159
  /** `GET /entities/{uuid}?model=…` — one hydrated entity. */
81
- read: (uuid) => `${ENTITIES}/${encodeURIComponent(uuid)}`,
82
- /** `POST /entities/batch` — many hydrated entities in one call. */
160
+ read: (uuid) => `${ENTITIES}/${segment(uuid)}`,
161
+ /** `POST /entities/batch` `{ uuids, depth?, max_depth? }` → `{ entities: [...] }`, unreadable ones dropped. */
83
162
  readBatch: () => `${ENTITIES}/batch`,
84
- /** `POST /entities?model=…` — create an entity, optionally with its items. */
163
+ /** `POST /entities?model=…` — create an entity, optionally with its items. `201`. */
85
164
  create: () => ENTITIES,
86
- /** `POST /entities/{uuid}/items` — the item op. An array body is one transaction. */
87
- items: (uuid) => `${ENTITIES}/${encodeURIComponent(uuid)}/items`,
88
- /** `DELETE /entities/{uuid}` — hard-delete; items cascade. */
89
- remove: (uuid) => `${ENTITIES}/${encodeURIComponent(uuid)}`,
90
- /** `POST /entities/delete` — bulk hard-delete, all-or-nothing on the pin guard. */
165
+ /** `POST /entities/{uuid}/items?model=…` — the item op. An array body is one transaction. */
166
+ items: (uuid) => `${ENTITIES}/${segment(uuid)}/items`,
167
+ /** `DELETE /entities/{uuid}` — hard-delete; items cascade. `204`. */
168
+ remove: (uuid) => `${ENTITIES}/${segment(uuid)}`,
169
+ /** `POST /entities/delete` `{ uuids, rev_ref_policy? }` → `{ deleted }`, all-or-nothing on a refusal. */
91
170
  removeBatch: () => `${ENTITIES}/delete`,
171
+ /** `GET /models/@scope/name` — the Model's definition, its sections among it. */
172
+ schema: (model) => `${MODELS}/${modelPath(model)}`,
92
173
  }
93
174
 
94
175
  /**
95
- * Query parameter names. **MEASURED**, with one open question.
176
+ * Query parameter names. **MEASURED.**
177
+ *
178
+ * ⛔ **Every route refuses a parameter it does not take**, with
179
+ * `400 "Unexpected parameters: …"` — so a parameter goes only where it is declared:
180
+ *
181
+ * | route | takes |
182
+ * |---|---|
183
+ * | list | `model` (required) · `scope` · `limit` · `offset` · `paginate` · `locale` · `include_disabled` |
184
+ * | read | `model` (required) · `via` · `depth` · `max_depth` · `locale` |
185
+ * | items | `model` (required) · `readback` |
186
+ * | create | `model` (required) |
187
+ * | remove | `rev_ref_policy` |
96
188
  *
97
- * ⚠️ `via` vs `depth`: this package sends `via` on a single-entity read — reading
98
- * an entity *through* a container the viewer holds an entitlement on. The working
99
- * client of this route sends `depth` / `max_depth` instead and no `via` at all.
100
- * Both are presumably valid on the same route, answering different questions, but
101
- * **nobody has confirmed they compose** — see `ASSUMPTIONS`.
189
+ * ✅ **`via` and `depth` compose** (an assumption retired 2026-09-18): both are
190
+ * parameters of the same read and answer different questions — `via` *who may
191
+ * read* (through a container the viewer is entitled to), `depth` *how much is
192
+ * resolved*. An unentitled `via` is a `404`, never a `403`; a malformed one a `400`.
193
+ *
194
+ * `locale` is an ordered preference list, comma-separated. ⚠️ **The backend has no
195
+ * fallback of its own**: a localized field with no value in any listed locale is
196
+ * omitted, so a list that names only the visitor's language drops every field not
197
+ * yet translated. This package sends the active locale, then the site's default.
102
198
  */
103
199
  export const PARAM = {
104
200
  model: 'model',
@@ -107,6 +203,7 @@ export const PARAM = {
107
203
  offset: 'offset',
108
204
  locale: 'locale',
109
205
  paginate: 'paginate',
206
+ includeDisabled: 'include_disabled',
110
207
  via: 'via',
111
208
  depth: 'depth',
112
209
  maxDepth: 'max_depth',
@@ -115,18 +212,42 @@ export const PARAM = {
115
212
  }
116
213
 
117
214
  /**
118
- * Item ops. The kinds, and which of them carry a precondition.
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.
219
+ */
220
+ export const SCOPE = {
221
+ mine: 'mine',
222
+ accessible: 'accessible',
223
+ all: 'all',
224
+ }
225
+
226
+ /**
227
+ * Paging. **MEASURED.** A list with no `limit` answers 50 rows; a `limit` is
228
+ * clamped to `[0, 1000]`; `paginate=false` returns the whole slice and ignores
229
+ * both. This package sends its page size explicitly, so it knows what a full
230
+ * page looks like.
231
+ */
232
+ export const PAGE = {
233
+ size: 50,
234
+ max: 1000,
235
+ }
236
+
237
+ /**
238
+ * Item ops — the kinds, and which of them carry a precondition. **MEASURED.**
119
239
  *
120
- * **MEASURED** for the semantics: `update` and `delete` carry the target item's
121
- * last-seen `updated_at` as `if_unmodified_since`; `create` is tokenless; a
122
- * mismatch is `409` with `current_updated_at`; a gone item is `404`; an absent
123
- * token is last-writer-wins, guarded same-transaction.
240
+ * | op | body | notes |
241
+ * |---|---|---|
242
+ * | `create` | `{ kind, section_id, data, parent_item_id?, position? }` | tokenless; `position` `'first'` · `'last'` · `{ after: <item_id> }`, default last. A second item in a one-item section is `409 Schema Rule Violation` |
243
+ * | `update` | `{ kind, item_id, data, if_unmodified_since? }` | replaces the item's data WHOLE |
244
+ * | `delete` | `{ kind, item_id, if_unmodified_since? }` | |
245
+ * | `move` | `{ kind, item_id, position, parent_item_id?, if_unmodified_since? }` | `position` required. Allowed on an insert-only section |
124
246
  *
125
- * ⛔ **`move` is ASSUMED.** It is in scope as a product requirement — an operator
126
- * arranging authored content by hand, where order is a stored fact and not a sort
127
- * key — but it was read off the **site** lane, and this lane's own documentation
128
- * names only `update` and `delete` as token-carrying. Whether `move` exists here at
129
- * all is unconfirmed.
247
+ * Ids are integers — `"3"` is a `400 … expected i64`. An array of ops is ONE
248
+ * transaction, and **an op may not reference an item created earlier in the same
249
+ * batch**. An absent token is last-writer-wins. ⚠️ An update that writes the data
250
+ * the item already holds is a no-op: the item's token does not move.
130
251
  */
131
252
  export const OP = {
132
253
  create: 'create',
@@ -139,105 +260,157 @@ export const OP = {
139
260
  export const GUARDED_OPS = new Set([OP.update, OP.delete, OP.move])
140
261
 
141
262
  /**
142
- * Field names on an op and on a write response.
263
+ * Field names on an op and on a write response. **MEASURED.**
143
264
  *
144
- * ⛔ **ASSUMED, all of them.** These were read off `POST /api/sites/{id}/content/items`
145
- * — a *different route* of the same binary — because the working client of *our*
146
- * route returns its responses unnormalized and so reveals no names at all.
147
- *
148
- * The shapes are very likely identical: both are `…/items` routes with the same op
149
- * vocabulary, and this lane's documentation says its concurrency is "aligned with
150
- * the sites lane". **Likely is not measured**, and this comment is the difference.
265
+ * A single-op write answers `{ entity, item_id, item_uuid, item_updated_at }`; a
266
+ * batch `{ entity, results: [{ item_id, item_uuid, item_updated_at }, …] }`, one
267
+ * result per op, in order. `item_uuid` is set on a create only. ⚠️ **A delete
268
+ * answers `item_id: null`** — the row is gone — so the item a delete removed is
269
+ * known only from the op that named it.
151
270
  */
152
271
  export const FIELD = {
153
- /** Names the target item on an op, and the affected item on a response. */
272
+ /** Names the target item on an op (an integer), and the item on a write result. */
154
273
  item: 'item_id',
155
- /** Placement on a `create`. */
274
+ /** The new item's uuid, on a create's result. */
275
+ itemUuid: 'item_uuid',
276
+ /** Placement under another item, for nested content. */
156
277
  parent: 'parent_item_id',
157
278
  /**
158
- * Which section of the entity an item belongs to.
279
+ * The section an item-create op writes into — the numeric id. **MEASURED.**
159
280
  *
160
- * ⛔ REQUIRED on a create, and its absence is silent. An entity has several
161
- * sections and they are not interchangeable: a rule declared on one — an
162
- * `append_only`, a field set — simply does not apply to an item that landed in
163
- * another. A create with no section is accepted, stored somewhere, and every
164
- * guarantee the author declared is quietly not in force.
281
+ * ⭐ **Two routes, two spellings, both MEASURED:** creating an entity names a
282
+ * section by NAME (`SECTION_NAME`), a `parent/child` path for a nested one; the
283
+ * item route after it takes this numeric id. This package resolves a name to its
284
+ * id from the Model's definition (`ROUTES.schema`), so a caller only ever says a
285
+ * name.
165
286
  */
166
- section: 'section',
287
+ section: 'section_id',
167
288
  /** The precondition an op carries. */
168
289
  precondition: 'if_unmodified_since',
169
- /** The item's next token, on a write response. */
290
+ /** The item's next token, on a write result. */
170
291
  token: 'item_updated_at',
171
- /** The item's current token, on a `409`. */
292
+ /** The item's current token, on a stale `409`. */
172
293
  conflictToken: 'current_updated_at',
173
294
  }
174
295
 
175
296
  /**
176
- * The list response. **MEASURED on this exact route** — a working client of
177
- * `GET /api/entities?model=…` documents the body as `{"entities":[],"matched":0}`
178
- * and destructures it that way.
297
+ * The create body — `POST /entities?model=…`. **MEASURED.**
298
+ *
299
+ * `{ items: [{ section, data, parent_item_id? }] }`, each `section` a NAME (or a
300
+ * `parent/child` path). The entity and its items commit together, and the answer
301
+ * is `201` with the entity's row (the list's row shape, below). An unknown section
302
+ * name is `404 { kind: 'section' }`; an empty body makes an empty entity.
303
+ *
304
+ * ⛔ **There is no entity-level data.** An entity's content is always items in its
305
+ * sections; its `brief` is maintained by the backend from the brief section's
306
+ * item. ⛔ And the body's other keys are the backend's own — `uuid`, `owner_id` —
307
+ * so content spread at the top level is at best ignored and at worst read as one
308
+ * of them (`{ uuid: 't-1' }` is a `400`).
309
+ */
310
+ export const CREATE = {
311
+ items: 'items',
312
+ sectionName: 'section',
313
+ }
314
+
315
+ /**
316
+ * The list response — `{ entities: [row, …], matched }`. **MEASURED.**
179
317
  *
180
- * ⭐ `matched` is the count BEFORE paging, which is what makes it worth carrying:
181
- * it is the only thing that can answer "is there more" without a second request.
318
+ * A row: `{ model_uuid, model_name, via, id, uuid, model_id, owner_id, unit_id,
319
+ * sort_date, brief, disabled, created_by, created_at, updated_at }`. A list is
320
+ * brief-only — `brief` is the entity's summary, and there are no items on a row.
182
321
  *
183
- * ⚠️ **An empty list means empty, and has since 2026-08-29.** Before that a lapsed
184
- * session was answered anonymously on content routes — a `200` with an empty list,
185
- * byte-identical to a genuinely empty result — so a signed-out viewer was told
186
- * their content was gone. Every route now answers `401` instead. This package's
187
- * default `onUnauthorized: 'session-lost'` is the correct reading of that, and
188
- * anything here that treats an empty list as "maybe you are logged out" would be
189
- * re-implementing a bug the backend already fixed.
322
+ * ⛔ **`matched` counts the rows in THIS answer** — the page, when paging (a
323
+ * `limit=1` over two entities answers `matched: 1`). It is the total only for
324
+ * `paginate=false`, where the answer is the whole slice. There is no total when
325
+ * paging; a page that came back full is the only sign there may be more.
326
+ *
327
+ * `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 []`.
190
330
  */
191
331
  export const LIST = {
192
332
  records: 'entities',
193
- /** The count before `limit`/`offset` — the total, not the page. */
194
333
  matched: 'matched',
195
334
  }
196
335
 
336
+ /**
337
+ * The single-entity read. **MEASURED.**
338
+ *
339
+ * `{ model_uuid, model_name, can_edit, hydrated: { entity, items } }` — `entity`
340
+ * is the row without `model_*`, and each item is
341
+ * `{ id, section_id, parent_item_id, data, item_date, order_number, updated_at }`.
342
+ * `can_edit` is the write gate's own answer for this viewer. Items name their
343
+ * section by id only; the Model's definition maps ids to names.
344
+ *
345
+ * `absent` is one word for not-found and not-permitted, by design: both are
346
+ * `404 { kind: 'entity' }`.
347
+ */
348
+ export const READ = {
349
+ hydrated: 'hydrated',
350
+ entity: 'entity',
351
+ items: 'items',
352
+ itemId: 'id',
353
+ itemSection: 'section_id',
354
+ itemParent: 'parent_item_id',
355
+ itemToken: 'updated_at',
356
+ canEdit: 'can_edit',
357
+ }
358
+
359
+ /**
360
+ * A Model's definition — `GET /models/@scope/name`. **MEASURED.**
361
+ *
362
+ * `{ model: {…}, sections: [{ id, name, kind, is_brief, parent_section_id,
363
+ * fields, other_data, constraints }] }` — `kind` is `single` or `multi`;
364
+ * `other_data.append_only` marks an insert-only section.
365
+ *
366
+ * ⚠️ **Readable by the Models the viewer may create, and by the operator.** A
367
+ * member reading entities of a Model only the operator creates gets `404` here —
368
+ * so a member can read those items but cannot learn their section names.
369
+ */
370
+ export const SCHEMA = {
371
+ sections: 'sections',
372
+ id: 'id',
373
+ name: 'name',
374
+ kind: 'kind',
375
+ parent: 'parent_section_id',
376
+ }
377
+
378
+ /**
379
+ * Refusals this package branches on. **MEASURED.** Problem-JSON:
380
+ * `{ status, title, detail?, …extensions }` — `title` is the stable discriminator.
381
+ *
382
+ * | answer | means |
383
+ * |---|---|
384
+ * | `409 Conflict` + `current_updated_at` | the item changed since this viewer's token — the ONLY stale-token answer |
385
+ * | `409 Append-Only Section` + `section` | an insert-only section: its items can be added and moved, not edited or deleted |
386
+ * | `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
+ * | `403 Forbidden` + `op`, `target` | may read it, may not do this to it — `edit`, `delete`, `use_model` (create) |
388
+ * | `403 Email Not Verified` | right password, unverified address |
389
+ * | `403 CSRF Header Required` | a cookie-authenticated mutation without `X-Uniweb-Csrf` |
390
+ * | `400 Validation` + `field` | content that does not fit the Model (`field: 'data.minutes'`) |
391
+ * | `404 Not Found` + `kind`, `key` | nothing here for you — not found or not permitted |
392
+ */
393
+ export const PROBLEM = {
394
+ csrf: 'CSRF Header Required',
395
+ stepUp: 'Step-Up Required',
396
+ notVerified: 'Email Not Verified',
397
+ appendOnly: 'Append-Only Section',
398
+ schemaRule: 'Schema Rule Violation',
399
+ }
400
+
197
401
  /**
198
402
  * ⛔ THE LIST TO HAND BACKEND — everything this package asserts that nobody has
199
403
  * confirmed. Each entry says what we do, and what breaks if we are wrong.
200
404
  *
201
- * ⭐ This is not documentation of the backend. It is a **statement of what we
202
- * pinned**, which is the artifact backend asked for. Confirming one is a deliberate
203
- * edit here plus a moved comment above; `tests/wire.test.js` pins the set so the
204
- * change cannot be quiet.
205
- */
206
- export const ASSUMPTIONS = [
207
- {
208
- id: 'write-response-fields',
209
- we: `a write response names its item as '${FIELD.item}' and its next token as '${FIELD.token}'`,
210
- from: 'the site-editor lane, which is a different route',
211
- breaks: 'the ledger records nothing, so every second write on an item goes out unguarded — last-writer-wins instead of a 409',
212
- },
213
- {
214
- id: 'op-field-names',
215
- we: `an op names its target '${FIELD.item}', its placement '${FIELD.parent}', and its precondition '${FIELD.precondition}'`,
216
- from: 'the site-editor lane, which is a different route',
217
- breaks: 'writes are refused, loudly — the cheapest of these to be wrong about',
218
- },
219
- {
220
- id: 'move-exists',
221
- we: `'${OP.move}' is an op on this lane, and carries a precondition`,
222
- from: 'the site-editor lane; this lane documents only update and delete as token-carrying',
223
- breaks: 'an operator cannot reorder authored content, which is half of what makes an app an app rather than a CMS',
224
- },
225
- {
226
- id: 'move-position',
227
- we: 'a move is positioned server-side — "first" | "last" | { after } — and the client never computes an order number',
228
- from: 'the site-editor lane',
229
- breaks: 'reordering writes the wrong sequence, or needs a client-side order the store does not want',
230
- },
231
- {
232
- id: 'viewer-unit-signal',
233
- we: "read a viewer's unit membership from `acting_unit_id` on /auth/me, surfaced as `viewer.actingUnitId`",
234
- from: "the field this package already normalizes; whether it is THE membership signal, or one of several, is unconfirmed",
235
- breaks: 'an app cannot tell an operator from a member, so it either shows authoring controls to everyone or to nobody — and the refusal only arrives at the write',
236
- },
237
- {
238
- id: 'via-and-depth-compose',
239
- we: `'${PARAM.via}' and '${PARAM.depth}' are both valid on a single-entity read, answering different questions`,
240
- from: 'via is this package’s own; depth is what the working client of this route sends. Neither has been seen beside the other',
241
- breaks: 'an entitled read returns the wrong shape, or one parameter silently wins',
242
- },
243
- ]
405
+ * ⭐ **Empty since 2026-09-18.** The last two were answered by measurement:
406
+ *
407
+ * | retired | answered as |
408
+ * |---|---|
409
+ * | `viewer-unit-signal` | ⛔ false — every member of a site acts in the same unit; see `VIEWER` |
410
+ * | `via-and-depth-compose` | ✅ true — see `PARAM` |
411
+ *
412
+ * *(Retired 2026-09-17: `write-response-fields`, `move-exists`, `move-position`,
413
+ * `op-field-names` — see `FIELD` and `OP`.)* A new entry needs `we`, `from` and
414
+ * `breaks`; `tests/wire.test.js` pins the set, so adding one is deliberate.
415
+ */
416
+ export const ASSUMPTIONS = []