@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/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. ⛔ **Signed in on the site it holds a member's rights**, like
24
- * everyone: no `system_admin` in `roles`, and nothing of another member's unless
25
- * shared — a site runs third-party code with its member's session. *(Until
26
- * 2026-10-10 the operator's session held `system_admin` and read and wrote
27
- * everything.)* A seeded account is the operator with `operator: true`.
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, nested sections, second factors,
40
- * reference fields, password rules,
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, and a
239
- * session on a site holds no administrator's — the operator's neither. Whether the
240
- * backend's administration admits the ACCOUNT is `capabilities.admin_app`: the
241
- * operator's yes, a member's no.
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
- model.sections.push({
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
- fields: section?.fields || null,
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
- for (const [sectionName, section] of Object.entries(declared)) {
439
- add(sectionName, { kind: isMulti(section) ? 'multi' : 'single', brief: !!section?.brief, section })
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
- /** `GET /models/@scope/name`, in the backend's shape. */
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 field = ([key, f]) => ({
480
- key,
481
- required: !!f?.required,
482
- multi: f?.multiple === true || f?.type === 'array',
483
- type: { id: -1, name: f?.type ?? 'json', kind: f?.type ?? 'json', data: { key, kind: f?.type ?? 'json' } },
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: { label: model.decl?.label ?? shortName(model.name), grantable: true },
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: model.sections.map((s) => ({
500
- id: s.id,
501
- model_id: model.id,
502
- name: s.name,
503
- kind: s.kind,
504
- is_brief: s.is_brief,
505
- parent_section_id: null,
506
- fields: Object.entries(s.fields || {}).map(field),
507
- other_data: s.append_only ? { append_only: true } : {},
508
- constraints: {},
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
- for (const item of items) {
532
- const section = model.sections.find((s) => s.name === item.section)
533
- if (!section) throw new Error(`[uniweb/api mock] seed: ${modelName} has no section '${item.section}'`)
534
- this.addItem(entity, section, { data: item.data ?? {}, parent: item.parent ?? null })
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 any member, when the service lets members read each other's. The
603
- * operator is a member here like everyone: a session on a site holds no
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, and the rest it reads by the service's floor.
620
- * *(Until 2026-10-10 the operator's session read the rest as `rbac`, through
621
- * `system_admin`.)*
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.project(item.data, section, locales) : {}
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.project(i.data, sectionOf(i.section_id), locales),
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 = model.sections.find((s) => s.name === item.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 refusal = this.admit(staged, model, section, item.data, null)
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: item.parent_item_id ?? null })
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
- return refuse(400, 'Validation', `item.data.${first.field}: ${first.detail}`, { field: `data.${first.field}` })
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 null
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
- if (parent != null && !entity.items.some((i) => i.id === parent)) {
822
- return refuse(409, PROBLEM.schemaRule, `parent item ${parent} is not an item of this entity`)
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) {