@uniweb/api 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -45
- package/bin/uniweb-api-mock.js +34 -0
- package/package.json +10 -3
- package/src/client.js +229 -16
- package/src/hooks/useEntity.js +1 -1
- package/src/hooks/useEntityWriter.js +141 -0
- package/src/hooks/useRecords.js +97 -0
- package/src/index.js +6 -0
- package/src/ledger.js +48 -20
- package/src/mock/index.js +194 -0
- package/src/mock/node.js +106 -0
- package/src/mock/seed.js +51 -0
- package/src/mock/store.js +288 -0
- package/src/wire.js +243 -0
package/src/mock/seed.js
ADDED
|
@@ -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
|
+
]
|