@uniweb/api 0.1.0 → 0.2.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.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The default seed — a conference programme, because it is the shape the demo
3
+ * template uses and a seed nobody edits should still show something.
4
+ *
5
+ * ⭐ **A seed is the mock's whole data model**, and it is a plain object on purpose:
6
+ * a developer edits it in one file, diffs it, and commits it. That is the property
7
+ * a database would take away.
8
+ *
9
+ * ⚠️ `schemas` here lists only what the mock ENFORCES — who may create, and which
10
+ * sections are insert-only. It is not a data-schema and cannot validate content;
11
+ * the real schema lives in the foundation, where the site build reads it.
12
+ */
13
+ export const DEFAULT_SEED = {
14
+ accounts: [
15
+ // The organiser belongs to a unit, so `creatable_by: unit_members` lets them
16
+ // author the programme.
17
+ { username: 'organiser', password: 'organiser', handle: 'organiser', units: ['conf'], roles: ['member'] },
18
+ // The attendee belongs to none — the same rule refuses them, server-side, and
19
+ // that refusal is the demo.
20
+ { username: 'attendee', password: 'attendee', handle: 'attendee', units: [], roles: ['member'] },
21
+ ],
22
+ schemas: {
23
+ '@/track': { creatable_by: 'unit_members' },
24
+ '@/session': { creatable_by: 'unit_members' },
25
+ // Check-ins are insert-only: an attendee may record attending, and nobody —
26
+ // including them — may edit or remove it afterwards.
27
+ '@/attendance': { creatable_by: 'any_user', append_only: ['checkins'] },
28
+ },
29
+ entities: [
30
+ {
31
+ uuid: 'track-main',
32
+ model: '@/track',
33
+ data: { name: 'Main hall' },
34
+ items: [
35
+ { id: 'sess-1', section: 'sessions', data: { title: 'Opening keynote', room: 'Hall A', minutes: 45 } },
36
+ { id: 'sess-2', section: 'sessions', data: { title: 'Designing for the edge', room: 'Hall A', minutes: 30 } },
37
+ { id: 'sess-3', section: 'sessions', data: { title: 'Closing panel', room: 'Hall A', minutes: 60 } },
38
+ ],
39
+ },
40
+ {
41
+ uuid: 'track-workshops',
42
+ model: '@/track',
43
+ data: { name: 'Workshops' },
44
+ items: [
45
+ { id: 'sess-4', section: 'sessions', data: { title: 'Hands-on: foundations', room: 'Room 2', minutes: 90 } },
46
+ ],
47
+ },
48
+ ],
49
+ }
50
+
51
+ export default DEFAULT_SEED
@@ -0,0 +1,288 @@
1
+ import { FIELD, OP } from '../wire.js'
2
+
3
+ /**
4
+ * The mock's state — accounts, one session, entities and their items.
5
+ *
6
+ * ⭐ **Seeded fixtures plus in-memory mutation, and deliberately not a database.**
7
+ * A mock's job is fidelity to what `@uniweb/api` *expects*, not to how a real store
8
+ * is built. Reach for SQLite and the mock grows a schema, then migrations that
9
+ * mirror someone else's, and it stops being a fixture and starts being a second
10
+ * implementation nobody asked for — one that will drift and be believed anyway.
11
+ *
12
+ * ⛔ **It is also not a model of `uniwebd`.** Nothing here is evidence about the
13
+ * real backend. It answers what this client asks, in the shapes this client's own
14
+ * tests assert, and where those shapes are guesses they are guesses here too —
15
+ * see `../wire.js` § ASSUMPTIONS.
16
+ *
17
+ * What it *does* enforce is the part a demo would otherwise fake: `creatable_by`
18
+ * and `append_only` are checked server-side, so a foundation that hides a button
19
+ * still cannot write. That is the difference between showing a permission model
20
+ * and asserting one.
21
+ */
22
+
23
+ let counter = 0
24
+ const nextId = (prefix) => `${prefix}-${(counter += 1)}`
25
+ const now = () => new Date().toISOString()
26
+
27
+ /** A token that changes on every write — the shape of the value does not matter, only that it moves. */
28
+ const stamp = () => `${Date.now().toString(36)}-${(counter += 1).toString(36)}`
29
+
30
+ export class MockStore {
31
+ /**
32
+ * @param {object} seed
33
+ * @param {object[]} [seed.accounts] - `{ username, password, handle, roles?, units? }`
34
+ * @param {object} [seed.schemas] - `{ '@/session': { creatable_by?, append_only? } }`
35
+ * @param {object[]} [seed.entities] - `{ uuid?, model, data?, items? }`
36
+ */
37
+ constructor(seed = {}) {
38
+ this.accounts = (seed.accounts || []).map((a) => ({
39
+ uuid: a.uuid || nextId('acct'),
40
+ username: a.username,
41
+ password: a.password,
42
+ handle: a.handle || a.username,
43
+ roles: a.roles || ['member'],
44
+ units: a.units || [],
45
+ ...a,
46
+ }))
47
+ // What the mock knows about a Model: only the two things it must ENFORCE.
48
+ // Everything else about a schema is the site's business, not the server's.
49
+ this.schemas = seed.schemas || {}
50
+ this.entities = new Map()
51
+ for (const e of seed.entities || []) this.seedEntity(e)
52
+ /** The one session. A mock serves one developer, so one is the honest number. */
53
+ this.session = null
54
+ this.resets = new Map()
55
+ }
56
+
57
+ seedEntity({ uuid, model, data = {}, items = [], owner = null }) {
58
+ const id = uuid || nextId('ent')
59
+ this.entities.set(id, {
60
+ uuid: id,
61
+ model,
62
+ owner,
63
+ data,
64
+ updated_at: now(),
65
+ items: items.map((item) => this.makeItem(item)),
66
+ })
67
+ return this.entities.get(id)
68
+ }
69
+
70
+ makeItem({ section = 'items', data = {}, parent = null, id } = {}) {
71
+ return {
72
+ [FIELD.item]: id || nextId('item'),
73
+ section,
74
+ [FIELD.parent]: parent,
75
+ data,
76
+ created_at: now(),
77
+ [FIELD.token]: stamp(),
78
+ }
79
+ }
80
+
81
+ // ── Identity ────────────────────────────────────────────────────────────────
82
+
83
+ signIn(username, password) {
84
+ const account = this.accounts.find((a) => a.username === username)
85
+ if (!account || account.password !== password) return null
86
+ this.session = { account, at: now() }
87
+ return this.viewer()
88
+ }
89
+
90
+ signOut() {
91
+ this.session = null
92
+ }
93
+
94
+ register(fields) {
95
+ if (this.accounts.some((a) => a.username === fields.username)) return null
96
+ const account = {
97
+ uuid: nextId('acct'),
98
+ handle: fields.handle || fields.username,
99
+ roles: ['member'],
100
+ units: [],
101
+ ...fields,
102
+ }
103
+ this.accounts.push(account)
104
+ return account
105
+ }
106
+
107
+ /**
108
+ * The viewer, in the shape `/auth/me` answers.
109
+ *
110
+ * ⚠️ `acting_unit_id` is the unit signal, and it is the field the CLIENT already
111
+ * models (`viewer.actingUnitId`) — so a UI asks the package rather than inventing
112
+ * its own idea of membership. A mock that omitted it would push every consumer to
113
+ * invent one, which is how two apps end up disagreeing about who an organiser is.
114
+ */
115
+ viewer() {
116
+ if (!this.session) return null
117
+ const { uuid, username, handle, roles, units } = this.session.account
118
+ return {
119
+ account: { uuid, username, handle },
120
+ roles,
121
+ acting_unit_id: units?.length ? units[0] : null,
122
+ }
123
+ }
124
+
125
+ get account() {
126
+ return this.session?.account ?? null
127
+ }
128
+
129
+ // ── The rules the mock actually enforces ────────────────────────────────────
130
+
131
+ /**
132
+ * May the viewer create entities of this Model?
133
+ *
134
+ * ⭐ The default is OPEN — anyone with an account — and only a schema's
135
+ * `creatable_by` narrows it. That matches the real store, and it matters that the
136
+ * mock copies the DIRECTION rather than inventing a safer one: a demo whose mock
137
+ * denies by default would hide exactly the mistake `creatable_by` exists to
138
+ * prevent, and someone would ship a Model that anyone can write to having
139
+ * "tested" it here.
140
+ */
141
+ mayCreate(model) {
142
+ if (!this.account) return false
143
+ const rule = this.schemas[model]?.creatable_by || 'any_user'
144
+ if (rule === 'any_user') return true
145
+ if (rule === 'unit_members') return (this.account.units || []).length > 0
146
+ return false
147
+ }
148
+
149
+ /** Is this section insert-only? Existing items may not be edited or removed. */
150
+ isAppendOnly(model, section) {
151
+ const decl = this.schemas[model]?.append_only
152
+ if (decl === true) return true
153
+ return Array.isArray(decl) ? decl.includes(section) : false
154
+ }
155
+
156
+ // ── Reads ───────────────────────────────────────────────────────────────────
157
+
158
+ list({ model, limit, offset, all }) {
159
+ // Scoped by the session the way the real route is: what the viewer may see.
160
+ // A mock that returned everything would make an entitlement bug invisible.
161
+ const rows = [...this.entities.values()].filter(
162
+ (e) => e.model === model && (e.owner === null || e.owner === this.account?.uuid),
163
+ )
164
+ const matched = rows.length
165
+ const page = all ? rows : rows.slice(offset || 0, (offset || 0) + (limit ?? rows.length))
166
+ return { entities: page.map((e) => this.hydrate(e)), matched }
167
+ }
168
+
169
+ read(uuid) {
170
+ const entity = this.entities.get(uuid)
171
+ if (!entity) return null
172
+ if (entity.owner && entity.owner !== this.account?.uuid) return null
173
+ return this.hydrate(entity)
174
+ }
175
+
176
+ hydrate(entity) {
177
+ return {
178
+ uuid: entity.uuid,
179
+ model: entity.model,
180
+ ...entity.data,
181
+ items: entity.items.map((i) => ({ ...i })),
182
+ }
183
+ }
184
+
185
+ // ── Writes ──────────────────────────────────────────────────────────────────
186
+
187
+ create(model, data) {
188
+ const entity = this.seedEntity({ model, data, owner: this.account?.uuid ?? null })
189
+ return this.hydrate(entity)
190
+ }
191
+
192
+ remove(uuid) {
193
+ return this.entities.delete(uuid)
194
+ }
195
+
196
+ /**
197
+ * Apply one op. Returns `{ ok, result }` or `{ ok: false, problem }` — the caller
198
+ * turns a problem into the response, so a batch can stop at the first one and
199
+ * report which op failed.
200
+ */
201
+ applyOp(entity, op) {
202
+ const kind = op?.kind
203
+ const itemId = op?.[FIELD.item]
204
+ const item = itemId != null ? entity.items.find((i) => String(i[FIELD.item]) === String(itemId)) : null
205
+
206
+ if (kind !== OP.create) {
207
+ if (!item) {
208
+ return { ok: false, problem: { status: 404, title: 'NotFound', kind: 'item', [FIELD.item]: itemId } }
209
+ }
210
+ // Append-only guards EDIT and DELETE. Not `move`: `created_at` is the
211
+ // chronology and a reader orders by it, so repositioning loses no truth.
212
+ if (kind !== OP.move && this.isAppendOnly(entity.model, item.section)) {
213
+ return {
214
+ ok: false,
215
+ problem: { status: 409, title: 'AppendOnly', detail: `items of '${item.section}' may be added but not changed`, [FIELD.item]: itemId },
216
+ }
217
+ }
218
+ const expected = op?.[FIELD.precondition]
219
+ if (expected != null && expected !== item[FIELD.token]) {
220
+ return {
221
+ ok: false,
222
+ problem: { status: 409, title: 'Conflict', [FIELD.item]: itemId, [FIELD.conflictToken]: item[FIELD.token] },
223
+ }
224
+ }
225
+ }
226
+
227
+ if (kind === OP.create) {
228
+ // ⛔ No default. A create with no section is a client bug, and defaulting it
229
+ // would place the item outside the rules its author declared — silently.
230
+ if (!op[FIELD.section]) {
231
+ return { ok: false, problem: { status: 400, title: 'Validation', detail: 'create needs a section' } }
232
+ }
233
+ const made = this.makeItem({ section: op[FIELD.section], data: op.data, parent: op[FIELD.parent] ?? null })
234
+ this.place(entity, made, op.position)
235
+ return { ok: true, result: { [FIELD.item]: made[FIELD.item], [FIELD.token]: made[FIELD.token] } }
236
+ }
237
+ if (kind === OP.update) {
238
+ // Whole-data replace, like the real write: round-trip what you do not edit.
239
+ item.data = op.data ?? {}
240
+ item[FIELD.token] = stamp()
241
+ return { ok: true, result: { [FIELD.item]: item[FIELD.item], [FIELD.token]: item[FIELD.token] } }
242
+ }
243
+ if (kind === OP.delete) {
244
+ entity.items = entity.items.filter((i) => i !== item)
245
+ // A null token is how a delete reports itself, so a ledger forgets the item.
246
+ return { ok: true, result: { [FIELD.item]: item[FIELD.item], [FIELD.token]: null } }
247
+ }
248
+ if (kind === OP.move) {
249
+ entity.items = entity.items.filter((i) => i !== item)
250
+ this.place(entity, item, op.position)
251
+ item[FIELD.token] = stamp()
252
+ return { ok: true, result: { [FIELD.item]: item[FIELD.item], [FIELD.token]: item[FIELD.token] } }
253
+ }
254
+ return { ok: false, problem: { status: 400, title: 'Validation', detail: `unknown op kind '${kind}'` } }
255
+ }
256
+
257
+ /** Ordering is the server's: `'first' | 'last' | { after }`, never a number from the client. */
258
+ place(entity, item, position) {
259
+ if (position === 'first') {
260
+ entity.items.unshift(item)
261
+ return
262
+ }
263
+ if (position && typeof position === 'object' && position.after != null) {
264
+ const at = entity.items.findIndex((i) => String(i[FIELD.item]) === String(position.after))
265
+ if (at >= 0) {
266
+ entity.items.splice(at + 1, 0, item)
267
+ return
268
+ }
269
+ }
270
+ entity.items.push(item)
271
+ }
272
+
273
+ /** A batch is all-or-nothing: apply to a copy, and keep it only if every op lands. */
274
+ applyOps(entity, ops) {
275
+ const snapshot = entity.items.map((i) => ({ ...i }))
276
+ const results = []
277
+ for (const op of ops) {
278
+ const outcome = this.applyOp(entity, op)
279
+ if (!outcome.ok) {
280
+ entity.items = snapshot
281
+ return { ok: false, problem: outcome.problem }
282
+ }
283
+ results.push(outcome.result)
284
+ }
285
+ entity.updated_at = now()
286
+ return { ok: true, results }
287
+ }
288
+ }
package/src/wire.js ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The wire — every shape this package asserts about the backend, in one place.
3
+ *
4
+ * ## Why this module exists
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:
21
+ *
22
+ * | | means |
23
+ * |---|---|
24
+ * | **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 |
26
+ * | **ASSUMED** | ⛔ **we are building ahead. Nobody has confirmed this.** |
27
+ *
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
+ * @module @uniweb/api/wire
41
+ */
42
+
43
+ /**
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."*
51
+ *
52
+ * ⛔ Do not add a route here that does not begin `/entities`, or an auth route.
53
+ */
54
+ export const ENTITIES = '/entities'
55
+
56
+ /**
57
+ * Auth. **MEASURED** — shipped in `@uniweb/api@0.1.0` and exercised by the live
58
+ * suite (`tests/live/`) against a real `uniwebd`.
59
+ */
60
+ export const AUTH = {
61
+ me: '/auth/me',
62
+ login: '/auth/login',
63
+ challenge: '/auth/login/challenge',
64
+ logout: '/auth/logout',
65
+ register: '/auth/register',
66
+ resetRequest: '/auth/reset/request',
67
+ resetConfirm: '/auth/reset/confirm',
68
+ }
69
+
70
+ /**
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.
73
+ *
74
+ * ⚠️ Measured means *the route exists and answers*. It does **not** mean this
75
+ * package has confirmed the response bodies — see `ASSUMPTIONS`.
76
+ */
77
+ export const ROUTES = {
78
+ /** `GET /entities?model=…` — the door. RULED: this one, not the site door. */
79
+ list: () => ENTITIES,
80
+ /** `GET /entities/{uuid}?model=…` — one hydrated entity. */
81
+ read: (uuid) => `${ENTITIES}/${encodeURIComponent(uuid)}`,
82
+ /** `POST /entities/batch` — many hydrated entities in one call. */
83
+ readBatch: () => `${ENTITIES}/batch`,
84
+ /** `POST /entities?model=…` — create an entity, optionally with its items. */
85
+ 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. */
91
+ removeBatch: () => `${ENTITIES}/delete`,
92
+ }
93
+
94
+ /**
95
+ * Query parameter names. **MEASURED**, with one open question.
96
+ *
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`.
102
+ */
103
+ export const PARAM = {
104
+ model: 'model',
105
+ scope: 'scope',
106
+ limit: 'limit',
107
+ offset: 'offset',
108
+ locale: 'locale',
109
+ paginate: 'paginate',
110
+ via: 'via',
111
+ depth: 'depth',
112
+ maxDepth: 'max_depth',
113
+ readback: 'readback',
114
+ revRefPolicy: 'rev_ref_policy',
115
+ }
116
+
117
+ /**
118
+ * Item ops. The kinds, and which of them carry a precondition.
119
+ *
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.
124
+ *
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.
130
+ */
131
+ export const OP = {
132
+ create: 'create',
133
+ update: 'update',
134
+ delete: 'delete',
135
+ move: 'move',
136
+ }
137
+
138
+ /** Ops that carry `if_unmodified_since`. `create` has no target to guard. */
139
+ export const GUARDED_OPS = new Set([OP.update, OP.delete, OP.move])
140
+
141
+ /**
142
+ * Field names on an op and on a write response.
143
+ *
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.
151
+ */
152
+ export const FIELD = {
153
+ /** Names the target item on an op, and the affected item on a response. */
154
+ item: 'item_id',
155
+ /** Placement on a `create`. */
156
+ parent: 'parent_item_id',
157
+ /**
158
+ * Which section of the entity an item belongs to.
159
+ *
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.
165
+ */
166
+ section: 'section',
167
+ /** The precondition an op carries. */
168
+ precondition: 'if_unmodified_since',
169
+ /** The item's next token, on a write response. */
170
+ token: 'item_updated_at',
171
+ /** The item's current token, on a `409`. */
172
+ conflictToken: 'current_updated_at',
173
+ }
174
+
175
+ /**
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.
179
+ *
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.
182
+ *
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.
190
+ */
191
+ export const LIST = {
192
+ records: 'entities',
193
+ /** The count before `limit`/`offset` — the total, not the page. */
194
+ matched: 'matched',
195
+ }
196
+
197
+ /**
198
+ * ⛔ THE LIST TO HAND BACKEND — everything this package asserts that nobody has
199
+ * confirmed. Each entry says what we do, and what breaks if we are wrong.
200
+ *
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
+ ]