@uniweb/api 0.3.7 → 0.4.1

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,108 +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.
33
- *
34
- * ## What to do with it
35
- *
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.
39
- *
40
- * ## ⭐ THE ENTITY CONTENT MODEL — **MEASURED 2026-09-17**, and it corrects this package
41
- *
42
- * Backend read its own source and answered three questions we put to it. The answer
43
- * to the third replaced a premise this package was built on, so it is recorded here
44
- * first, before any route or field:
45
- *
46
- * ⛔ **THERE IS NO ENTITY-LEVEL DATA. Entity content is ALWAYS items.**
47
- *
48
- * An entity stores identity, ownership, flags, timestamps, and a `brief` /
49
- * `sort_date` **the server maintains itself**. There is no separate data record, and
50
- * therefore no route that updates one. Everything an author writes is an item in a
51
- * section.
52
- *
53
- * - **A `single` section — `brief: true` included — holds an ORDINARY ITEM**, with
54
- * its own `item_id`, updated through the same item route as any other. It takes
55
- * exactly ONE item: a second `create` into it is refused, so the shape is
56
- * create-once-then-update.
57
- * - **Never send `brief`.** The server rebuilds `brief` and `sort_date` after a
58
- * write to the brief section. It is output, not input.
59
- * - ⚠️ **Two spellings for one flag:** `brief: true` is how a model *file* is
60
- * written; a schema *read* returns **`is_brief`**.
61
- * - **Creating with content puts items under `items`, addressed BY NAME:**
62
- * `POST /entities?model=…` · `{ items: [ { section: 'profile', data: {…} } ] }`.
63
- * Entity and items commit in one transaction. A nested section uses a
64
- * parent/child path.
65
- * - **After creation, item ops address a section by NUMERIC `section_id`** — not by
66
- * name (see `FIELD.section`).
67
- * - Site content, folder and deployment entities answer **409** on the item route;
68
- * they are written through `/api/sites/…` and `/api/folders/…`. That is the same
69
- * boundary the RULED lane below draws, enforced from the other side.
70
- * - A batch runs in one transaction, but **an op may not reference an item created
71
- * earlier in the same batch.**
72
- *
73
- * ⛔ **WHAT THIS PACKAGE GETS WRONG TODAY — do not read the code as the contract.**
74
- * `createEntity({ schema, data })` sends content as a **top-level `data` key**, which
75
- * the create route does not accept. The create body **does not reject unknown keys**,
76
- * so that payload is *silently dropped*: **201, and an empty entity, with no error.**
77
- * Our own mock accepts the same shape, so the fiction is symmetrical and nothing in
78
- * this repo currently fails because of it. Correcting the client is a separate change
79
- * (it touches behaviour); this module's job is to make sure the contract is written
80
- * down before that happens.
81
- *
82
- * ⚠️ **Provenance of the above: MEASURED BY BACKEND, reading backend's own source —
83
- * not observed on the wire by us, and the ignored-`data` behaviour was explicitly
84
- * described as read-not-tested.** That is stronger than anything else we have on this
85
- * lane and weaker than a response we have held in our hands. It earns MEASURED under
86
- * the table above ("or in backend's own source"); the untested corner is flagged
87
- * where it matters rather than promoted.
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.
21
+ *
22
+ * ## ⭐ MEASURED 2026-09-18 — every shape below, in literal responses
23
+ *
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" | 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
+ * | 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 |
88
41
  *
89
42
  * @module @uniweb/api/wire
90
43
  */
91
44
 
92
45
  /**
93
- * The lane. ⭐ **RULED** *(Diego, 2026-09-01)*: this package reads and writes
94
- * **entities**, and touches nothing under `/api/sites/*`.
95
- *
96
- * Those routes are not merely unnecessary — they **create sites**, which is the
97
- * app's job, not a foundation's, and on a site's own service-provider backend they
98
- * have nothing to address anyway: no site of that id lives in that database. *"Our
99
- * 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.
100
50
  *
101
- * ⛔ 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`.
102
52
  */
103
53
  export const ENTITIES = '/entities'
104
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
+
105
80
  /**
106
- * Auth. **MEASURED** — shipped in `@uniweb/api@0.1.0` and exercised by the live
107
- * 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.
108
85
  */
109
86
  export const AUTH = {
110
87
  me: '/auth/me',
@@ -112,42 +89,112 @@ export const AUTH = {
112
89
  challenge: '/auth/login/challenge',
113
90
  logout: '/auth/logout',
114
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',
115
99
  resetRequest: '/auth/reset/request',
116
100
  resetConfirm: '/auth/reset/confirm',
117
101
  }
118
102
 
119
103
  /**
120
- * Entity routes. **MEASURED** — every one of these is called by a working client
121
- * 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.
107
+ *
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.**
122
135
  *
123
- * ⚠️ Measured means *the route exists and answers*. It does **not** mean this
124
- * package has confirmed the response bodies — see `ASSUMPTIONS`.
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: …"`.
125
155
  */
126
156
  export const ROUTES = {
127
- /** `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. */
128
158
  list: () => ENTITIES,
129
159
  /** `GET /entities/{uuid}?model=…` — one hydrated entity. */
130
- read: (uuid) => `${ENTITIES}/${encodeURIComponent(uuid)}`,
131
- /** `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. */
132
162
  readBatch: () => `${ENTITIES}/batch`,
133
- /** `POST /entities?model=…` — create an entity, optionally with its items. */
163
+ /** `POST /entities?model=…` — create an entity, optionally with its items. `201`. */
134
164
  create: () => ENTITIES,
135
- /** `POST /entities/{uuid}/items` — the item op. An array body is one transaction. */
136
- items: (uuid) => `${ENTITIES}/${encodeURIComponent(uuid)}/items`,
137
- /** `DELETE /entities/{uuid}` — hard-delete; items cascade. */
138
- remove: (uuid) => `${ENTITIES}/${encodeURIComponent(uuid)}`,
139
- /** `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. */
140
170
  removeBatch: () => `${ENTITIES}/delete`,
171
+ /** `GET /models/@scope/name` — the Model's definition, its sections among it. */
172
+ schema: (model) => `${MODELS}/${modelPath(model)}`,
141
173
  }
142
174
 
143
175
  /**
144
- * Query parameter names. **MEASURED**, with one open question.
176
+ * Query parameter names. **MEASURED.**
145
177
  *
146
- * ⚠️ `via` vs `depth`: this package sends `via` on a single-entity read — reading
147
- * an entity *through* a container the viewer holds an entitlement on. The working
148
- * client of this route sends `depth` / `max_depth` instead and no `via` at all.
149
- * Both are presumably valid on the same route, answering different questions, but
150
- * **nobody has confirmed they compose** — see `ASSUMPTIONS`.
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` |
188
+ *
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.
151
198
  */
152
199
  export const PARAM = {
153
200
  model: 'model',
@@ -156,6 +203,7 @@ export const PARAM = {
156
203
  offset: 'offset',
157
204
  locale: 'locale',
158
205
  paginate: 'paginate',
206
+ includeDisabled: 'include_disabled',
159
207
  via: 'via',
160
208
  depth: 'depth',
161
209
  maxDepth: 'max_depth',
@@ -164,23 +212,46 @@ export const PARAM = {
164
212
  }
165
213
 
166
214
  /**
167
- * Item ops. The kinds, and which of them carry a precondition.
168
- *
169
- * **MEASURED** for the semantics: `update` and `delete` carry the target item's
170
- * last-seen `updated_at` as `if_unmodified_since`; `create` is tokenless; a
171
- * mismatch is `409` with `current_updated_at`; a gone item is `404`; an absent
172
- * token is last-writer-wins, guarded same-transaction.
173
- *
174
- * ✅ **`move` is MEASURED (2026-09-17).** It exists on this lane, carries a
175
- * precondition, and takes `{ item_id, parent_item_id, position, if_unmodified_since }`.
176
- * **Position is decided server-side** — `"first"`, `"last"` or `{ after: <item_id> }` —
177
- * and the client never computes an order number. Both assumptions that stood here are
178
- * retired. *(It had been read off the site lane, whose documentation names only
179
- * `update` and `delete` as token-carrying; the doubt was reasonable and wrong.)*
180
- *
181
- * ⚠️ **Batch caveat, MEASURED:** an array of ops runs in ONE transaction — all commit
182
- * or none — but **an op may not reference an item created earlier in the same batch.**
183
- * A create-then-position sequence is therefore two round trips, not one batch.
215
+ * The list's `scope`. Default `accessible` — everything the viewer may read; `mine`
216
+ * — only what the viewer owns.
217
+ *
218
+ * What a member of a site may read: their own entities, and what was shared with
219
+ * them. Other members' entities are private to their owners unless the site's
220
+ * service was set up to let members read each other's. The operator reads
221
+ * everything. *(A site's service let every member read every other member's
222
+ * entities until 2026-09-18 — measured, and changed on the backend.)*
223
+ */
224
+ export const SCOPE = {
225
+ mine: 'mine',
226
+ accessible: 'accessible',
227
+ all: 'all',
228
+ }
229
+
230
+ /**
231
+ * Paging. **MEASURED.** A list with no `limit` answers 50 rows; a `limit` is
232
+ * clamped to `[0, 1000]`; `paginate=false` returns the whole slice and ignores
233
+ * both. This package sends its page size explicitly, so it knows what a full
234
+ * page looks like.
235
+ */
236
+ export const PAGE = {
237
+ size: 50,
238
+ max: 1000,
239
+ }
240
+
241
+ /**
242
+ * Item ops — the kinds, and which of them carry a precondition. **MEASURED.**
243
+ *
244
+ * | op | body | notes |
245
+ * |---|---|---|
246
+ * | `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` |
247
+ * | `update` | `{ kind, item_id, data, if_unmodified_since? }` | replaces the item's data WHOLE |
248
+ * | `delete` | `{ kind, item_id, if_unmodified_since? }` | |
249
+ * | `move` | `{ kind, item_id, position, parent_item_id?, if_unmodified_since? }` | `position` required. Allowed on an insert-only section |
250
+ *
251
+ * Ids are integers — `"3"` is a `400 … expected i64`. An array of ops is ONE
252
+ * transaction, and **an op may not reference an item created earlier in the same
253
+ * batch**. An absent token is last-writer-wins. ⚠️ An update that writes the data
254
+ * the item already holds is a no-op: the item's token does not move.
184
255
  */
185
256
  export const OP = {
186
257
  create: 'create',
@@ -193,136 +264,166 @@ export const OP = {
193
264
  export const GUARDED_OPS = new Set([OP.update, OP.delete, OP.move])
194
265
 
195
266
  /**
196
- * Field names on an op and on a write response.
197
- *
198
- * ✅ **MEASURED 2026-09-17** — backend confirmed these against its own source, and the
199
- * four assumptions that stood here are retired (see `ASSUMPTIONS`). Three of the four
200
- * names held exactly: `item_id`, `parent_item_id`, `if_unmodified_since`.
201
- *
202
- * ⛔ **The fourth did not.** `section` is wrong for the item route — see `section`
203
- * below, where the divergence is documented in full. It is left wrong deliberately:
204
- * correcting it changes behaviour, which is a separate change.
205
- *
206
- * A write answers `{ entity, item_id, item_uuid, item_updated_at }`, and
207
- * **`item_uuid` is set on `create` only** — a field this module does not yet name.
208
- * `item_updated_at` is the token to send back as `if_unmodified_since` on the next
209
- * write to that item; omitting it is last-writer-wins, and the check is per item, so
210
- * two people editing different sections do not collide.
211
- *
212
- * *(Historical: these were originally read off `POST /api/sites/{id}/content/items` —
213
- * a different route of the same binary — because the working client of our route
214
- * returns responses unnormalized and reveals no names. The guess was right on three
215
- * of four, which is roughly the hit rate this file exists to make visible.)*
267
+ * Field names on an op and on a write response. **MEASURED.**
268
+ *
269
+ * A single-op write answers `{ entity, item_id, item_uuid, item_updated_at }`; a
270
+ * batch `{ entity, results: [{ item_id, item_uuid, item_updated_at }, …] }`, one
271
+ * result per op, in order. `item_uuid` is set on a create only. ⚠️ **A delete
272
+ * answers `item_id: null`** — the row is gone — so the item a delete removed is
273
+ * known only from the op that named it.
216
274
  */
217
275
  export const FIELD = {
218
- /** Names the target item on an op, and the affected item on a response. */
276
+ /**
277
+ * Names the target item on an op (an integer), the item on a write result, and
278
+ * the stale item on a `409` — the last since 2026-09-18; an older backend's `409`
279
+ * carries only `conflictToken`.
280
+ */
219
281
  item: 'item_id',
220
- /** Placement on a `create`. */
282
+ /** The new item's uuid, on a create's result. */
283
+ itemUuid: 'item_uuid',
284
+ /** Placement under another item, for nested content. */
221
285
  parent: 'parent_item_id',
222
286
  /**
223
- * Which section of the entity an item belongs to.
224
- *
225
- * ⛔ REQUIRED on a create, and its absence is silent. An entity has several
226
- * sections and they are not interchangeable: a rule declared on one — an
227
- * `append_only`, a field set — simply does not apply to an item that landed in
228
- * another. A create with no section is accepted, stored somewhere, and every
229
- * guarantee the author declared is quietly not in force.
230
- *
231
- * ⛔ **DIVERGENT FROM THE BACKEND — MEASURED 2026-09-17. This value is wrong for
232
- * the item route, and is left wrong on purpose until the client change lands.**
287
+ * The section an item-create op writes into — the numeric id. **MEASURED.**
233
288
  *
234
- * The backend takes a section **two different ways, on two different routes**:
235
- *
236
- * | route | how a section is named |
237
- * |---|---|
238
- * | `POST /entities` (create with content) | **by NAME** — `{ items: [{ section: 'profile', … }] }` |
239
- * | `POST /entities/{uuid}/items` (everything after) | **by NUMERIC `section_id`** |
240
- *
241
- * We send `section: '<name>'` on the item route, where the backend wants
242
- * `section_id: <number>`.
243
- *
244
- * ⚠️ **And the cost is larger than the field name**, because the id is needed to
245
- * *find* an item as well as to create one. An entity read returns `items[]` with
246
- * `id`, `section_id` and `updated_at` — **no section name at all**. Code that
247
- * locates an item by name (`items.find(i => i.section === 'content')`) works only
248
- * against a mock that invents the name, and against the real backend returns
249
- * `undefined`: the update silently never happens, or a duplicate is created.
250
- *
251
- * ⇒ A client needs **`GET /api/models/{scope}/{name}`** — `sections[]` with `id`,
252
- * `name`, `kind`, `is_brief`, `parent_section_id` — to resolve a name, plus a
253
- * path-aware resolver for nested sections. That route is **not in `ROUTES`**: it is
254
- * outside the `/entities` lane this module pins, so adding it is a deliberate
255
- * change, not a drive-by.
289
+ * ⭐ **Two routes, two spellings, both MEASURED:** creating an entity names a
290
+ * section by NAME (`SECTION_NAME`), a `parent/child` path for a nested one; the
291
+ * item route after it takes this numeric id. This package resolves a name to its
292
+ * id from the Model's definition (`ROUTES.schema`), so a caller only ever says a
293
+ * name.
256
294
  */
257
- section: 'section',
295
+ section: 'section_id',
258
296
  /** The precondition an op carries. */
259
297
  precondition: 'if_unmodified_since',
260
- /** The item's next token, on a write response. */
298
+ /** The item's next token, on a write result. */
261
299
  token: 'item_updated_at',
262
- /** The item's current token, on a `409`. */
300
+ /** The item's current token, on a stale `409`. */
263
301
  conflictToken: 'current_updated_at',
264
302
  }
265
303
 
266
304
  /**
267
- * The list response. **MEASURED on this exact route** — a working client of
268
- * `GET /api/entities?model=…` documents the body as `{"entities":[],"matched":0}`
269
- * and destructures it that way.
270
- *
271
- * ⭐ `matched` is the count BEFORE paging, which is what makes it worth carrying:
272
- * it is the only thing that can answer "is there more" without a second request.
273
- *
274
- * ⚠️ **An empty list means empty, and has since 2026-08-29.** Before that a lapsed
275
- * session was answered anonymously on content routes — a `200` with an empty list,
276
- * byte-identical to a genuinely empty result — so a signed-out viewer was told
277
- * their content was gone. Every route now answers `401` instead. This package's
278
- * default `onUnauthorized: 'session-lost'` is the correct reading of that, and
279
- * anything here that treats an empty list as "maybe you are logged out" would be
280
- * re-implementing a bug the backend already fixed.
305
+ * The create body — `POST /entities?model=…`. **MEASURED.**
306
+ *
307
+ * `{ items: [{ section, data, parent_item_id? }] }`, each `section` a NAME (or a
308
+ * `parent/child` path). The entity and its items commit together, and the answer
309
+ * is `201` with the entity's row (the list's row shape, below). An unknown section
310
+ * name is `404 { kind: 'section' }`; an empty body makes an empty entity.
311
+ *
312
+ * ⛔ **There is no entity-level data.** An entity's content is always items in its
313
+ * sections; its `brief` is maintained by the backend from the brief section's
314
+ * item. ⛔ And the body's other keys are the backend's own — `uuid`, `owner_id` —
315
+ * so content spread at the top level is at best ignored and at worst read as one
316
+ * of them (`{ uuid: 't-1' }` is a `400`).
317
+ */
318
+ export const CREATE = {
319
+ items: 'items',
320
+ sectionName: 'section',
321
+ }
322
+
323
+ /**
324
+ * The list response — `{ entities: [row, …], matched }`. **MEASURED.**
325
+ *
326
+ * A row: `{ model_uuid, model_name, via, id, uuid, model_id, owner_id, unit_id,
327
+ * sort_date, brief, disabled, created_by, created_at, updated_at }`. A list is
328
+ * brief-only — `brief` is the entity's summary, and there are no items on a row.
329
+ *
330
+ * ⛔ **`matched` counts the rows in THIS answer** — the page, when paging (a
331
+ * `limit=1` over two entities answers `matched: 1`). It is the total only for
332
+ * `paginate=false`, where the answer is the whole slice. There is no total when
333
+ * paging; a page that came back full is the only sign there may be more.
334
+ *
335
+ * `via` names why the viewer can read the row — `owner`, `grant`, `rbac`,
336
+ * `unit_member`, `entitlement`; treat an unknown value as "no opinion". The
337
+ * operator's own rows are `owner` and the rest `rbac` — since 2026-09-18; an older
338
+ * backend answers `rbac` on the operator's own rows too. ⚠️ An empty list means
339
+ * empty: a lapsed session is a `401`, never `200 []`.
281
340
  */
282
341
  export const LIST = {
283
342
  records: 'entities',
284
- /** The count before `limit`/`offset` — the total, not the page. */
285
343
  matched: 'matched',
286
344
  }
287
345
 
288
346
  /**
289
- * ⛔ THE LIST TO HAND BACKEND — everything this package asserts that nobody has
290
- * confirmed. Each entry says what we do, and what breaks if we are wrong.
347
+ * The single-entity read. **MEASURED.**
348
+ *
349
+ * `{ model_uuid, model_name, can_edit, hydrated: { entity, items } }` — `entity`
350
+ * is the row without `model_*`, and each item is
351
+ * `{ id, section_id, parent_item_id, data, item_date, order_number, updated_at }`.
352
+ * `can_edit` is the write gate's own answer for this viewer. Items name their
353
+ * section by id only; the Model's definition maps ids to names.
354
+ *
355
+ * `absent` is one word for not-found and not-permitted, by design: both are
356
+ * `404 { kind: 'entity' }`. So is an entity of ANOTHER Model: `?model=` says what
357
+ * the caller takes the entity to be, and a read or a write naming the wrong one is
358
+ * the same `404` — since 2026-09-18; before that the backend answered, stamped with
359
+ * the Model named.
360
+ */
361
+ export const READ = {
362
+ hydrated: 'hydrated',
363
+ entity: 'entity',
364
+ items: 'items',
365
+ itemId: 'id',
366
+ itemSection: 'section_id',
367
+ itemParent: 'parent_item_id',
368
+ itemToken: 'updated_at',
369
+ canEdit: 'can_edit',
370
+ }
371
+
372
+ /**
373
+ * A Model's definition — `GET /models/@scope/name`. **MEASURED.**
374
+ *
375
+ * `{ model: {…}, sections: [{ id, name, kind, is_brief, parent_section_id,
376
+ * fields, other_data, constraints }] }` — `kind` is `single` or `multi`;
377
+ * `other_data.append_only` marks an insert-only section.
291
378
  *
292
- * ⭐ This is not documentation of the backend. It is a **statement of what we
293
- * pinned**, which is the artifact backend asked for. Confirming one is a deliberate
294
- * edit here plus a moved comment above; `tests/wire.test.js` pins the set so the
295
- * change cannot be quiet.
379
+ * ⚠️ **Readable by the Models the viewer may create, and by the operator.** A
380
+ * member reading entities of a Model only the operator creates gets `404` here —
381
+ * so a member can read those items but cannot learn their section names.
296
382
  */
383
+ export const SCHEMA = {
384
+ sections: 'sections',
385
+ id: 'id',
386
+ name: 'name',
387
+ kind: 'kind',
388
+ parent: 'parent_section_id',
389
+ }
390
+
297
391
  /**
298
- * ⭐ **Four entries were RETIRED on 2026-09-17** — backend confirmed them against its
299
- * own source, and the rule above is to move the note to MEASURED and delete the entry:
392
+ * Refusals this package branches on. **MEASURED.** Problem-JSON:
393
+ * `{ status, title, detail?, …extensions }` — `title` is the stable discriminator.
300
394
  *
301
- * | retired | confirmed as |
395
+ * | answer | means |
302
396
  * |---|---|
303
- * | `write-response-fields` | a write answers `{ entity, item_id, item_uuid, item_updated_at }`; `item_uuid` is set on **create only** |
304
- * | `move-exists` | `move` is an op here and carries a precondition |
305
- * | `move-position` | positioned server-side — `"first"` \| `"last"` \| `{ after }`; the client never computes an order |
306
- * | `op-field-names` | `item_id`, `parent_item_id`, `if_unmodified_since` all confirmed — **but the section field is NOT**, and that is now a recorded DIVERGENCE on `FIELD.section`, not an open question |
397
+ * | `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 |
398
+ * | `409 Append-Only Section` + `section` | an insert-only section: its items can be added and moved, not edited or deleted |
399
+ * | `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 |
400
+ * | `403 Forbidden` + `op`, `target` | may read it, may not do this to it — `edit`, `delete`, `use_model` (create) |
401
+ * | `403 Email Not Verified` | right password, unverified address |
402
+ * | `403 CSRF Header Required` | a cookie-authenticated mutation without `X-Uniweb-Csrf` |
403
+ * | `400 Validation` + `field` | content that does not fit the Model (`field: 'data.minutes'`) |
404
+ * | `404 Not Found` + `kind`, `key` | nothing here for you — not found or not permitted |
405
+ */
406
+ export const PROBLEM = {
407
+ csrf: 'CSRF Header Required',
408
+ stepUp: 'Step-Up Required',
409
+ notVerified: 'Email Not Verified',
410
+ appendOnly: 'Append-Only Section',
411
+ schemaRule: 'Schema Rule Violation',
412
+ }
413
+
414
+ /**
415
+ * ⛔ THE LIST TO HAND BACKEND — everything this package asserts that nobody has
416
+ * confirmed. Each entry says what we do, and what breaks if we are wrong.
307
417
  *
308
- * ⛔ `op-field-names` left this list by being **answered, not by being right**. Three
309
- * of its four names held; the fourth is wrong and is documented where the wrong value
310
- * lives. An assumption that turns out false is not an assumption any more — it is a
311
- * defect, and hiding it in a list of open questions is how it stays unfixed.
418
+ * ⭐ **Empty since 2026-09-18.** The last two were answered by measurement:
419
+ *
420
+ * | retired | answered as |
421
+ * |---|---|
422
+ * | `viewer-unit-signal` | ⛔ false — every member of a site acts in the same unit; see `VIEWER` |
423
+ * | `via-and-depth-compose` | ✅ true — see `PARAM` |
312
424
  *
313
- * What remains below is genuinely unconfirmed.
425
+ * *(Retired 2026-09-17: `write-response-fields`, `move-exists`, `move-position`,
426
+ * `op-field-names` — see `FIELD` and `OP`.)* A new entry needs `we`, `from` and
427
+ * `breaks`; `tests/wire.test.js` pins the set, so adding one is deliberate.
314
428
  */
315
- export const ASSUMPTIONS = [
316
- {
317
- id: 'viewer-unit-signal',
318
- we: "read a viewer's unit membership from `acting_unit_id` on /auth/me, surfaced as `viewer.actingUnitId`",
319
- from: "the field this package already normalizes; whether it is THE membership signal, or one of several, is unconfirmed",
320
- 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',
321
- },
322
- {
323
- id: 'via-and-depth-compose',
324
- we: `'${PARAM.via}' and '${PARAM.depth}' are both valid on a single-entity read, answering different questions`,
325
- from: 'via is this package’s own; depth is what the working client of this route sends. Neither has been seen beside the other',
326
- breaks: 'an entitled read returns the wrong shape, or one parameter silently wins',
327
- },
328
- ]
429
+ export const ASSUMPTIONS = []