@uniweb/api 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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,44 @@ 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; every
40
+ * signed-in account reads every entity, the owner and the operator write it;
41
+ * `creatable_by: 'unit_members'` Models are created by the operator only; sign-up
42
+ * leaves an account unverified until the link in `mock.outbox` is followed. It
43
+ * does not model entitlements behind `via`, nested sections, or second factors.
49
44
  *
50
- * @returns {{ fetch: (request: Request) => Promise<Response>, store: MockStore, diagnostics: object[] }}
45
+ * @param {object} [options]
46
+ * @param {object} [options.seed] - accounts, schemas and entities to start from (see `MockStore`)
47
+ * @param {string} [options.signedInAs] - a seeded `username` to start signed in as. Throws if not seeded.
48
+ * @param {string} [options.prefix] - the path the API is mounted under (default `/_api`), stripped when present
49
+ * @returns {{ fetch: (request: Request) => Promise<Response>, store: MockStore, diagnostics: object[], outbox: object[] }}
51
50
  */
52
- export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/api', signedInAs = null } = {}) {
51
+ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signedInAs = null } = {}) {
53
52
  const store = new MockStore(seed, { signedInAs })
54
53
 
55
54
  const json = (status, body) =>
@@ -60,142 +59,357 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/api', signed
60
59
 
61
60
  /**
62
61
  * 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.
62
+ * document, as RFC 7807 says and `ApiError.fromResponse` reads.
66
63
  */
67
- const problem = ({ status = 400, title = 'Error', detail, ...extensions }) =>
68
- json(status, { status, title, ...(detail ? { detail } : {}), ...extensions })
64
+ const problem = (p) =>
65
+ new Response(JSON.stringify(p), { status: p.status, headers: { 'content-type': 'application/problem+json' } })
69
66
 
70
- const unauthorized = () =>
71
- problem({ status: 401, title: 'Unauthorized', detail: 'sign in to continue' })
67
+ const answer = (outcome) =>
68
+ outcome.problem ? problem(outcome.problem) : json(outcome.status ?? 200, outcome.body)
69
+
70
+ const badRequest = (detail) => problem({ status: 400, title: 'Bad Request', detail })
72
71
 
73
72
  async function body(request) {
74
73
  try {
75
- return await request.json()
74
+ const text = await request.text()
75
+ return text ? JSON.parse(text) : null
76
76
  } catch {
77
- return null
77
+ return undefined
78
78
  }
79
79
  }
80
80
 
81
+ /** The route's own path: the mount stripped, and an older client's extra `/api`. */
82
+ function ownPath(pathname) {
83
+ let path = pathname
84
+ for (const mount of [prefix, '/api']) {
85
+ if (mount && (path === mount || path.startsWith(`${mount}/`))) path = path.slice(mount.length) || '/'
86
+ }
87
+ return path
88
+ }
89
+
90
+ /** `400 Unexpected parameters` for anything a route does not take — the backend's rule. */
91
+ function unexpected(q, allowed) {
92
+ const extra = [...new Set([...q.keys()])].filter((k) => !allowed.includes(k))
93
+ return extra.length ? badRequest(`Unexpected parameters: ${extra.join(', ')}`) : null
94
+ }
95
+
96
+ const int = (q, name, fallback) => {
97
+ if (!q.has(name)) return fallback
98
+ const n = Number(q.get(name))
99
+ return Number.isInteger(n) ? n : undefined
100
+ }
101
+ const bool = (q, name, fallback) => {
102
+ if (!q.has(name)) return fallback
103
+ const v = q.get(name)
104
+ return v === 'true' ? true : v === 'false' ? false : undefined
105
+ }
106
+
107
+ /** The fields a body must carry, in the backend's words when one is missing. */
108
+ function fields(value, names, shape) {
109
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return badRequest(`expected \`${shape}\``)
110
+ for (const name of names) {
111
+ if (typeof value[name] !== 'string') return badRequest(`expected \`${shape}\`: missing field \`${name}\``)
112
+ }
113
+ return null
114
+ }
115
+
116
+ /** Parse an item op the way the backend's tagged union does. `{ op }` or `{ problem }`. */
117
+ function parseOp(raw, where) {
118
+ const fail = (detail) => refuse(400, 'Bad Request', `invalid ${where} body: ${detail}`)
119
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return fail('expected an op object')
120
+ if (raw.kind == null) return fail('missing field `kind`')
121
+ if (!Object.values(OP).includes(raw.kind)) {
122
+ return fail(`unknown variant \`${raw.kind}\`, expected one of \`create\`, \`update\`, \`delete\`, \`move\``)
123
+ }
124
+ const integer = (name, required) => {
125
+ const v = raw[name]
126
+ if (v == null) return required ? `missing field \`${name}\`` : null
127
+ return Number.isInteger(v) ? null : `invalid type: ${JSON.stringify(v)}, expected i64`
128
+ }
129
+ const position = (required) => {
130
+ const p = raw.position
131
+ if (p == null) return required ? 'missing field `position`' : null
132
+ if (p === 'first' || p === 'last') return null
133
+ if (p && typeof p === 'object' && Number.isInteger(p.after)) return null
134
+ return `unknown variant ${JSON.stringify(p)}, expected one of \`first\`, \`last\`, \`after\``
135
+ }
136
+ const checks =
137
+ raw.kind === OP.create
138
+ ? [integer(FIELD.section, true), raw.data === undefined ? 'missing field `data`' : null, integer(FIELD.parent, false), position(false)]
139
+ : raw.kind === OP.update
140
+ ? [integer(FIELD.item, true), raw.data === undefined ? 'missing field `data`' : null]
141
+ : raw.kind === OP.move
142
+ ? [integer(FIELD.item, true), integer(FIELD.parent, false), position(true)]
143
+ : [integer(FIELD.item, true)]
144
+ const first = checks.find(Boolean)
145
+ if (first) return fail(first)
146
+ if (GUARDED_OPS.has(raw.kind) && raw[FIELD.precondition] != null && typeof raw[FIELD.precondition] !== 'string') {
147
+ return fail(`invalid type for \`${FIELD.precondition}\`, expected a timestamp`)
148
+ }
149
+ return { op: raw }
150
+ }
151
+
152
+ const unauthorized = () => problem({ status: 401, title: 'Unauthorized' })
153
+ const notFound = (kind, key) => problem({ status: 404, title: 'Not Found', detail: `${kind} ${key} not found`, kind, key })
154
+
155
+ function locales(q) {
156
+ const raw = (q.get(PARAM.locale) || '').trim()
157
+ return raw ? raw.split(',').map((l) => l.trim()).filter(Boolean) : null
158
+ }
159
+
160
+ /** The entity a path names, checked against `?model=` — or the answer that refuses it. */
161
+ function target(uuid, modelName) {
162
+ if (!isUuid(uuid)) return { response: badRequest(`invalid uuid: ${uuid}`) }
163
+ const model = store.model(modelName)
164
+ if (!model) return { response: notFound('model', modelName) }
165
+ const entity = store.entities.get(uuid)
166
+ if (!entity || entity.model !== model.name) return { response: notFound('entity', uuid) }
167
+ return { entity, model }
168
+ }
169
+
81
170
  async function route(request) {
82
171
  const url = new URL(request.url)
83
- const path = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : url.pathname
172
+ const path = ownPath(url.pathname)
84
173
  const q = url.searchParams
85
174
  const method = request.method.toUpperCase()
175
+ const unsafe = !['GET', 'HEAD', 'OPTIONS'].includes(method)
86
176
 
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' })
177
+ // A signed-in mutation must carry the header a cookie-authenticated request
178
+ // needs — this package always sends it, so only a hand-rolled call trips it.
179
+ if (unsafe && store.account && !request.headers.get('x-uniweb-csrf')) {
180
+ return problem({
181
+ status: 403,
182
+ title: PROBLEM.csrf,
183
+ detail: 'a cookie-authenticated POST/PUT/DELETE/PATCH must also send the `X-Uniweb-Csrf` header (any value); the session cookie alone is refused',
184
+ })
107
185
  }
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' })
186
+
187
+ // ── Identity ────────────────────────────────────────────────────────────
188
+ if (path.startsWith('/auth/')) {
189
+ if (path === AUTH.verify && method === 'GET') {
190
+ const bad = unexpected(q, ['token'])
191
+ if (bad) return bad
192
+ const token = q.get('token') || ''
193
+ return token ? answer(store.verify(token)) : badRequest('verify requires `token`')
194
+ }
195
+ const bad = unexpected(q, [])
196
+ if (bad) return bad
197
+ if (path === AUTH.me && method === 'GET') {
198
+ const viewer = store.viewer()
199
+ return viewer ? json(200, viewer) : unauthorized()
200
+ }
201
+ if (path === AUTH.login && method === 'POST') {
202
+ const b = await body(request)
203
+ return fields(b, ['username', 'password'], '{"username": ..., "password": ...}') ?? answer(store.signIn(b))
204
+ }
205
+ if (path === AUTH.challenge && method === 'POST') {
206
+ const b = await body(request)
207
+ // The mock issues no second factor, so no challenge token is ever good.
208
+ return fields(b, ['challenge_token', 'code'], '{"challenge_token": ..., "code": ...}') ?? unauthorized()
209
+ }
210
+ if (path === AUTH.logout && method === 'POST') {
211
+ if (!store.account) return unauthorized()
212
+ store.signOut()
213
+ return new Response(null, { status: 204 })
214
+ }
215
+ if (path === AUTH.register && method === 'POST') {
216
+ const b = await body(request)
217
+ return (
218
+ fields(b, ['username', 'email', 'password'], '{"username": ..., "email": ..., "password": ...}') ??
219
+ answer(store.register(b))
220
+ )
221
+ }
222
+ if (path === AUTH.resetRequest && method === 'POST') {
223
+ const b = await body(request)
224
+ return fields(b, ['email'], '{"email": ...}') ?? answer(store.requestReset(b.email))
225
+ }
226
+ if (path === AUTH.resetConfirm && method === 'POST') {
227
+ const b = await body(request)
228
+ return fields(b, ['token', 'new_password'], '{"token": ..., "new_password": ...}') ?? answer(store.confirmReset(b))
229
+ }
112
230
  }
113
231
 
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())) {
232
+ // ── A Model's definition ─────────────────────────────────────────────────
233
+ if (path.startsWith(`${MODELS}/`) && method === 'GET') {
234
+ const bad = unexpected(q, [])
235
+ if (bad) return bad
120
236
  if (!store.account) return unauthorized()
237
+ // Path segments are not percent-decoded — `%40scope` is not `@scope`, as on the backend.
238
+ const name = path.slice(MODELS.length + 1)
239
+ const model = store.model(name)
240
+ return model && store.maySeeSchema(model) ? json(200, store.schemaOf(model)) : notFound('model', name)
241
+ }
121
242
 
122
- const rest = path.slice(ROUTES.list().length)
123
- const model = q.get(PARAM.model)
243
+ // ── Entities ────────────────────────────────────────────────────────────
244
+ if (path === ENTITIES || path.startsWith(`${ENTITIES}/`)) {
245
+ const rest = path.slice(ENTITIES.length)
124
246
 
125
247
  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 }))
248
+ const bad = unexpected(q, [PARAM.model, PARAM.scope, PARAM.limit, PARAM.offset, PARAM.paginate, PARAM.locale, PARAM.includeDisabled])
249
+ if (bad) return bad
250
+ if (!q.get(PARAM.model)) return badRequest(`Missing required parameter: ${PARAM.model}`)
251
+ const limit = int(q, PARAM.limit, PAGE.size)
252
+ const offset = int(q, PARAM.offset, 0)
253
+ const paginate = bool(q, PARAM.paginate, true)
254
+ if (limit === undefined || offset === undefined || paginate === undefined || bool(q, PARAM.includeDisabled, false) === undefined) {
255
+ return badRequest('invalid parameter value')
256
+ }
257
+ const scope = q.get(PARAM.scope) || SCOPE.accessible
258
+ if (!Object.values(SCOPE).includes(scope)) return problem({ status: 400, title: 'Bad Request', detail: `invalid scope: ${scope}` })
259
+ if (!store.account) return unauthorized()
260
+ const model = store.model(q.get(PARAM.model))
261
+ if (!model) return notFound('model', q.get(PARAM.model))
262
+ return json(
263
+ 200,
264
+ store.list(model, {
265
+ scope,
266
+ limit: Math.min(Math.max(limit, 0), PAGE.max),
267
+ offset: Math.max(offset, 0),
268
+ paginate,
269
+ locales: locales(q),
270
+ }),
271
+ )
131
272
  }
132
273
 
133
274
  if (rest === '' && method === 'POST') {
134
- if (!model) return problem({ title: 'Validation', detail: `Missing required parameter: ${PARAM.model}` })
275
+ const bad = unexpected(q, [PARAM.model])
276
+ if (bad) return bad
277
+ if (!q.get(PARAM.model)) return badRequest(`Missing required parameter: ${PARAM.model}`)
278
+ if (!store.account) return unauthorized()
279
+ const b = await body(request)
280
+ if (b === undefined || (b !== null && (typeof b !== 'object' || Array.isArray(b)))) return badRequest('invalid create body')
281
+ if (b?.uuid != null && !isUuid(b.uuid)) return badRequest(`invalid create body: UUID parsing failed: ${b.uuid}`)
282
+ const items = b?.[CREATE.items] ?? []
283
+ if (!Array.isArray(items) || items.some((i) => !i || typeof i[CREATE.sectionName] !== 'string')) {
284
+ return badRequest('invalid create body: missing field `section`')
285
+ }
286
+ const model = store.model(q.get(PARAM.model))
287
+ if (!model) return notFound('model', q.get(PARAM.model))
135
288
  if (!store.mayCreate(model)) {
136
- return problem({ status: 403, title: 'Denied', detail: `not permitted to create '${model}'` })
289
+ return problem({
290
+ status: 403,
291
+ title: 'Forbidden',
292
+ detail: `use_model on model ${model.uuid} not permitted`,
293
+ op: 'use_model',
294
+ target: `model ${model.uuid}`,
295
+ })
137
296
  }
138
- return json(200, store.create(model, (await body(request)) || {}))
297
+ // Any other top-level key is ignored, exactly as the backend ignores it.
298
+ return answer(store.create(model, items.map((i) => ({ ...i, data: i.data ?? {} }))))
139
299
  }
140
300
 
141
301
  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 })
302
+ const bad = unexpected(q, [])
303
+ if (bad) return bad
304
+ if (!store.account) return unauthorized()
305
+ const b = await body(request)
306
+ if (!b || !Array.isArray(b.uuids)) return badRequest('invalid delete body: missing field `uuids`')
307
+ const invalid = b.uuids.find((u) => !isUuid(u))
308
+ if (invalid) return badRequest(`invalid uuid: ${invalid}`)
309
+ const found = b.uuids.map((u) => store.entities.get(u)).filter(Boolean)
310
+ const refused = found.find((e) => !store.mayEdit(e))
311
+ if (refused) {
312
+ return problem({ status: 403, title: 'Forbidden', detail: `delete on entity ${refused.uuid} not permitted`, op: 'delete', target: `entity ${refused.uuid}` })
313
+ }
314
+ for (const e of found) store.remove(e)
315
+ return json(200, { deleted: found.length })
146
316
  }
147
317
 
148
318
  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 })
319
+ const bad = unexpected(q, [])
320
+ if (bad) return bad
321
+ if (!store.account) return unauthorized()
322
+ const b = await body(request)
323
+ if (!b || !Array.isArray(b.uuids)) return badRequest('invalid batch body: missing field `uuids`')
324
+ const invalid = b.uuids.find((u) => !isUuid(u))
325
+ if (invalid) return badRequest(`invalid uuid: ${invalid}`)
326
+ const entities = b.uuids.map((u) => store.entities.get(u)).filter(Boolean)
327
+ return json(200, { entities: entities.map((e) => store.read(e, { canEdit: false })) })
152
328
  }
153
329
 
154
330
  const items = rest.match(/^\/([^/]+)\/items$/)
155
331
  if (items && method === 'POST') {
156
- const entity = store.entities.get(decodeURIComponent(items[1]))
157
- if (!entity) return problem({ status: 404, title: 'NotFound', kind: 'entity' })
332
+ const bad = unexpected(q, [PARAM.model, PARAM.readback])
333
+ if (bad) return bad
334
+ if (!q.get(PARAM.model)) return badRequest(`Missing required parameter: ${PARAM.model}`)
335
+ const readback = bool(q, PARAM.readback, false)
336
+ if (readback === undefined) return badRequest('invalid parameter value')
337
+ if (!store.account) return unauthorized()
158
338
  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])
339
+ const batch = Array.isArray(payload)
340
+ const parsed = (batch ? payload : [payload]).map((raw) => parseOp(raw, batch ? 'items batch' : 'update_item'))
341
+ const failed = parsed.find((p) => p.problem)
342
+ if (failed) return answer(failed)
343
+ const found = target(items[1], q.get(PARAM.model))
344
+ if (found.response) return found.response
345
+ const { entity, model } = found
346
+ if (!store.mayEdit(entity)) {
347
+ return problem({ status: 403, title: 'Forbidden', detail: `edit on entity ${entity.uuid} not permitted`, op: 'edit', target: `entity ${entity.uuid}` })
348
+ }
349
+ const before = store.core(entity)
350
+ const outcome = store.applyOps(entity, model, parsed.map((p) => p.op))
351
+ if (outcome.problem) return answer(outcome)
352
+ // The entity as it was checked, unless `readback` asked for it after the write.
353
+ const shown = readback ? store.core(entity) : before
354
+ return json(200, batch ? { entity: shown, results: outcome.results } : { entity: shown, ...outcome.results[0] })
165
355
  }
166
356
 
167
357
  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' })
358
+ if (one && method === 'GET') {
359
+ const bad = unexpected(q, [PARAM.model, PARAM.depth, PARAM.maxDepth, PARAM.locale, PARAM.via])
360
+ if (bad) return bad
361
+ if (!q.get(PARAM.model)) return badRequest(`Missing required parameter: ${PARAM.model}`)
362
+ const depth = q.get(PARAM.depth) || 'shallow'
363
+ if (!['none', 'brief', 'shallow', 'deep'].includes(depth)) {
364
+ return badRequest(`invalid depth \`${depth}\`; expected one of: none, brief, shallow, deep`)
365
+ }
366
+ if (!store.account) return unauthorized()
367
+ if (!isUuid(one[1])) return badRequest(`invalid uuid: ${one[1]}`)
368
+ const via = (q.get(PARAM.via) || '').trim()
369
+ if (via && !isUuid(via)) return badRequest(`invalid via uuid: ${via}`)
370
+ const found = target(one[1], q.get(PARAM.model))
371
+ if (found.response) return found.response
372
+ // The mock models no entitlements: a `via` read is the same read, once the
373
+ // container exists. Not found and not permitted are one answer, as ever.
374
+ if (via && !store.entities.has(via)) return notFound('entity', one[1])
375
+ return json(200, store.read(found.entity, { locales: locales(q), withItems: depth === 'shallow' || depth === 'deep' }))
376
+ }
377
+
378
+ if (one && method === 'DELETE') {
379
+ const bad = unexpected(q, [PARAM.revRefPolicy])
380
+ if (bad) return bad
381
+ const policy = q.get(PARAM.revRefPolicy) || 'abort'
382
+ if (!['abort', 'orphan_refs'].includes(policy)) {
383
+ return badRequest(`invalid rev_ref_policy \`${policy}\`; expected one of: abort, orphan_refs`)
175
384
  }
176
- if (method === 'DELETE') {
177
- return store.remove(uuid)
178
- ? new Response(null, { status: 204 })
179
- : problem({ status: 404, title: 'NotFound', kind: 'entity' })
385
+ if (!store.account) return unauthorized()
386
+ if (!isUuid(one[1])) return badRequest(`invalid uuid: ${one[1]}`)
387
+ const entity = store.entities.get(one[1])
388
+ if (!entity) return notFound('entity', one[1])
389
+ if (!store.mayEdit(entity)) {
390
+ return problem({ status: 403, title: 'Forbidden', detail: `delete on entity ${entity.uuid} not permitted`, op: 'delete', target: `entity ${entity.uuid}` })
180
391
  }
392
+ store.remove(entity)
393
+ return new Response(null, { status: 204 })
181
394
  }
182
395
  }
183
396
 
184
- return problem({ status: 404, title: 'NotFound', detail: `no route for ${method} ${path}` })
397
+ return problem({ status: 404, title: 'Not Found', detail: `no route for ${method} ${path}` })
185
398
  }
186
399
 
187
400
  return {
188
401
  store,
189
402
  /**
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.
403
+ * Writes the mock let through on the seed's say-so — into a section listed as
404
+ * `migration_debt`. Empty is the good state.
195
405
  */
196
406
  get diagnostics() {
197
407
  return store.diagnostics
198
408
  },
409
+ /** The mail the backend would have sent — sign-up verification and password-reset tokens. */
410
+ get outbox() {
411
+ return store.outbox
412
+ },
199
413
  async fetch(request) {
200
414
  try {
201
415
  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