@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/mock/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import { AUTH, ROUTES, PARAM, LIST, FIELD } from '../wire.js'
2
- import { MockStore } from './store.js'
1
+ import { AUTH, ENTITIES, MODELS, PARAM, OP, FIELD, CREATE, PAGE, SCOPE, PROBLEM, GUARDED_OPS } from '../wire.js'
2
+ import { MockStore, isUuid, refuse } from './store.js'
3
3
  import { DEFAULT_SEED } from './seed.js'
4
4
 
5
5
  /**
6
- * A mock service-provider backend for local development.
6
+ * A mock of a site's `api` service, for local development.
7
7
  *
8
8
  * ```js
9
9
  * import { createMockBackend } from '@uniweb/api/mock'
@@ -11,45 +11,45 @@ import { DEFAULT_SEED } from './seed.js'
11
11
  * const response = await mock.fetch(request) // web-standard in, web-standard out
12
12
  * ```
13
13
  *
14
- * ## ⭐ Why this lives in `@uniweb/api` and not in a package of its own
14
+ * ⭐ **It answers what the backend answers, for the routes this package uses** —
15
+ * the same statuses, the same bodies, the same refusal titles, measured against a
16
+ * real backend on 2026-09-18 (`../wire.js` records each). Code written against it
17
+ * should not change when the site goes live. Where the backend is strict — an
18
+ * unknown query parameter, a path id that is not a UUID, a missing field in an
19
+ * op — so is the mock, because a mock that accepts what production refuses turns
20
+ * a local success into a production bug.
15
21
  *
16
- * Its entire value is **fidelity to what this client expects**, and the cheapest
17
- * way to guarantee that is to make drift impossible: it is built from the same
18
- * `../wire.js` the client reads, so a route or field name cannot disagree with the
19
- * caller — they are the same constant. A separate package would need a version
20
- * matrix nobody maintains, and would be wrong quietly.
22
+ * ⭐ **Why it lives in `@uniweb/api`**: it is built from the same `../wire.js` the
23
+ * client reads, so a route or field name cannot disagree with the caller.
21
24
  *
22
- * ⛔ **It ships Node code, and the browser must never reach it.** That is why this
23
- * is a separate export (`@uniweb/api/mock`), never imported by `index.js` or
24
- * `client.js`, and `tests/environment.test.js` walks the import graph from the
25
- * browser entries to keep it that way.
25
+ * ⛔ **It ships Node-free code, but the browser must never reach it** — a separate
26
+ * export (`@uniweb/api/mock`), never imported by `index.js` or `client.js`;
27
+ * `tests/environment.test.js` walks the import graph to keep it that way.
26
28
  *
27
- * ## What it is, and what it is not
29
+ * ## Where it answers
28
30
  *
29
- * ⭐ **It is the executable statement of what this package pins.** Backend asked
30
- * *"tell us what you pin, and we will treat it as a contract"* — this is that
31
- * answer in a form you can run. Where `../wire.js` marks a shape ASSUMED, this
32
- * server implements the assumption, so pointing the same suite at a real `uniwebd`
33
- * measures the delta instead of arguing about it.
31
+ * Its routes are the API's own — `/auth/me`, `/entities` — under whatever the
32
+ * site's `api` address is. It accepts a request with the site's `/_api` prefix
33
+ * still on, and one with an extra `/api` after it (how clients before 0.4
34
+ * composed URLs), so it answers the same whether or not the server mounting it
35
+ * strips its path.
34
36
  *
35
- * ⛔ **It is not a model of the real backend, and no doc may cite it as one.** It
36
- * answers what this client asks. A behaviour it happens to have is evidence about
37
- * this mock and nothing else.
37
+ * ## What it models, and what it does not
38
38
  *
39
- * @param {object} [options]
40
- * @param {object} [options.seed] - accounts, schemas and entities to start from
41
- * @param {string} [options.signedInAs] - a seeded `username` to start signed in as, for a
42
- * demo whose subject is the signed-in view. Development-only by construction, since
43
- * this whole module is; throws if the username is not seeded.
44
- * @param {string} [options.prefix] - the path the API is mounted under (default `/api`)
45
- * Supply `seed.schemas[model].sections` (the framework's LOWERED sections map) and
46
- * item writes are shape-checked: a `many:` section is ENFORCED, everything else is
47
- * recorded on `.diagnostics` and allowed — see `./schema-shape.js` for why that
48
- * asymmetry is deliberate rather than lenient.
39
+ * One operator (`operator: true` on a seeded account) and members; a member reads
40
+ * and writes their own entities and nothing of another's (unless the seed's
41
+ * `memberFloor` says the service lets them), the operator reads and writes all;
42
+ * `creatable_by: 'unit_members'` Models are created by the operator only; sign-up
43
+ * leaves an account unverified until the link in `mock.outbox` is followed. It
44
+ * does not model entitlements behind `via`, nested sections, or second factors.
49
45
  *
50
- * @returns {{ fetch: (request: Request) => Promise<Response>, store: MockStore, diagnostics: object[] }}
46
+ * @param {object} [options]
47
+ * @param {object} [options.seed] - accounts, schemas and entities to start from (see `MockStore`)
48
+ * @param {string} [options.signedInAs] - a seeded `username` to start signed in as. Throws if not seeded.
49
+ * @param {string} [options.prefix] - the path the API is mounted under (default `/_api`), stripped when present
50
+ * @returns {{ fetch: (request: Request) => Promise<Response>, store: MockStore, diagnostics: object[], outbox: object[] }}
51
51
  */
52
- export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/api', signedInAs = null } = {}) {
52
+ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signedInAs = null } = {}) {
53
53
  const store = new MockStore(seed, { signedInAs })
54
54
 
55
55
  const json = (status, body) =>
@@ -60,142 +60,359 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/api', signed
60
60
 
61
61
  /**
62
62
  * A refusal, in problem+JSON. ⚠️ Extension members ride at the TOP LEVEL of the
63
- * document — that is what RFC 7807 says and what `ApiError.fromResponse` reads.
64
- * Nesting them under `extensions` is a mistake that looks right, and one this
65
- * package's own tests made before they were corrected against the parser.
63
+ * document, as RFC 7807 says and `ApiError.fromResponse` reads.
66
64
  */
67
- const problem = ({ status = 400, title = 'Error', detail, ...extensions }) =>
68
- json(status, { status, title, ...(detail ? { detail } : {}), ...extensions })
65
+ const problem = (p) =>
66
+ new Response(JSON.stringify(p), { status: p.status, headers: { 'content-type': 'application/problem+json' } })
69
67
 
70
- const unauthorized = () =>
71
- problem({ status: 401, title: 'Unauthorized', detail: 'sign in to continue' })
68
+ const answer = (outcome) =>
69
+ outcome.problem ? problem(outcome.problem) : json(outcome.status ?? 200, outcome.body)
70
+
71
+ const badRequest = (detail) => problem({ status: 400, title: 'Bad Request', detail })
72
72
 
73
73
  async function body(request) {
74
74
  try {
75
- return await request.json()
75
+ const text = await request.text()
76
+ return text ? JSON.parse(text) : null
76
77
  } catch {
77
- return null
78
+ return undefined
78
79
  }
79
80
  }
80
81
 
82
+ /** The route's own path: the mount stripped, and an older client's extra `/api`. */
83
+ function ownPath(pathname) {
84
+ let path = pathname
85
+ for (const mount of [prefix, '/api']) {
86
+ if (mount && (path === mount || path.startsWith(`${mount}/`))) path = path.slice(mount.length) || '/'
87
+ }
88
+ return path
89
+ }
90
+
91
+ /** `400 Unexpected parameters` for anything a route does not take — the backend's rule. */
92
+ function unexpected(q, allowed) {
93
+ const extra = [...new Set([...q.keys()])].filter((k) => !allowed.includes(k))
94
+ return extra.length ? badRequest(`Unexpected parameters: ${extra.join(', ')}`) : null
95
+ }
96
+
97
+ const int = (q, name, fallback) => {
98
+ if (!q.has(name)) return fallback
99
+ const n = Number(q.get(name))
100
+ return Number.isInteger(n) ? n : undefined
101
+ }
102
+ const bool = (q, name, fallback) => {
103
+ if (!q.has(name)) return fallback
104
+ const v = q.get(name)
105
+ return v === 'true' ? true : v === 'false' ? false : undefined
106
+ }
107
+
108
+ /** The fields a body must carry, in the backend's words when one is missing. */
109
+ function fields(value, names, shape) {
110
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return badRequest(`expected \`${shape}\``)
111
+ for (const name of names) {
112
+ if (typeof value[name] !== 'string') return badRequest(`expected \`${shape}\`: missing field \`${name}\``)
113
+ }
114
+ return null
115
+ }
116
+
117
+ /** Parse an item op the way the backend's tagged union does. `{ op }` or `{ problem }`. */
118
+ function parseOp(raw, where) {
119
+ const fail = (detail) => refuse(400, 'Bad Request', `invalid ${where} body: ${detail}`)
120
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return fail('expected an op object')
121
+ if (raw.kind == null) return fail('missing field `kind`')
122
+ if (!Object.values(OP).includes(raw.kind)) {
123
+ return fail(`unknown variant \`${raw.kind}\`, expected one of \`create\`, \`update\`, \`delete\`, \`move\``)
124
+ }
125
+ const integer = (name, required) => {
126
+ const v = raw[name]
127
+ if (v == null) return required ? `missing field \`${name}\`` : null
128
+ return Number.isInteger(v) ? null : `invalid type: ${JSON.stringify(v)}, expected i64`
129
+ }
130
+ const position = (required) => {
131
+ const p = raw.position
132
+ if (p == null) return required ? 'missing field `position`' : null
133
+ if (p === 'first' || p === 'last') return null
134
+ if (p && typeof p === 'object' && Number.isInteger(p.after)) return null
135
+ return `unknown variant ${JSON.stringify(p)}, expected one of \`first\`, \`last\`, \`after\``
136
+ }
137
+ const checks =
138
+ raw.kind === OP.create
139
+ ? [integer(FIELD.section, true), raw.data === undefined ? 'missing field `data`' : null, integer(FIELD.parent, false), position(false)]
140
+ : raw.kind === OP.update
141
+ ? [integer(FIELD.item, true), raw.data === undefined ? 'missing field `data`' : null]
142
+ : raw.kind === OP.move
143
+ ? [integer(FIELD.item, true), integer(FIELD.parent, false), position(true)]
144
+ : [integer(FIELD.item, true)]
145
+ const first = checks.find(Boolean)
146
+ if (first) return fail(first)
147
+ if (GUARDED_OPS.has(raw.kind) && raw[FIELD.precondition] != null && typeof raw[FIELD.precondition] !== 'string') {
148
+ return fail(`invalid type for \`${FIELD.precondition}\`, expected a timestamp`)
149
+ }
150
+ return { op: raw }
151
+ }
152
+
153
+ const unauthorized = () => problem({ status: 401, title: 'Unauthorized' })
154
+ const notFound = (kind, key) => problem({ status: 404, title: 'Not Found', detail: `${kind} ${key} not found`, kind, key })
155
+
156
+ function locales(q) {
157
+ const raw = (q.get(PARAM.locale) || '').trim()
158
+ return raw ? raw.split(',').map((l) => l.trim()).filter(Boolean) : null
159
+ }
160
+
161
+ /** The entity a path names, checked against `?model=` — or the answer that refuses it. */
162
+ function target(uuid, modelName) {
163
+ if (!isUuid(uuid)) return { response: badRequest(`invalid uuid: ${uuid}`) }
164
+ const model = store.model(modelName)
165
+ if (!model) return { response: notFound('model', modelName) }
166
+ const entity = store.entities.get(uuid)
167
+ if (!entity || entity.model !== model.name) return { response: notFound('entity', uuid) }
168
+ return { entity, model }
169
+ }
170
+
81
171
  async function route(request) {
82
172
  const url = new URL(request.url)
83
- const path = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : url.pathname
173
+ const path = ownPath(url.pathname)
84
174
  const q = url.searchParams
85
175
  const method = request.method.toUpperCase()
176
+ const unsafe = !['GET', 'HEAD', 'OPTIONS'].includes(method)
86
177
 
87
- // ── Identity ────────────────────────────────────────────────────────────
88
- if (path === AUTH.me) {
89
- const viewer = store.viewer()
90
- return viewer ? json(200, viewer) : unauthorized()
91
- }
92
- if (path === AUTH.login && method === 'POST') {
93
- const fields = (await body(request)) || {}
94
- const viewer = store.signIn(fields.username, fields.password)
95
- return viewer ? json(200, viewer) : problem({ status: 401, title: 'Unauthorized', detail: 'wrong username or password' })
96
- }
97
- if (path === AUTH.logout && method === 'POST') {
98
- store.signOut()
99
- return new Response(null, { status: 204 })
100
- }
101
- if (path === AUTH.register && method === 'POST') {
102
- const fields = (await body(request)) || {}
103
- const account = store.register(fields)
104
- return account
105
- ? json(200, { account: { uuid: account.uuid, username: account.username, handle: account.handle } })
106
- : problem({ status: 409, title: 'Conflict', detail: 'that username is taken' })
178
+ // A signed-in mutation must carry the header a cookie-authenticated request
179
+ // needs — this package always sends it, so only a hand-rolled call trips it.
180
+ if (unsafe && store.account && !request.headers.get('x-uniweb-csrf')) {
181
+ return problem({
182
+ status: 403,
183
+ title: PROBLEM.csrf,
184
+ detail: 'a cookie-authenticated POST/PUT/DELETE/PATCH must also send the `X-Uniweb-Csrf` header (any value); the session cookie alone is refused',
185
+ })
107
186
  }
108
- if (path === AUTH.resetRequest && method === 'POST') return new Response(null, { status: 204 })
109
- if (path === AUTH.resetConfirm && method === 'POST') return new Response(null, { status: 204 })
110
- if (path === AUTH.challenge && method === 'POST') {
111
- return problem({ status: 400, title: 'Validation', detail: 'this mock issues no challenge' })
187
+
188
+ // ── Identity ────────────────────────────────────────────────────────────
189
+ if (path.startsWith('/auth/')) {
190
+ if (path === AUTH.verify && method === 'GET') {
191
+ const bad = unexpected(q, ['token'])
192
+ if (bad) return bad
193
+ const token = q.get('token') || ''
194
+ return token ? answer(store.verify(token)) : badRequest('verify requires `token`')
195
+ }
196
+ const bad = unexpected(q, [])
197
+ if (bad) return bad
198
+ if (path === AUTH.me && method === 'GET') {
199
+ const viewer = store.viewer()
200
+ return viewer ? json(200, viewer) : unauthorized()
201
+ }
202
+ if (path === AUTH.login && method === 'POST') {
203
+ const b = await body(request)
204
+ return fields(b, ['username', 'password'], '{"username": ..., "password": ...}') ?? answer(store.signIn(b))
205
+ }
206
+ if (path === AUTH.challenge && method === 'POST') {
207
+ const b = await body(request)
208
+ // The mock issues no second factor, so no challenge token is ever good.
209
+ return fields(b, ['challenge_token', 'code'], '{"challenge_token": ..., "code": ...}') ?? unauthorized()
210
+ }
211
+ if (path === AUTH.logout && method === 'POST') {
212
+ if (!store.account) return unauthorized()
213
+ store.signOut()
214
+ return new Response(null, { status: 204 })
215
+ }
216
+ if (path === AUTH.register && method === 'POST') {
217
+ const b = await body(request)
218
+ return (
219
+ fields(b, ['username', 'email', 'password'], '{"username": ..., "email": ..., "password": ...}') ??
220
+ answer(store.register(b))
221
+ )
222
+ }
223
+ if (path === AUTH.resetRequest && method === 'POST') {
224
+ const b = await body(request)
225
+ return fields(b, ['email'], '{"email": ...}') ?? answer(store.requestReset(b.email))
226
+ }
227
+ if (path === AUTH.resetConfirm && method === 'POST') {
228
+ const b = await body(request)
229
+ return fields(b, ['token', 'new_password'], '{"token": ..., "new_password": ...}') ?? answer(store.confirmReset(b))
230
+ }
112
231
  }
113
232
 
114
- // ── Entities ────────────────────────────────────────────────────────────
115
- // ⭐ Everything below refuses an anonymous caller. That mirrors the real
116
- // route's session invariant, and it is the half a mock is tempted to skip —
117
- // a mock that answered anonymously would make every gate in the app look
118
- // like it worked.
119
- if (path.startsWith(ROUTES.list())) {
233
+ // ── A Model's definition ─────────────────────────────────────────────────
234
+ if (path.startsWith(`${MODELS}/`) && method === 'GET') {
235
+ const bad = unexpected(q, [])
236
+ if (bad) return bad
120
237
  if (!store.account) return unauthorized()
238
+ // Path segments are not percent-decoded — `%40scope` is not `@scope`, as on the backend.
239
+ const name = path.slice(MODELS.length + 1)
240
+ const model = store.model(name)
241
+ return model && store.maySeeSchema(model) ? json(200, store.schemaOf(model)) : notFound('model', name)
242
+ }
121
243
 
122
- const rest = path.slice(ROUTES.list().length)
123
- const model = q.get(PARAM.model)
244
+ // ── Entities ────────────────────────────────────────────────────────────
245
+ if (path === ENTITIES || path.startsWith(`${ENTITIES}/`)) {
246
+ const rest = path.slice(ENTITIES.length)
124
247
 
125
248
  if (rest === '' && method === 'GET') {
126
- if (!model) return problem({ title: 'Validation', detail: `Missing required parameter: ${PARAM.model}` })
127
- const all = q.get(PARAM.paginate) === 'false'
128
- const limit = q.has(PARAM.limit) ? Number(q.get(PARAM.limit)) : undefined
129
- const offset = q.has(PARAM.offset) ? Number(q.get(PARAM.offset)) : undefined
130
- return json(200, store.list({ model, limit, offset, all }))
249
+ const bad = unexpected(q, [PARAM.model, PARAM.scope, PARAM.limit, PARAM.offset, PARAM.paginate, PARAM.locale, PARAM.includeDisabled])
250
+ if (bad) return bad
251
+ if (!q.get(PARAM.model)) return badRequest(`Missing required parameter: ${PARAM.model}`)
252
+ const limit = int(q, PARAM.limit, PAGE.size)
253
+ const offset = int(q, PARAM.offset, 0)
254
+ const paginate = bool(q, PARAM.paginate, true)
255
+ if (limit === undefined || offset === undefined || paginate === undefined || bool(q, PARAM.includeDisabled, false) === undefined) {
256
+ return badRequest('invalid parameter value')
257
+ }
258
+ const scope = q.get(PARAM.scope) || SCOPE.accessible
259
+ if (!Object.values(SCOPE).includes(scope)) return problem({ status: 400, title: 'Bad Request', detail: `invalid scope: ${scope}` })
260
+ if (!store.account) return unauthorized()
261
+ const model = store.model(q.get(PARAM.model))
262
+ if (!model) return notFound('model', q.get(PARAM.model))
263
+ return json(
264
+ 200,
265
+ store.list(model, {
266
+ scope,
267
+ limit: Math.min(Math.max(limit, 0), PAGE.max),
268
+ offset: Math.max(offset, 0),
269
+ paginate,
270
+ locales: locales(q),
271
+ }),
272
+ )
131
273
  }
132
274
 
133
275
  if (rest === '' && method === 'POST') {
134
- if (!model) return problem({ title: 'Validation', detail: `Missing required parameter: ${PARAM.model}` })
276
+ const bad = unexpected(q, [PARAM.model])
277
+ if (bad) return bad
278
+ if (!q.get(PARAM.model)) return badRequest(`Missing required parameter: ${PARAM.model}`)
279
+ if (!store.account) return unauthorized()
280
+ const b = await body(request)
281
+ if (b === undefined || (b !== null && (typeof b !== 'object' || Array.isArray(b)))) return badRequest('invalid create body')
282
+ if (b?.uuid != null && !isUuid(b.uuid)) return badRequest(`invalid create body: UUID parsing failed: ${b.uuid}`)
283
+ const items = b?.[CREATE.items] ?? []
284
+ if (!Array.isArray(items) || items.some((i) => !i || typeof i[CREATE.sectionName] !== 'string')) {
285
+ return badRequest('invalid create body: missing field `section`')
286
+ }
287
+ const model = store.model(q.get(PARAM.model))
288
+ if (!model) return notFound('model', q.get(PARAM.model))
135
289
  if (!store.mayCreate(model)) {
136
- return problem({ status: 403, title: 'Denied', detail: `not permitted to create '${model}'` })
290
+ return problem({
291
+ status: 403,
292
+ title: 'Forbidden',
293
+ detail: `use_model on model ${model.uuid} not permitted`,
294
+ op: 'use_model',
295
+ target: `model ${model.uuid}`,
296
+ })
137
297
  }
138
- return json(200, store.create(model, (await body(request)) || {}))
298
+ // Any other top-level key is ignored, exactly as the backend ignores it.
299
+ return answer(store.create(model, items.map((i) => ({ ...i, data: i.data ?? {} }))))
139
300
  }
140
301
 
141
302
  if (rest === '/delete' && method === 'POST') {
142
- const fields = (await body(request)) || {}
143
- let deleted = 0
144
- for (const uuid of fields.uuids || []) if (store.remove(uuid)) deleted += 1
145
- return json(200, { deleted })
303
+ const bad = unexpected(q, [])
304
+ if (bad) return bad
305
+ if (!store.account) return unauthorized()
306
+ const b = await body(request)
307
+ if (!b || !Array.isArray(b.uuids)) return badRequest('invalid delete body: missing field `uuids`')
308
+ const invalid = b.uuids.find((u) => !isUuid(u))
309
+ if (invalid) return badRequest(`invalid uuid: ${invalid}`)
310
+ const found = b.uuids.map((u) => store.entities.get(u)).filter(Boolean)
311
+ const refused = found.find((e) => !store.mayEdit(e))
312
+ if (refused) {
313
+ return problem({ status: 403, title: 'Forbidden', detail: `delete on entity ${refused.uuid} not permitted`, op: 'delete', target: `entity ${refused.uuid}` })
314
+ }
315
+ for (const e of found) store.remove(e)
316
+ return json(200, { deleted: found.length })
146
317
  }
147
318
 
148
319
  if (rest === '/batch' && method === 'POST') {
149
- const fields = (await body(request)) || {}
150
- const found = (fields.uuids || []).map((u) => store.read(u)).filter(Boolean)
151
- return json(200, { [LIST.records]: found, [LIST.matched]: found.length })
320
+ const bad = unexpected(q, [])
321
+ if (bad) return bad
322
+ if (!store.account) return unauthorized()
323
+ const b = await body(request)
324
+ if (!b || !Array.isArray(b.uuids)) return badRequest('invalid batch body: missing field `uuids`')
325
+ const invalid = b.uuids.find((u) => !isUuid(u))
326
+ if (invalid) return badRequest(`invalid uuid: ${invalid}`)
327
+ // Unreadable ones drop, like missing ones — the batch shows what the caller can see.
328
+ const entities = b.uuids.map((u) => store.entities.get(u)).filter((e) => e && store.mayRead(e))
329
+ return json(200, { entities: entities.map((e) => store.read(e, { canEdit: false })) })
152
330
  }
153
331
 
154
332
  const items = rest.match(/^\/([^/]+)\/items$/)
155
333
  if (items && method === 'POST') {
156
- const entity = store.entities.get(decodeURIComponent(items[1]))
157
- if (!entity) return problem({ status: 404, title: 'NotFound', kind: 'entity' })
334
+ const bad = unexpected(q, [PARAM.model, PARAM.readback])
335
+ if (bad) return bad
336
+ if (!q.get(PARAM.model)) return badRequest(`Missing required parameter: ${PARAM.model}`)
337
+ const readback = bool(q, PARAM.readback, false)
338
+ if (readback === undefined) return badRequest('invalid parameter value')
339
+ if (!store.account) return unauthorized()
158
340
  const payload = await body(request)
159
- const ops = Array.isArray(payload) ? payload : [payload]
160
- const outcome = store.applyOps(entity, ops)
161
- if (!outcome.ok) return problem(outcome.problem)
162
- // One op in, one result out; a batch reports per-op results. Matching the
163
- // request's shape is what lets the ledger absorb either without branching.
164
- return json(200, Array.isArray(payload) ? { results: outcome.results } : outcome.results[0])
341
+ const batch = Array.isArray(payload)
342
+ const parsed = (batch ? payload : [payload]).map((raw) => parseOp(raw, batch ? 'items batch' : 'update_item'))
343
+ const failed = parsed.find((p) => p.problem)
344
+ if (failed) return answer(failed)
345
+ const found = target(items[1], q.get(PARAM.model))
346
+ if (found.response) return found.response
347
+ const { entity, model } = found
348
+ if (!store.mayEdit(entity)) {
349
+ return problem({ status: 403, title: 'Forbidden', detail: `edit on entity ${entity.uuid} not permitted`, op: 'edit', target: `entity ${entity.uuid}` })
350
+ }
351
+ const before = store.core(entity)
352
+ const outcome = store.applyOps(entity, model, parsed.map((p) => p.op))
353
+ if (outcome.problem) return answer(outcome)
354
+ // The entity as it was checked, unless `readback` asked for it after the write.
355
+ const shown = readback ? store.core(entity) : before
356
+ return json(200, batch ? { entity: shown, results: outcome.results } : { entity: shown, ...outcome.results[0] })
165
357
  }
166
358
 
167
359
  const one = rest.match(/^\/([^/]+)$/)
168
- if (one) {
169
- const uuid = decodeURIComponent(one[1])
170
- if (method === 'GET') {
171
- const entity = store.read(uuid)
172
- // ⭐ One word for not-found and not-permitted, by the real design: a
173
- // component renders its paywall on it and never says "deleted".
174
- return entity ? json(200, entity) : problem({ status: 404, title: 'NotFound', kind: 'entity' })
360
+ if (one && method === 'GET') {
361
+ const bad = unexpected(q, [PARAM.model, PARAM.depth, PARAM.maxDepth, PARAM.locale, PARAM.via])
362
+ if (bad) return bad
363
+ if (!q.get(PARAM.model)) return badRequest(`Missing required parameter: ${PARAM.model}`)
364
+ const depth = q.get(PARAM.depth) || 'shallow'
365
+ if (!['none', 'brief', 'shallow', 'deep'].includes(depth)) {
366
+ return badRequest(`invalid depth \`${depth}\`; expected one of: none, brief, shallow, deep`)
367
+ }
368
+ if (!store.account) return unauthorized()
369
+ if (!isUuid(one[1])) return badRequest(`invalid uuid: ${one[1]}`)
370
+ const via = (q.get(PARAM.via) || '').trim()
371
+ if (via && !isUuid(via)) return badRequest(`invalid via uuid: ${via}`)
372
+ const found = target(one[1], q.get(PARAM.model))
373
+ if (found.response) return found.response
374
+ // Not found and not permitted are one answer. The mock models no entitlements:
375
+ // a `via` read is the same read, once the container exists.
376
+ if (!store.mayRead(found.entity)) return notFound('entity', one[1])
377
+ if (via && !store.entities.has(via)) return notFound('entity', one[1])
378
+ return json(200, store.read(found.entity, { locales: locales(q), withItems: depth === 'shallow' || depth === 'deep' }))
379
+ }
380
+
381
+ if (one && method === 'DELETE') {
382
+ const bad = unexpected(q, [PARAM.revRefPolicy])
383
+ if (bad) return bad
384
+ const policy = q.get(PARAM.revRefPolicy) || 'abort'
385
+ if (!['abort', 'orphan_refs'].includes(policy)) {
386
+ return badRequest(`invalid rev_ref_policy \`${policy}\`; expected one of: abort, orphan_refs`)
175
387
  }
176
- if (method === 'DELETE') {
177
- return store.remove(uuid)
178
- ? new Response(null, { status: 204 })
179
- : problem({ status: 404, title: 'NotFound', kind: 'entity' })
388
+ if (!store.account) return unauthorized()
389
+ if (!isUuid(one[1])) return badRequest(`invalid uuid: ${one[1]}`)
390
+ const entity = store.entities.get(one[1])
391
+ if (!entity) return notFound('entity', one[1])
392
+ if (!store.mayEdit(entity)) {
393
+ return problem({ status: 403, title: 'Forbidden', detail: `delete on entity ${entity.uuid} not permitted`, op: 'delete', target: `entity ${entity.uuid}` })
180
394
  }
395
+ store.remove(entity)
396
+ return new Response(null, { status: 204 })
181
397
  }
182
398
  }
183
399
 
184
- return problem({ status: 404, title: 'NotFound', detail: `no route for ${method} ${path}` })
400
+ return problem({ status: 404, title: 'Not Found', detail: `no route for ${method} ${path}` })
185
401
  }
186
402
 
187
403
  return {
188
404
  store,
189
405
  /**
190
- * Writes the mock shape-checked but did not refuse.
191
- *
192
- * ⛔ Each entry is a write whose storage mapping is an OPEN QUESTION
193
- * (`./schema-shape.js`), not one that broke a rule and was let through. Empty
194
- * is the good state; a long list is a map of what we are still guessing about.
406
+ * Writes the mock let through on the seed's say-so — into a section listed as
407
+ * `migration_debt`. Empty is the good state.
195
408
  */
196
409
  get diagnostics() {
197
410
  return store.diagnostics
198
411
  },
412
+ /** The mail the backend would have sent — sign-up verification and password-reset tokens. */
413
+ get outbox() {
414
+ return store.outbox
415
+ },
199
416
  async fetch(request) {
200
417
  try {
201
418
  return await route(request)
package/src/mock/node.js CHANGED
@@ -39,7 +39,9 @@ async function send(response, res) {
39
39
  }
40
40
 
41
41
  /**
42
- * Connect-style middleware — what Vite's `configureServer` takes.
42
+ * Connect-style middleware — what Vite's `configureServer` takes. Mount it at the
43
+ * site's `api` address (`/_api`), which is the base the client composes routes
44
+ * onto: `/_api/auth/me`, `/_api/entities`.
43
45
  *
44
46
  * ```js
45
47
  * // vite.config.js
@@ -73,7 +75,9 @@ export function middleware(mock, { prefix = '/_api' } = {}) {
73
75
  }
74
76
 
75
77
  /**
76
- * A standalone server. Resolves once listening; call `close()` to stop.
78
+ * A standalone server. Resolves once listening; call `close()` to stop. Its routes
79
+ * answer at the root — a site's `api` address for it is the server's URL
80
+ * (`http://localhost:8787`), plus `prefix` when one is given.
77
81
  *
78
82
  * @param {{ fetch: Function }} mock
79
83
  * @param {object} [options]
@@ -1,6 +1,14 @@
1
1
  /**
2
2
  * Field-shape checking for writes, and an HONEST NAME for what we cannot check yet.
3
3
  *
4
+ * ✅ **ANSWERED 2026-09-18 — the open question below.** There is no entity-level
5
+ * data: an entity's content is always items in its sections, a `single` (brief)
6
+ * section's item included, and creating an entity names each item's section. So
7
+ * every write the mock sees is an item write into a declared section, checked by
8
+ * `checkItemWrite`. `checkEntityDataWrite` and the `UNRESOLVED` classification
9
+ * remain for code that imported them; the mock no longer calls them. What follows
10
+ * is kept as the record of how the question stood.
11
+ *
4
12
  * ⭐ **Why this exists.** Until now the mock enforced exactly two things —
5
13
  * `creatable_by` and `append_only` — and performed no field-shape validation at
6
14
  * all. A demo could therefore write `{ minutes: "twelve" }` into a field the