@uniweb/api 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +200 -21
- package/package.json +2 -2
- package/src/client.js +275 -8
- package/src/elevation.js +268 -0
- package/src/hooks/useChoices.js +102 -0
- package/src/hooks/useDefinition.js +78 -0
- package/src/hooks/useElevation.js +147 -0
- package/src/hooks/useRecords.js +2 -2
- package/src/hooks/useSession.js +5 -0
- package/src/index.js +11 -0
- package/src/mock/definition.js +117 -0
- package/src/mock/index.js +74 -7
- package/src/mock/schema-shape.js +139 -6
- package/src/mock/store.js +442 -61
- package/src/wire.js +247 -34
package/src/mock/store.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CLAIM, FIELD, INVITATION, LINK, OP, PROBLEM } from '../wire.js'
|
|
2
2
|
import { checkItemWrite, OUTCOME } from './schema-shape.js'
|
|
3
|
+
import { fieldRow, rulesOf, otherDataOf, parseOptions, kindOf, isList } from './definition.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The mock's state — accounts, one session, Models with their sections, and
|
|
@@ -20,11 +21,14 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
|
|
|
20
21
|
* other's — the seed's `memberFloor` models that choice. *(Until 2026-09-18 the
|
|
21
22
|
* backend gave every member read access to every other member's entities.)*
|
|
22
23
|
* - **The operator** — the account that runs the site's service — owns what the
|
|
23
|
-
* seed gives it
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* seed gives it, and holds `system_admin` over the whole service. ⛔ **Signed in on
|
|
25
|
+
* the site, it holds that role as a reader until it is elevated**: it reads every
|
|
26
|
+
* member's entities (`via: rbac`) and changes none of them — a site runs third-party
|
|
27
|
+
* code with its member's session. Elevated, with its password again, its role answers
|
|
28
|
+
* in full until the window closes (`ELEVATION_WINDOW`) or it lowers it. *(Stated by
|
|
29
|
+
* the backend, 2026-10-14. From 2026-10-10 the operator's session held a member's
|
|
30
|
+
* rights only, and before that `system_admin` in full.)* A seeded account is the
|
|
31
|
+
* operator with `operator: true`.
|
|
28
32
|
* - **An offer binds when it is claimed, or when its address is confirmed** — and
|
|
29
33
|
* then its token answers `404`, as an unknown one does (backend, 2026-10-11). What
|
|
30
34
|
* it binds: its invitations, each waiting for the account's answer, to an
|
|
@@ -35,9 +39,20 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
|
|
|
35
39
|
* A mock that grows a schema and migrations becomes a second implementation that
|
|
36
40
|
* drifts and is believed anyway.
|
|
37
41
|
*
|
|
42
|
+
* - **A choice from a list** — a field of kind `item_ref`, `options:` in a schema —
|
|
43
|
+
* stores an item's uuid, checked against the section the field names, and that
|
|
44
|
+
* section's items are listed by `GET /sections/{uuid}/items` as the backend lists
|
|
45
|
+
* them. A nested section is a section of its own, and its items sit under an item of
|
|
46
|
+
* its parent section. *(Both since 2026-10-14; nested sections were not modelled
|
|
47
|
+
* before.)*
|
|
48
|
+
*
|
|
38
49
|
* ⛔ **What it does not model**: entitlements behind `via` (a `via` read is the
|
|
39
|
-
* same read), a Model whose creation is closed,
|
|
40
|
-
*
|
|
50
|
+
* same read), a Model whose creation is closed, second factors —
|
|
51
|
+
* at a sign-in or an elevation, where a `code` is accepted and not checked —
|
|
52
|
+
* sign-in providers, and so no elevation through one, roles other than the operator's,
|
|
53
|
+
* step-up, a reference to an entity (an `entity_ref` value is stored unchecked), a
|
|
54
|
+
* section's own rules — `exclusive`, `unique_field`, `min_items` and the rest are answered
|
|
55
|
+
* in the definition and not applied; a field's `enum` is — password rules,
|
|
41
56
|
* an offer's grants (a count, sharing nothing), what joining an organization
|
|
42
57
|
* gives — accepting an invitation answers, and opens nothing to read — and an
|
|
43
58
|
* organization withdrawing an invitation.
|
|
@@ -60,6 +75,15 @@ export const HOME_ORG = Object.freeze({
|
|
|
60
75
|
handle: 'home',
|
|
61
76
|
})
|
|
62
77
|
|
|
78
|
+
/**
|
|
79
|
+
* How long an elevation lasts — the backend's window, 15 minutes on 2026-10-14, as the
|
|
80
|
+
* backend states it. The client never assumes it; it reads `until`.
|
|
81
|
+
*/
|
|
82
|
+
export const ELEVATION_WINDOW = 15 * 60 * 1000
|
|
83
|
+
|
|
84
|
+
/** The operator's role, as `/auth/me` names it — MEASURED 2026-09-18. */
|
|
85
|
+
const OPERATOR_ROLE = Object.freeze({ role: 'system_admin', scope_unit_id: null })
|
|
86
|
+
|
|
63
87
|
/** The gap between neighbouring items' order numbers, as the backend spaces them. */
|
|
64
88
|
const GAP = 1_000_000
|
|
65
89
|
|
|
@@ -99,6 +123,32 @@ function canonical(value) {
|
|
|
99
123
|
const isMulti = (section) => section?.multiple === true || section?.kind === 'multi'
|
|
100
124
|
const shortName = (model) => String(model).split('/').pop() || String(model)
|
|
101
125
|
|
|
126
|
+
/** A nested section, declared as a field of its parent. */
|
|
127
|
+
const isSectionNode = (field) => field?.type === 'section'
|
|
128
|
+
|
|
129
|
+
/** A section's own fields, by key — its nested sections left out. */
|
|
130
|
+
function leavesOf(section) {
|
|
131
|
+
const fields = section?.fields
|
|
132
|
+
if (!fields || typeof fields !== 'object') return null
|
|
133
|
+
return Object.fromEntries(Object.entries(fields).filter(([, f]) => !isSectionNode(f)))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A section's nested sections, as `[name, declaration]`. */
|
|
137
|
+
function subSectionsOf(section) {
|
|
138
|
+
const fields = section?.fields
|
|
139
|
+
return fields && typeof fields === 'object' ? Object.entries(fields).filter(([, f]) => isSectionNode(f)) : []
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A section's kind, derived from its shape as the backend derives it: `multi` when it
|
|
144
|
+
* holds many items, `binder` when it holds only sections, `single` otherwise.
|
|
145
|
+
*/
|
|
146
|
+
function sectionKind(section) {
|
|
147
|
+
if (isMulti(section)) return 'multi'
|
|
148
|
+
const fields = section?.fields && typeof section.fields === 'object' ? Object.values(section.fields) : []
|
|
149
|
+
return fields.length && fields.every(isSectionNode) ? 'binder' : 'single'
|
|
150
|
+
}
|
|
151
|
+
|
|
102
152
|
/**
|
|
103
153
|
* A `{ locale: value }` map — a plain object, never an array (a localized list's
|
|
104
154
|
* value), and never the value of a field whose OWN type is an object.
|
|
@@ -177,6 +227,22 @@ export class MockStore {
|
|
|
177
227
|
for (const name of new Set([...Object.keys(this.schemas), ...itemSections.keys()])) {
|
|
178
228
|
this.defineModel(name, this.schemas[name], itemSections.get(name))
|
|
179
229
|
}
|
|
230
|
+
// A choice from a list names its list, and a backend refuses to register a schema
|
|
231
|
+
// whose list it cannot find. The mock says so when it starts, and the field offers no
|
|
232
|
+
// choices: refusing to start would stop a site whose seed spells its lists another way.
|
|
233
|
+
/** Fields whose `options:` names no seeded section — `{ model, section, field, options }`. */
|
|
234
|
+
this.unlisted = []
|
|
235
|
+
for (const model of this.models.values()) {
|
|
236
|
+
for (const section of model.sections) {
|
|
237
|
+
for (const [key, field] of Object.entries(section.fields || {})) {
|
|
238
|
+
if (kindOf(field) !== 'item_ref' || field?.options == null || this.choicesSection(field)) continue
|
|
239
|
+
this.unlisted.push({ model: model.name, section: section.path, field: key, options: field.options })
|
|
240
|
+
console.warn(
|
|
241
|
+
`[uniweb/api mock] ${model.name}: field '${key}' of '${section.path}' takes its choices from ${JSON.stringify(field.options)}, which names no section the seed declares — so it offers none. A backend refuses to register it: options is '@scope/name/section', a nested section by its path.`,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
180
246
|
|
|
181
247
|
/**
|
|
182
248
|
* Shape findings the mock saw but did not refuse — writes into a section the
|
|
@@ -209,7 +275,7 @@ export class MockStore {
|
|
|
209
275
|
const known = this.accounts.map((a) => a.username).join(', ') || '(none)'
|
|
210
276
|
throw new Error(`[uniweb/api mock] signedInAs: '${signedInAs}' is not a seeded account. Seeded: ${known}`)
|
|
211
277
|
}
|
|
212
|
-
this.session = { account }
|
|
278
|
+
this.session = { account, elevatedUntil: null }
|
|
213
279
|
}
|
|
214
280
|
}
|
|
215
281
|
|
|
@@ -234,24 +300,52 @@ export class MockStore {
|
|
|
234
300
|
return this.session?.account ?? null
|
|
235
301
|
}
|
|
236
302
|
|
|
303
|
+
/** Whether the session is elevated now — its window still open. */
|
|
304
|
+
get elevated() {
|
|
305
|
+
const until = this.session?.elevatedUntil
|
|
306
|
+
return until != null && Date.parse(until) > Date.now()
|
|
307
|
+
}
|
|
308
|
+
|
|
237
309
|
/**
|
|
238
|
-
* The viewer, in the shape `/auth/me` answers. `roles` are the session's
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
* operator's yes
|
|
310
|
+
* The viewer, in the shape `/auth/me` answers. `roles` are the session's: the
|
|
311
|
+
* operator's `system_admin`, held as `content_reader` at the same scope until the
|
|
312
|
+
* session is elevated; a member's, none. `elevation` says whether it can be — the
|
|
313
|
+
* operator's yes — and until when it is. Whether the backend's administration admits
|
|
314
|
+
* the ACCOUNT is `capabilities.admin_app`: the operator's yes, a member's no.
|
|
242
315
|
*/
|
|
243
316
|
viewer(account = this.account) {
|
|
244
317
|
if (!account) return null
|
|
318
|
+
const elevated = this.elevated
|
|
319
|
+
const roles = account.operator ? [elevated ? { ...OPERATOR_ROLE } : { ...OPERATOR_ROLE, role: 'content_reader' }] : []
|
|
245
320
|
return {
|
|
246
321
|
account: this.identity(account),
|
|
247
|
-
roles
|
|
322
|
+
roles,
|
|
248
323
|
// A request that names no workspace — as this package's never do — lands in the
|
|
249
324
|
// home org, which every account belongs to. Both null would mean a non-member.
|
|
250
325
|
workspace: { ...HOME_ORG },
|
|
251
326
|
capabilities: { admin_app: account.operator === true },
|
|
327
|
+
elevation: { available: account.operator === true, ...(elevated ? { until: this.session.elevatedUntil } : {}) },
|
|
252
328
|
}
|
|
253
329
|
}
|
|
254
330
|
|
|
331
|
+
/**
|
|
332
|
+
* `POST /auth/elevate` — the password again, and the window opens: `elevated_until`.
|
|
333
|
+
* A wrong password is `401`, as the backend's one answer for a wrong password or code.
|
|
334
|
+
* An account with nothing to elevate is answered the same way — what the backend does
|
|
335
|
+
* then is not measured, and it changes nothing the account may do.
|
|
336
|
+
*/
|
|
337
|
+
elevate({ password }) {
|
|
338
|
+
if (!this.account || this.account.password !== password) return refuse(401, 'Unauthorized')
|
|
339
|
+
this.session.elevatedUntil = new Date(Date.now() + ELEVATION_WINDOW).toISOString()
|
|
340
|
+
return { body: { elevated_until: this.session.elevatedUntil } }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** `DELETE /auth/elevate` — the window closes now. */
|
|
344
|
+
lower() {
|
|
345
|
+
this.session.elevatedUntil = null
|
|
346
|
+
return { body: { elevated_until: null } }
|
|
347
|
+
}
|
|
348
|
+
|
|
255
349
|
identity(account) {
|
|
256
350
|
return { uuid: account.uuid, username: account.username, handle: account.handle }
|
|
257
351
|
}
|
|
@@ -260,7 +354,7 @@ export class MockStore {
|
|
|
260
354
|
const account = this.accounts.find((a) => a.username === username)
|
|
261
355
|
if (!account || account.password !== password) return refuse(401, 'Unauthorized')
|
|
262
356
|
if (!account.verified) return refuse(403, PROBLEM.notVerified, 'verify your email address before signing in')
|
|
263
|
-
this.session = { account }
|
|
357
|
+
this.session = { account, elevatedUntil: null }
|
|
264
358
|
const expires = new Date(Date.now() + 30 * 24 * 3600 * 1000).toISOString()
|
|
265
359
|
// No `token`: a site's backend answers a sign-in with the session cookie alone.
|
|
266
360
|
return { body: { expires_at: expires, account: this.identity(account) } }
|
|
@@ -415,29 +509,50 @@ export class MockStore {
|
|
|
415
509
|
section?.append_only === true ||
|
|
416
510
|
decl?.append_only === true ||
|
|
417
511
|
(Array.isArray(decl?.append_only) && decl.append_only.includes(sectionName))
|
|
512
|
+
const at = this.clock()
|
|
418
513
|
const model = {
|
|
419
514
|
id: (this.ids.model += 1),
|
|
420
515
|
uuid: newUuid(),
|
|
421
516
|
name,
|
|
422
517
|
decl: decl || {},
|
|
423
518
|
sections: [],
|
|
519
|
+
created_at: at,
|
|
520
|
+
updated_at: at,
|
|
424
521
|
}
|
|
425
|
-
const add = (sectionName, { kind, brief = false, section = null, debt = false }) =>
|
|
426
|
-
|
|
522
|
+
const add = (sectionName, { kind, brief = false, section = null, debt = false, parent = null }) => {
|
|
523
|
+
const row = {
|
|
427
524
|
id: (this.ids.section += 1),
|
|
525
|
+
// Never in the definition (`SCHEMA`): a field whose choices are this section's
|
|
526
|
+
// items names it, and `GET /sections/{uuid}/items` is addressed by it.
|
|
527
|
+
uuid: newUuid(),
|
|
428
528
|
name: sectionName,
|
|
529
|
+
// The name a write takes: `parent/child` for a nested section.
|
|
530
|
+
path: parent ? `${parent.path}/${sectionName}` : sectionName,
|
|
531
|
+
parent: parent?.id ?? null,
|
|
429
532
|
kind,
|
|
430
533
|
is_brief: brief,
|
|
431
534
|
append_only: appendOnly(sectionName, section),
|
|
432
|
-
|
|
535
|
+
// Its items nest under items of the same section (`self_nesting: true`).
|
|
536
|
+
nests: section?.self_nesting === true || section?.allow_self_nesting === true,
|
|
537
|
+
// Its own fields, by key — a nested section is a section of its own, not a field.
|
|
538
|
+
fields: leavesOf(section),
|
|
539
|
+
decl: section,
|
|
433
540
|
debt,
|
|
434
|
-
}
|
|
541
|
+
}
|
|
542
|
+
model.sections.push(row)
|
|
543
|
+
return row
|
|
544
|
+
}
|
|
435
545
|
|
|
436
546
|
const declared = decl?.sections && typeof decl.sections === 'object' ? decl.sections : null
|
|
437
547
|
if (declared) {
|
|
438
|
-
|
|
439
|
-
|
|
548
|
+
// Parents first, so a nested section's parent row exists when it is added.
|
|
549
|
+
const walk = (entries, parent) => {
|
|
550
|
+
for (const [sectionName, section] of entries) {
|
|
551
|
+
const row = add(sectionName, { kind: sectionKind(section), brief: !!section?.brief, section, parent })
|
|
552
|
+
walk(subSectionsOf(section), row)
|
|
553
|
+
}
|
|
440
554
|
}
|
|
555
|
+
walk(Object.entries(declared), null)
|
|
441
556
|
// Sections the site KNOWS diverge from the declaration, kept writable while
|
|
442
557
|
// they are unwound — and only the ones it named.
|
|
443
558
|
for (const sectionName of decl.migration_debt || []) {
|
|
@@ -474,20 +589,28 @@ export class MockStore {
|
|
|
474
589
|
return !!this.account
|
|
475
590
|
}
|
|
476
591
|
|
|
477
|
-
/**
|
|
592
|
+
/**
|
|
593
|
+
* `GET /models/@scope/name`, as the backend answers it (`SCHEMA` in `../wire.js`): each
|
|
594
|
+
* field its stored declaration under `type.data` (`./definition.js`), an `enum` as its
|
|
595
|
+
* section's `one_of` rule, a section's `label` and `description` in `other_data`, a
|
|
596
|
+
* nested section as a row naming its parent — top-level rows first, each group by name.
|
|
597
|
+
*/
|
|
478
598
|
schemaOf(model) {
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
599
|
+
const data = { grantable: true }
|
|
600
|
+
for (const k of ['label', 'description', 'icon', 'plural']) {
|
|
601
|
+
if (typeof model.decl?.[k] === 'string') data[k] = model.decl[k]
|
|
602
|
+
}
|
|
603
|
+
const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
|
604
|
+
const rows = [...model.sections].sort((a, b) => {
|
|
605
|
+
if ((a.parent == null) !== (b.parent == null)) return a.parent == null ? -1 : 1
|
|
606
|
+
return (a.parent ?? 0) - (b.parent ?? 0) || byName(a, b)
|
|
484
607
|
})
|
|
485
608
|
return {
|
|
486
609
|
model: {
|
|
487
610
|
id: model.id,
|
|
488
611
|
uuid: model.uuid,
|
|
489
612
|
name: model.name,
|
|
490
|
-
data
|
|
613
|
+
data,
|
|
491
614
|
version: 1,
|
|
492
615
|
owner_id: this.operator()?.id ?? null,
|
|
493
616
|
unit_id: null,
|
|
@@ -495,19 +618,235 @@ export class MockStore {
|
|
|
495
618
|
owner_cardinality: 'unbounded',
|
|
496
619
|
unit_cardinality: 'unbounded',
|
|
497
620
|
role: 'content',
|
|
621
|
+
created_at: model.created_at,
|
|
622
|
+
updated_at: model.updated_at,
|
|
498
623
|
},
|
|
499
|
-
sections:
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
624
|
+
sections: rows.map((s) => {
|
|
625
|
+
const rules = rulesOf(s.decl, s.fields)
|
|
626
|
+
return {
|
|
627
|
+
id: s.id,
|
|
628
|
+
model_id: model.id,
|
|
629
|
+
name: s.name,
|
|
630
|
+
kind: s.kind,
|
|
631
|
+
is_brief: s.is_brief,
|
|
632
|
+
parent_section_id: s.parent,
|
|
633
|
+
fields: Object.entries(s.fields || {}).map(([key, f]) =>
|
|
634
|
+
fieldRow(key, f, {
|
|
635
|
+
section: kindOf(f) === 'item_ref' ? this.choicesSection(f)?.uuid : null,
|
|
636
|
+
model: kindOf(f) === 'entity_ref' ? this.model(f?.model)?.uuid : null,
|
|
637
|
+
}),
|
|
638
|
+
),
|
|
639
|
+
other_data: otherDataOf(s.decl, { appendOnly: s.append_only, nests: s.nests }),
|
|
640
|
+
constraints: rules.length ? { rules } : {},
|
|
641
|
+
}
|
|
642
|
+
}),
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* A section of a Model by the name a write takes — its `parent/child` path, or its
|
|
648
|
+
* own name when no other section shares it — or null.
|
|
649
|
+
*/
|
|
650
|
+
sectionNamed(model, name) {
|
|
651
|
+
const byPath = model.sections.find((s) => s.path === name)
|
|
652
|
+
if (byPath) return byPath
|
|
653
|
+
const byName = model.sections.filter((s) => s.name === name)
|
|
654
|
+
return byName.length === 1 ? byName[0] : null
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** `{ model, section }` for a section uuid, or null. */
|
|
658
|
+
sectionByUuid(uuid) {
|
|
659
|
+
for (const model of this.models.values()) {
|
|
660
|
+
const section = model.sections.find((s) => s.uuid === uuid)
|
|
661
|
+
if (section) return { model, section }
|
|
662
|
+
}
|
|
663
|
+
return null
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** The section an `item_ref` field's `options: '@scope/name/section'` names, or null. */
|
|
667
|
+
choicesSection(field) {
|
|
668
|
+
const named = parseOptions(field?.options)
|
|
669
|
+
const model = named && this.model(named.model)
|
|
670
|
+
return model ? this.sectionNamed(model, named.path) : null
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** The nearest section above this one that holds items — past any binder — or null. */
|
|
674
|
+
holdingAncestor(model, section) {
|
|
675
|
+
for (let at = section.parent; at != null; ) {
|
|
676
|
+
const above = model.sections.find((s) => s.id === at)
|
|
677
|
+
if (!above) return null
|
|
678
|
+
if (above.kind !== 'binder') return above
|
|
679
|
+
at = above.parent
|
|
510
680
|
}
|
|
681
|
+
return null
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Where an item of this section may sit, as the backend checks it: under an item of its
|
|
686
|
+
* nearest item-holding parent section — which an item of a nested section must name —
|
|
687
|
+
* or, in a section whose items nest, under one of its own. `null` when it may sit there.
|
|
688
|
+
*/
|
|
689
|
+
parentRefusal(entity, model, section, parentId) {
|
|
690
|
+
const ancestor = this.holdingAncestor(model, section)
|
|
691
|
+
if (parentId == null) {
|
|
692
|
+
return ancestor
|
|
693
|
+
? refuse(
|
|
694
|
+
409,
|
|
695
|
+
PROBLEM.schemaRule,
|
|
696
|
+
`section ${section.id} (\`${section.name}\`) nests under section ${ancestor.id}; its items need a parent_item_id naming an item of that section`,
|
|
697
|
+
)
|
|
698
|
+
: null
|
|
699
|
+
}
|
|
700
|
+
const parent = entity.items.find((i) => i.id === parentId)
|
|
701
|
+
if (!parent) return refuse(409, PROBLEM.schemaRule, `parent item ${parentId} is not an item of this entity`)
|
|
702
|
+
if (section.nests && parent.section_id === section.id) return null
|
|
703
|
+
if (ancestor && parent.section_id === ancestor.id) return null
|
|
704
|
+
return refuse(
|
|
705
|
+
409,
|
|
706
|
+
PROBLEM.schemaRule,
|
|
707
|
+
`parent_item_id ${parentId} lives in section ${parent.section_id}, but section ${section.id}'s items sit under ${ancestor ? `section ${ancestor.id}'s` : 'no item'}`,
|
|
708
|
+
)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* `GET /sections/{uuid}/items` — one page of a section's items, as the backend answers
|
|
713
|
+
* it (`SECTION_ITEMS` in `../wire.js`), its refusals in the backend's order: a term with
|
|
714
|
+
* no field, the entry, a binder, the field. `{ body }` or `{ problem }`.
|
|
715
|
+
*
|
|
716
|
+
* ⚖️ The backend may answer a short page while more follow (a localized search);
|
|
717
|
+
* the mock filters before it pages, so its pages are full. Both end only where
|
|
718
|
+
* `next_cursor` is `null`.
|
|
719
|
+
*
|
|
720
|
+
* @param {object} model
|
|
721
|
+
* @param {object} section
|
|
722
|
+
* @param {{ entity?: string, parent?: string, field?: string, q?: string, locales?: string[]|null, after?: number[]|null, limit: number }} ask
|
|
723
|
+
*/
|
|
724
|
+
sectionItems(model, section, { entity, parent, field, q, locales, after, limit }) {
|
|
725
|
+
const term = (q || '').trim()
|
|
726
|
+
const named = (field || '').trim()
|
|
727
|
+
if (term && !named) return refuse(400, 'Validation', "`q` searches one field of the Section's items: name it with `field`")
|
|
728
|
+
|
|
729
|
+
let entries
|
|
730
|
+
if (entity) {
|
|
731
|
+
const found = this.entities.get(entity)
|
|
732
|
+
// Read as a single read is: not found, not permitted and another Model are one answer.
|
|
733
|
+
if (!found || found.model !== model.name || !this.mayRead(found)) {
|
|
734
|
+
return refuse(404, 'Not Found', `entity ${entity} not found`, { kind: 'entity', key: entity })
|
|
735
|
+
}
|
|
736
|
+
entries = [found]
|
|
737
|
+
} else {
|
|
738
|
+
entries = [...this.entities.values()].filter((e) => e.model === model.name && this.mayRead(e))
|
|
739
|
+
}
|
|
740
|
+
if (section.kind === 'binder') {
|
|
741
|
+
return refuse(400, 'Validation', `section \`${section.name}\` is a binder: it holds sections, never items`)
|
|
742
|
+
}
|
|
743
|
+
let searched = null
|
|
744
|
+
if (term) {
|
|
745
|
+
searched = section.fields?.[named]
|
|
746
|
+
if (!searched) return refuse(400, 'Validation', `section \`${section.name}\` declares no field \`${named}\``, { field: named })
|
|
747
|
+
const kind = kindOf(searched)
|
|
748
|
+
if (isList(searched) || !['string', 'text'].includes(kind)) {
|
|
749
|
+
return refuse(400, 'Validation', `\`${named}\` is ${isList(searched) ? 'a multiple ' : 'a '}${kind}: a search reads one \`string\` or \`text\` value`, {
|
|
750
|
+
field: named,
|
|
751
|
+
})
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const needle = term.toLowerCase()
|
|
756
|
+
const holds = (value) => {
|
|
757
|
+
if (typeof value === 'string') return value.toLowerCase().includes(needle)
|
|
758
|
+
if (!isLocaleMap(searched, value)) return false
|
|
759
|
+
// A localized value in the read's language, as the read shows it — any language without one.
|
|
760
|
+
if (locales?.length) {
|
|
761
|
+
const hit = locales.find((l) => value[l] != null)
|
|
762
|
+
return hit != null && holds(value[hit])
|
|
763
|
+
}
|
|
764
|
+
return Object.entries(value).some(([lang, v]) => lang !== '@' && typeof v === 'string' && v.toLowerCase().includes(needle))
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const rows = []
|
|
768
|
+
for (const e of [...entries].sort((a, b) => a.id - b.id)) {
|
|
769
|
+
const byId = new Map(e.items.map((i) => [i.id, i]))
|
|
770
|
+
for (const i of e.items) {
|
|
771
|
+
if (i.section_id !== section.id) continue
|
|
772
|
+
const up = i.parent_item_id != null ? byId.get(i.parent_item_id) ?? null : null
|
|
773
|
+
// One level: the items under `parent`, or the section's top level — its items whose
|
|
774
|
+
// parent is not an item of the same section.
|
|
775
|
+
if (parent ? up?.uuid !== parent : up?.section_id === section.id) continue
|
|
776
|
+
if (searched && !holds(i.data?.[named])) continue
|
|
777
|
+
rows.push({ e, i, up, key: [e.id, up?.order_number ?? 0, up?.id ?? 0, i.order_number ?? Number.MAX_SAFE_INTEGER, i.id] })
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
const before = (a, b) => {
|
|
781
|
+
for (let n = 0; n < a.length; n += 1) if (a[n] !== b[n]) return a[n] < b[n]
|
|
782
|
+
return false
|
|
783
|
+
}
|
|
784
|
+
rows.sort((a, b) => (before(a.key, b.key) ? -1 : before(b.key, a.key) ? 1 : 0))
|
|
785
|
+
const rest = after ? rows.filter((r) => before(after, r.key)) : rows
|
|
786
|
+
const page = rest.slice(0, limit)
|
|
787
|
+
const children = (e, i) => e.items.some((c) => c.parent_item_id === i.id && c.section_id === section.id)
|
|
788
|
+
return {
|
|
789
|
+
body: {
|
|
790
|
+
items: page.map(({ e, i, up }) => ({
|
|
791
|
+
uuid: i.uuid,
|
|
792
|
+
entity: e.uuid,
|
|
793
|
+
...(up ? { parent: up.uuid } : {}),
|
|
794
|
+
data: this.project(i.data, section, locales),
|
|
795
|
+
...(section.nests ? { has_children: children(e, i) } : {}),
|
|
796
|
+
})),
|
|
797
|
+
next_cursor: rest.length > page.length ? page[page.length - 1].key : null,
|
|
798
|
+
},
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** `{ entity, item }` for an item uuid, or null. */
|
|
803
|
+
itemByUuid(uuid) {
|
|
804
|
+
for (const entity of this.entities.values()) {
|
|
805
|
+
const item = entity.items.find((i) => i.uuid === uuid)
|
|
806
|
+
if (item) return { entity, item }
|
|
807
|
+
}
|
|
808
|
+
return null
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* An `item_ref` value the backend would refuse — not a uuid string, an item that does not
|
|
813
|
+
* exist, or an item of another section than the field's list (MEASURED 2026-10-14) — or
|
|
814
|
+
* null. A list of them is checked one by one.
|
|
815
|
+
*/
|
|
816
|
+
refRefusal(section, data) {
|
|
817
|
+
for (const [key, field] of Object.entries(section.fields || {})) {
|
|
818
|
+
if (kindOf(field) !== 'item_ref' || data?.[key] == null) continue
|
|
819
|
+
const values = isList(field) && Array.isArray(data[key]) ? data[key] : [data[key]]
|
|
820
|
+
const pinned = this.choicesSection(field)
|
|
821
|
+
for (const value of values) {
|
|
822
|
+
if (typeof value !== 'string') {
|
|
823
|
+
return refuse(400, 'Validation', `item.data.${key} must be a uuid string (item_ref value is the target item's uuid)`, {
|
|
824
|
+
reason: 'invalid_field',
|
|
825
|
+
field: `data.${key}`,
|
|
826
|
+
rule: 'type',
|
|
827
|
+
})
|
|
828
|
+
}
|
|
829
|
+
if (!isUuid(value)) {
|
|
830
|
+
return refuse(400, 'Validation', `item.data.${key}=\`${value}\` is not a valid uuid`, {
|
|
831
|
+
reason: 'invalid_field',
|
|
832
|
+
field: `data.${key}`,
|
|
833
|
+
rule: 'format',
|
|
834
|
+
format: 'uuid',
|
|
835
|
+
})
|
|
836
|
+
}
|
|
837
|
+
const target = this.itemByUuid(value)
|
|
838
|
+
if (!target) return refuse(409, 'Bad Reference', `item.data.${key} → item ${value}, but no item with that uuid exists`)
|
|
839
|
+
if (pinned && target.item.section_id !== pinned.id) {
|
|
840
|
+
const actual = this.model(target.entity.model)?.sections.find((s) => s.id === target.item.section_id)
|
|
841
|
+
return refuse(
|
|
842
|
+
409,
|
|
843
|
+
'Bad Reference',
|
|
844
|
+
`item.data.${key} → item ${value} is in section ${actual?.uuid ?? target.item.section_id}, but the type pins section ${pinned.uuid}`,
|
|
845
|
+
)
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return null
|
|
511
850
|
}
|
|
512
851
|
|
|
513
852
|
operator() {
|
|
@@ -528,11 +867,17 @@ export class MockStore {
|
|
|
528
867
|
if (uuid) entity.uuid = uuid
|
|
529
868
|
const brief = model.sections.find((s) => s.is_brief)
|
|
530
869
|
if (data && brief && Object.keys(data).length) this.addItem(entity, brief, { data })
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
870
|
+
// An item may carry the items that sit under it — a nested section's, or its own
|
|
871
|
+
// section's when its items nest — as `items`, so a seed never needs an item id.
|
|
872
|
+
const place = (list, parent) => {
|
|
873
|
+
for (const item of list) {
|
|
874
|
+
const section = this.sectionNamed(model, item.section)
|
|
875
|
+
if (!section) throw new Error(`[uniweb/api mock] seed: ${modelName} has no section '${item.section}'`)
|
|
876
|
+
const made = this.addItem(entity, section, { data: item.data ?? {}, parent: parent ?? item.parent ?? null })
|
|
877
|
+
if (Array.isArray(item.items)) place(item.items, made.id)
|
|
878
|
+
}
|
|
535
879
|
}
|
|
880
|
+
place(items, null)
|
|
536
881
|
this.entities.set(entity.uuid, entity)
|
|
537
882
|
return entity
|
|
538
883
|
}
|
|
@@ -599,30 +944,35 @@ export class MockStore {
|
|
|
599
944
|
}
|
|
600
945
|
|
|
601
946
|
/**
|
|
602
|
-
* The owner — or
|
|
603
|
-
*
|
|
604
|
-
* administrator's rights.
|
|
947
|
+
* The owner — or the operator, whose role reads everything, elevated or not — or any
|
|
948
|
+
* member, when the service lets members read each other's.
|
|
605
949
|
*/
|
|
606
950
|
mayRead(entity) {
|
|
607
951
|
const me = this.account
|
|
608
|
-
return !!me && (entity.owner_id === me.id || this.floorFor(entity) != null)
|
|
952
|
+
return !!me && (entity.owner_id === me.id || me.operator === true || this.floorFor(entity) != null)
|
|
609
953
|
}
|
|
610
954
|
|
|
955
|
+
/**
|
|
956
|
+
* The owner — or the operator while its session is elevated — or any member, when the
|
|
957
|
+
* service lets members edit each other's.
|
|
958
|
+
*/
|
|
611
959
|
mayEdit(entity) {
|
|
612
960
|
const me = this.account
|
|
613
|
-
return !!me && (entity.owner_id === me.id || this.floorFor(entity) === 'edit')
|
|
961
|
+
return !!me && (entity.owner_id === me.id || (me.operator === true && this.elevated) || this.floorFor(entity) === 'edit')
|
|
614
962
|
}
|
|
615
963
|
|
|
616
964
|
/**
|
|
617
965
|
* Which branch lets the viewer read a row, as the backend names it — the first to
|
|
618
966
|
* answer in its order: `owner`, `grant`, `rbac`, `unit_member`. A session's own rows
|
|
619
|
-
* are `owner`, the operator's too
|
|
620
|
-
*
|
|
621
|
-
*
|
|
967
|
+
* are `owner`, the operator's too; the rest the operator reads through its role,
|
|
968
|
+
* `rbac`; a member reads the rest by the service's floor. *(From 2026-10-10 to
|
|
969
|
+
* 2026-10-14 the operator's session read nothing of a member's, and before that the
|
|
970
|
+
* rest as `rbac`, through `system_admin`.)*
|
|
622
971
|
*/
|
|
623
972
|
via(entity) {
|
|
624
973
|
const me = this.account
|
|
625
974
|
if (entity.owner_id === me?.id) return 'owner'
|
|
975
|
+
if (me?.operator === true) return 'rbac'
|
|
626
976
|
return 'unit_member'
|
|
627
977
|
}
|
|
628
978
|
|
|
@@ -631,7 +981,7 @@ export class MockStore {
|
|
|
631
981
|
const model = this.model(entity.model)
|
|
632
982
|
const section = model?.sections.find((s) => s.is_brief)
|
|
633
983
|
const item = section && entity.items.find((i) => i.section_id === section.id)
|
|
634
|
-
return item ? this.
|
|
984
|
+
return item ? this.present(item.data, section, locales) : {}
|
|
635
985
|
}
|
|
636
986
|
|
|
637
987
|
/**
|
|
@@ -658,6 +1008,29 @@ export class MockStore {
|
|
|
658
1008
|
return out
|
|
659
1009
|
}
|
|
660
1010
|
|
|
1011
|
+
/**
|
|
1012
|
+
* An item's data as a read shows it — each `item_ref` value resolved one hop to
|
|
1013
|
+
* `{ item, brief }`, the item it names and that item's data, as the backend reads one
|
|
1014
|
+
* (MEASURED 2026-10-14: a brief's `country` comes back `{ item, brief: { code, label } }`)
|
|
1015
|
+
* — then projected to the read's locale. A value that names no item stays as stored.
|
|
1016
|
+
*/
|
|
1017
|
+
present(data, section, locales) {
|
|
1018
|
+
if (!data || typeof data !== 'object' || !section?.fields) return this.project(data, section, locales)
|
|
1019
|
+
let out = data
|
|
1020
|
+
for (const [key, field] of Object.entries(section.fields)) {
|
|
1021
|
+
if (kindOf(field) !== 'item_ref' || data[key] == null) continue
|
|
1022
|
+
const resolve = (value) => {
|
|
1023
|
+
const target = typeof value === 'string' ? this.itemByUuid(value) : null
|
|
1024
|
+
if (!target) return value
|
|
1025
|
+
const own = this.model(target.entity.model)?.sections.find((s) => s.id === target.item.section_id)
|
|
1026
|
+
return { item: value, brief: this.project(target.item.data, own, locales) }
|
|
1027
|
+
}
|
|
1028
|
+
if (out === data) out = { ...data }
|
|
1029
|
+
out[key] = Array.isArray(data[key]) ? data[key].map(resolve) : resolve(data[key])
|
|
1030
|
+
}
|
|
1031
|
+
return this.project(out, section, locales)
|
|
1032
|
+
}
|
|
1033
|
+
|
|
661
1034
|
/** The entity row without its Model's identity — as it sits inside a write's answer. */
|
|
662
1035
|
core(entity, locales) {
|
|
663
1036
|
const model = this.model(entity.model)
|
|
@@ -704,7 +1077,7 @@ export class MockStore {
|
|
|
704
1077
|
id: i.id,
|
|
705
1078
|
section_id: i.section_id,
|
|
706
1079
|
parent_item_id: i.parent_item_id,
|
|
707
|
-
data: this.
|
|
1080
|
+
data: this.present(i.data, sectionOf(i.section_id), locales),
|
|
708
1081
|
item_date: null,
|
|
709
1082
|
order_number: i.order_number,
|
|
710
1083
|
updated_at: i.updated_at,
|
|
@@ -732,11 +1105,12 @@ export class MockStore {
|
|
|
732
1105
|
const entity = this.newEntity(model, this.account)
|
|
733
1106
|
const staged = { ...entity, items: [] }
|
|
734
1107
|
for (const item of items) {
|
|
735
|
-
const section =
|
|
1108
|
+
const section = this.sectionNamed(model, item.section)
|
|
736
1109
|
if (!section) return refuse(404, 'Not Found', `section ${model.id}:${item.section} not found`, { kind: 'section', key: `${model.id}:${item.section}` })
|
|
737
|
-
const
|
|
1110
|
+
const parent = item.parent_item_id ?? null
|
|
1111
|
+
const refusal = this.parentRefusal(staged, model, section, parent) ?? this.admit(staged, model, section, item.data, null)
|
|
738
1112
|
if (refusal) return refusal
|
|
739
|
-
this.addItem(staged, section, { data: item.data, parent
|
|
1113
|
+
this.addItem(staged, section, { data: item.data, parent })
|
|
740
1114
|
}
|
|
741
1115
|
Object.assign(entity, { items: staged.items, updated_at: staged.updated_at })
|
|
742
1116
|
this.entities.set(entity.uuid, entity)
|
|
@@ -772,15 +1146,23 @@ export class MockStore {
|
|
|
772
1146
|
*/
|
|
773
1147
|
shapeGuard(model, section, data, itemId) {
|
|
774
1148
|
if (!model.decl?.sections) return null
|
|
775
|
-
const check = checkItemWrite({ decl: model.decl, section: section.name, data })
|
|
1149
|
+
const check = checkItemWrite({ decl: model.decl, section: section.path ?? section.name, data })
|
|
776
1150
|
if (check.outcome === OUTCOME.VIOLATES) {
|
|
777
1151
|
const first = check.problems[0]
|
|
778
|
-
|
|
1152
|
+
// As the backend refuses it (MEASURED 2026-10-14): `reason`, `rule`, the bound beside
|
|
1153
|
+
// it — and the field, except for a `one_of`, which names it only in `detail`.
|
|
1154
|
+
if (first.rule === 'undeclared-section') return refuse(400, 'Validation', first.detail, { field: first.field })
|
|
1155
|
+
return refuse(400, 'Validation', first.detail, {
|
|
1156
|
+
reason: 'invalid_field',
|
|
1157
|
+
...(first.rule === 'one_of' ? {} : { field: `data.${first.field}` }),
|
|
1158
|
+
rule: first.rule,
|
|
1159
|
+
...(first.params || {}),
|
|
1160
|
+
})
|
|
779
1161
|
}
|
|
780
1162
|
if (check.outcome === OUTCOME.DIAGNOSED) {
|
|
781
1163
|
this.diagnose(model.name, { op: itemId == null ? 'create-item' : 'update-item', section: section.name }, check)
|
|
782
1164
|
}
|
|
783
|
-
return
|
|
1165
|
+
return this.refRefusal(section, data)
|
|
784
1166
|
}
|
|
785
1167
|
|
|
786
1168
|
/** Record a write the mock let through on the seed's say-so (`migration_debt`), deduped with a count. */
|
|
@@ -818,9 +1200,8 @@ export class MockStore {
|
|
|
818
1200
|
: refuse(404, 'Not Found', `section ${op[FIELD.section]} not found`, { kind: 'section', key: String(op[FIELD.section]) })
|
|
819
1201
|
}
|
|
820
1202
|
const parent = op[FIELD.parent] ?? null
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
}
|
|
1203
|
+
const misplaced = this.parentRefusal(entity, model, section, parent)
|
|
1204
|
+
if (misplaced) return misplaced
|
|
824
1205
|
const refusal = this.admit(entity, model, section, op.data, null)
|
|
825
1206
|
if (refusal) return refusal
|
|
826
1207
|
if (op.position != null && this.orderFor(entity, section.id, parent, op.position, null) == null) {
|