@uniweb/api 0.3.6 → 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/README.md +96 -23
- package/bin/uniweb-api-mock.js +20 -2
- package/package.json +2 -2
- package/src/client.js +237 -84
- package/src/errors.js +39 -12
- package/src/hooks/useEntity.js +9 -4
- package/src/hooks/useEntityWriter.js +42 -26
- package/src/hooks/useRecords.js +18 -6
- package/src/http.js +11 -6
- package/src/ledger.js +87 -55
- package/src/mock/index.js +330 -116
- package/src/mock/node.js +6 -2
- package/src/mock/schema-shape.js +8 -0
- package/src/mock/seed.js +39 -22
- package/src/mock/store.js +580 -295
- package/src/wire.js +312 -139
package/src/mock/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { AUTH,
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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,
|
|
23
|
-
*
|
|
24
|
-
* `
|
|
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
|
-
* ##
|
|
29
|
+
* ## Where it answers
|
|
28
30
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
-
* @
|
|
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 = '/
|
|
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
|
|
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 = (
|
|
68
|
-
|
|
64
|
+
const problem = (p) =>
|
|
65
|
+
new Response(JSON.stringify(p), { status: p.status, headers: { 'content-type': 'application/problem+json' } })
|
|
69
66
|
|
|
70
|
-
const
|
|
71
|
-
problem(
|
|
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
|
-
|
|
74
|
+
const text = await request.text()
|
|
75
|
+
return text ? JSON.parse(text) : null
|
|
76
76
|
} catch {
|
|
77
|
-
return
|
|
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 =
|
|
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
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
if (path
|
|
111
|
-
|
|
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
|
-
// ──
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
157
|
-
if (
|
|
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
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return
|
|
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 (
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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: '
|
|
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
|
|
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]
|
package/src/mock/schema-shape.js
CHANGED
|
@@ -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
|