@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/wire.js CHANGED
@@ -40,6 +40,18 @@
40
40
  * | `acting_unit_id` was a membership signal | every signed-in member of a site acts in the same unit — it cannot tell an operator from a member; `roles` can |
41
41
  * | the viewer carried `actingUnitId` | it carries `workspace` — where the request landed: on a site's service, its home org (2026-09-23; `acting_unit_id` leaves the wire) |
42
42
  *
43
+ * ## ⭐ MEASURED 2026-10-14 — a Model's definition in full, and a Section's items
44
+ *
45
+ * Against a backend holding data schemas registered the way a site's are, and items
46
+ * written through `/entities`: what a definition carries for each field and section
47
+ * (`SCHEMA`), the route that lists a Section's items (`SECTION_ITEMS`), and the
48
+ * refusals a field's rules answer (`PROBLEM`). That pass corrected:
49
+ *
50
+ * | was | is |
51
+ * |---|---|
52
+ * | the mock's definition gave each field `{ key, kind }` and nothing else | a field's whole declaration is under `type.data`; an `enum` is a `one_of` rule in its section's `constraints`; a section's `label` and `description` are in `other_data`; a nested section is a row of its own naming its `parent_section_id` |
53
+ * | the list took `include_disabled` | the backend refuses it — `400 Unexpected parameters: include_disabled` — and lists a disabled entity like any other |
54
+ *
43
55
  * @module @uniweb/api/wire
44
56
  */
45
57
 
@@ -49,19 +61,22 @@
49
61
  * `/sites/*`. Those routes create sites, which is the app's job, not a
50
62
  * foundation's, and a site's own `backend` service has no site to address.
51
63
  *
52
- * Its routes are under `/entities`, `/models` and `/auth`, plus what a member does
53
- * with an invitation — claim an offer (`OFFERS`), answer an invitation
64
+ * Its routes are under `/entities`, `/models`, `/sections` and `/auth`, plus what a
65
+ * member does with an invitation — claim an offer (`OFFERS`), answer an invitation
54
66
  * (`INVITATIONS`) — the steps the backend's mail asks a member to finish on the site
55
67
  * (2026-10-11). ⛔ Never `/sites/*`, and nothing that administers: the backend's
56
68
  * administration is reached through the Admin link (`AUTH.memberHandoff`), never
57
69
  * wrapped. *(This said "no route that is not under `/entities`, `/models` or
58
- * `/auth`" until 2026-10-11.)*
70
+ * `/auth`" until 2026-10-11, and left out `/sections` until 2026-10-14.)*
59
71
  */
60
72
  export const ENTITIES = '/entities'
61
73
 
62
74
  /** The Models lane — read-only, one route: a Model's definition. MEASURED. */
63
75
  export const MODELS = '/models'
64
76
 
77
+ /** The Sections lane — read-only, one route: a Section's items (`SECTION_ITEMS`). MEASURED. */
78
+ export const SECTIONS = '/sections'
79
+
65
80
  /**
66
81
  * The base, and how a route joins it. **MEASURED.**
67
82
  *
@@ -121,6 +136,10 @@ export const AUTH = {
121
136
  * `detail`. **MEASURED** by backend, 2026-10-10.
122
137
  */
123
138
  memberHandoff: '/auth/member-handoff',
139
+ /**
140
+ * `POST /auth/elevate` elevates a site's session; `DELETE` lowers it (`ELEVATION`).
141
+ */
142
+ elevate: '/auth/elevate',
124
143
  }
125
144
 
126
145
  /**
@@ -148,7 +167,8 @@ export const HANDOFF_REFUSAL = Object.freeze({
148
167
  * | reset request | `{ email }` | `202 { status: 'reset_requested' }` whether or not the address is known |
149
168
  * | reset confirm | `{ token, new_password, code? }` | `200 { reset: true }`, and every other session of the account ends; a bad token is `400 Validation`; `code` is required when a second factor is enrolled. A password the rule refuses is `400` with `reason: 'password_too_short'` and `min_length` — refused BEFORE the token is spent, so the same link works again (RULED by backend, 2026-10-11) |
150
169
  * | logout | none | `204`; with no session to end, `401` |
151
- * | me | none | `200 { account: { uuid, username, handle }, roles, workspace: { unit_uuid, handle }, capabilities: { admin_app } }` · `401` |
170
+ * | me | none | `200 { account: { uuid, username, handle }, roles, workspace: { unit_uuid, handle }, capabilities: { admin_app }, elevation? }` · `401` |
171
+ * | elevate | `{ password, code? }` | `200 { elevated_until }` · `401`, one answer for a wrong password or code (`ELEVATION`) |
152
172
  *
153
173
  * ⭐ **The session is the cookie, and only the cookie.** A site's backend answers a
154
174
  * sign-in with no `token` (since 2026-10-10, MEASURED by backend; `token` was in the
@@ -161,6 +181,54 @@ export const AUTH_BODY = {
161
181
  register: ['username', 'email', 'password'],
162
182
  resetRequest: ['email'],
163
183
  resetConfirm: ['token', 'new_password', 'code?'],
184
+ elevate: ['password', 'code?'],
185
+ }
186
+
187
+ /**
188
+ * Elevation. **Stated by the backend, 2026-10-14, and measured by its own end-to-end
189
+ * test; not yet measured here.**
190
+ *
191
+ * On a site's backend, a session signed in on the site holds each of the account's
192
+ * roles as `content_reader`, at the same scope, until it is ELEVATED: it reads wherever
193
+ * a role reached and changes nothing through one. Ownership and grants work as always —
194
+ * a member edits, deletes and shares their own. To act through a role — an
195
+ * administrator removing a member's post — the session elevates, for a while.
196
+ *
197
+ * | route | body | answer |
198
+ * |---|---|---|
199
+ * | `GET /auth/me` | none | carries `elevation: { available, until? }` on such a session — `available` when the account holds a role that answers more than `read`; `until`, an ISO time, while elevated. A backend no site reaches sends no `elevation`: its roles answer in full |
200
+ * | `POST /auth/elevate` | `{ password, code? }` — `code` when a second factor is live | `200 { elevated_until }`. A wrong password or code is `401`, one answer — and so is no session at all |
201
+ * | `DELETE /auth/elevate` | none | `200 { elevated_until: null }` |
202
+ * | `GET /auth/elevate/{provider}/authorize?redirect_uri=&state=` | — | ⛔ **a navigation**, for an account with no password: the provider's sign-in, then back to `redirect_uri` with `?elevate=ok`, or `?error=oauth_step_up_not_linked` when the identity that came back is not the account's |
203
+ *
204
+ * - **The window is the backend's** — 15 minutes on 2026-10-14 — and this package never
205
+ * assumes it: it reads `until`. Elevating again starts it again; nothing extends it.
206
+ * - **`roles` on `/auth/me` is what the session holds now** — `content_reader` rows until
207
+ * it is elevated, the account's roles while it is (`VIEWER`).
208
+ * - **Elevating is step-up's proof too** (`PROBLEM.stepUp`).
209
+ * - **It does not reach the backend's administration**, which takes a site's session as
210
+ * a reader, elevated or not. The Admin link does (`AUTH.memberHandoff`).
211
+ * - **What an elevated session may do to an entity is still the entity's answer** —
212
+ * `can_edit` on a read (`READ`), so a read made before elevating is read again.
213
+ */
214
+ export const ELEVATION = {
215
+ /** `/auth/me`'s field, and its two keys. */
216
+ me: 'elevation',
217
+ available: 'available',
218
+ until: 'until',
219
+ /** The answer of `POST` and `DELETE /auth/elevate`: an ISO time, or `null`. */
220
+ answer: 'elevated_until',
221
+ password: 'password',
222
+ code: 'code',
223
+ /** The provider's elevation — a navigation, never a fetch. */
224
+ byProvider: (provider) => `/auth/elevate/${segment(provider)}/authorize`,
225
+ redirect: 'redirect_uri',
226
+ state: 'state',
227
+ /** What the provider's elevation returns with: `?elevate=ok`, or `?error=oauth_step_up_not_linked`. */
228
+ returned: 'elevate',
229
+ ok: 'ok',
230
+ error: 'error',
231
+ notLinked: 'oauth_step_up_not_linked',
164
232
  }
165
233
 
166
234
  /**
@@ -274,13 +342,20 @@ export const TOTP = {
274
342
  * The viewer, as `/auth/me` answers. **MEASURED.**
275
343
  *
276
344
  * `roles` is a list of `{ role, scope_unit_id }` — `role` one of `system_admin`,
277
- * `unit_admin`, `content_editor`, `user`. ⭐ **They describe the session, not the
278
- * account.** On a site's `backend` service a session signed in on the site holds no
279
- * `system_admin` and no `unit_admin` of the home org — the operator's neither — so
280
- * `roles` never name an administrator there: a site runs third-party code with its
281
- * member's session, and a bug in that code must not act as one. An ordinary member
282
- * holds none: `[]`. *(Since 2026-10-10, MEASURED by backend; until then the operator's
283
- * session on a site held `system_admin`.)*
345
+ * `unit_admin`, `content_editor`, `content_reader`, `user`; the operator's is
346
+ * `{ role: 'system_admin', scope_unit_id: null }` (MEASURED 2026-09-18). ⭐ **They
347
+ * describe the session, not the account.** On a site's `backend` service a session
348
+ * signed in on the site holds each of the account's roles as `content_reader`, at the
349
+ * same scope, until it is elevated — so `roles` name an administrator there only while
350
+ * the session is (`ELEVATION`): a site runs third-party code with its member's session,
351
+ * and a bug in that code acts with whatever the session holds. An ordinary member holds
352
+ * none: `[]`. *(Since 2026-10-14, stated by backend. From 2026-10-10 such a session held
353
+ * no `system_admin` and no `unit_admin` of the home org and every other role whole, and
354
+ * before that the operator's session on a site held `system_admin`.)*
355
+ *
356
+ * `elevation` is `{ available, until? }` on a site's session (`ELEVATION`), absent on a
357
+ * backend no site reaches. The viewer carries it as `{ available, until }` — `until`
358
+ * `null` when not elevated — or `null` when absent.
284
359
  *
285
360
  * `workspace` is `{ unit_uuid, handle }` — **the workspace the request works in**. This
286
361
  * package's requests name none, and on a site's `backend` service such a request lands in
@@ -293,16 +368,16 @@ export const TOTP = {
293
368
  *
294
369
  * ⛔ **Neither is an operator signal.** Every member of a site's service works in the
295
370
  * same home org, so `workspace` reads the same for the operator and for someone who
296
- * signed up a minute ago, and on a site `roles` name no administrator. Ask about the
297
- * thing instead — a single-entity read carries `can_edit`, the write gate's own answer
298
- * for this session.
371
+ * signed up a minute ago, and on a site `roles` name an administrator only while the
372
+ * session is elevated. Ask about the thing instead — a single-entity read carries
373
+ * `can_edit`, the write gate's own answer for this session.
299
374
  *
300
375
  * `capabilities.admin_app` is the one fact here about the ACCOUNT rather than the
301
376
  * session: `true` when the backend's administration admits it — the operator, and
302
377
  * whoever else administers the service. The viewer carries it as `adminApp`, and the
303
378
  * package then offers the Admin link (`AUTH.memberHandoff`). It decides what to OFFER,
304
- * never what is allowed on the site, where the session acts as a member whatever the
305
- * account holds. `true` or `false` for every signed-in caller, at the same path as on
379
+ * never what is allowed on the site, where the session acts through its roles only
380
+ * while elevated. `true` or `false` for every signed-in caller, at the same path as on
306
381
  * `/session/bootstrap` (MEASURED by backend, 2026-10-10); anything else reads as
307
382
  * `false`.
308
383
  */
@@ -312,6 +387,7 @@ export const VIEWER = {
312
387
  workspace: 'workspace',
313
388
  capabilities: 'capabilities',
314
389
  adminApp: 'admin_app',
390
+ elevation: ELEVATION.me,
315
391
  }
316
392
 
317
393
  /**
@@ -336,6 +412,8 @@ export const ROUTES = {
336
412
  removeBatch: () => `${ENTITIES}/delete`,
337
413
  /** `GET /models/@scope/name` — the Model's definition, its sections among it. */
338
414
  schema: (model) => `${MODELS}/${modelPath(model)}`,
415
+ /** `GET /sections/{uuid}/items` — one page of a Section's items (`SECTION_ITEMS`). */
416
+ sectionItems: (uuid) => `${SECTIONS}/${segment(uuid)}/items`,
339
417
  }
340
418
 
341
419
  /**
@@ -346,13 +424,18 @@ export const ROUTES = {
346
424
  *
347
425
  * | route | takes |
348
426
  * |---|---|
349
- * | list | `model` (required) · `scope` · `limit` · `offset` · `paginate` · `locale` · `include_disabled` |
427
+ * | list | `model` (required) · `scope` · `limit` · `offset` · `paginate` · `locale` |
350
428
  * | read | `model` (required) · `via` · `depth` · `max_depth` · `locale` |
351
429
  * | items | `model` (required) · `readback` |
352
430
  * | create | `model` (required) |
353
431
  * | remove | `rev_ref_policy` |
432
+ * | section items | `entity` · `parent` · `field` · `q` · `locale` · `cursor` · `limit` |
354
433
  * | verify (`AUTH.verify`) | `token` (required) |
355
434
  *
435
+ * ⛔ **The list takes no `include_disabled`** — `400 Unexpected parameters:
436
+ * include_disabled` (MEASURED 2026-10-14). A disabled entity lists like any other,
437
+ * and its row says `disabled`; the flag only keeps it off a site's public reads.
438
+ *
356
439
  * ✅ **`via` and `depth` compose** (an assumption retired 2026-09-18): both are
357
440
  * parameters of the same read and answer different questions — `via` *who may
358
441
  * read* (through a container the viewer is entitled to), `depth` *how much is
@@ -370,6 +453,7 @@ export const PARAM = {
370
453
  offset: 'offset',
371
454
  locale: 'locale',
372
455
  paginate: 'paginate',
456
+ /** ⛔ Refused by the backend (see above) and never sent. Kept so an import of it does not break. */
373
457
  includeDisabled: 'include_disabled',
374
458
  via: 'via',
375
459
  depth: 'depth',
@@ -377,18 +461,31 @@ export const PARAM = {
377
461
  readback: 'readback',
378
462
  revRefPolicy: 'rev_ref_policy',
379
463
  token: 'token',
464
+ /** Section items: only the items this entity holds. */
465
+ entity: 'entity',
466
+ /** Section items: the items under this item — the level below it. */
467
+ parent: 'parent',
468
+ /** Section items: the field `q` searches. */
469
+ field: 'field',
470
+ /** Section items: the search term. */
471
+ q: 'q',
472
+ /** Section items: the previous page's `next_cursor`. */
473
+ cursor: 'cursor',
380
474
  }
381
475
 
382
476
  /**
383
477
  * The list's `scope`. Default `accessible` — everything the viewer may read; `mine`
384
478
  * — only what the viewer owns.
385
479
  *
386
- * What a session signed in on a site may read — any member's, the operator's
387
- * included (`VIEWER`): its own entities, and what was shared with it. Other members'
388
- * entities are private to their owners unless the site's service was set up to let
389
- * members read each other's. *(A site's service let every member read every other
390
- * member's entities until 2026-09-18, and the operator's session read everything until
391
- * 2026-10-10 — both measured, and changed on the backend.)*
480
+ * What a session signed in on a site may read: its own entities, what was shared with
481
+ * it, and — for an account that holds a role — whatever that role reaches, elevated or
482
+ * not (`ELEVATION`): the operator's session reads every member's. An ordinary member's
483
+ * holds no role, and other members' entities are private to their owners unless the
484
+ * site's service was set up to let members read each other's. *(A site's service let
485
+ * every member read every other member's entities until 2026-09-18 — measured, and
486
+ * changed on the backend. The operator's session read everything until 2026-10-10,
487
+ * nothing of a member's from then, and since 2026-10-14 reads everything again and
488
+ * changes it only while elevated — stated by the backend.)*
392
489
  */
393
490
  export const SCOPE = {
394
491
  mine: 'mine',
@@ -503,9 +600,14 @@ export const CREATE = {
503
600
  *
504
601
  * `via` names why the viewer can read the row — `owner`, `grant`, `rbac`,
505
602
  * `unit_member`, `entitlement`; treat an unknown value as "no opinion". A session's
506
- * own rows are `owner` — the operator's too. *(Until 2026-10-10 the operator's session
507
- * on a site read the rest as `rbac`, through `system_admin`.)* ⚠️ An empty list means
508
- * empty: a lapsed session is a `401`, never `200 []`.
603
+ * own rows are `owner` — the operator's too — and the rest the operator's session reads
604
+ * are `rbac`, through its role, elevated or not. *(From 2026-10-10 to 2026-10-14 the
605
+ * operator's session on a site read nothing of a member's.)* `rbac` is any
606
+ * administrative path — a role, and since 2026-10-14 a reader held at a unit over its
607
+ * people's content, and a reference whose field declares `readable_with` and whose
608
+ * target the viewer holds at that level *[backend, 2026-10-14; not measured here]* —
609
+ * while `grant` stays "shared with you". ⚠️ An empty list means empty: a lapsed session
610
+ * is a `401`, never `200 []`.
509
611
  */
510
612
  export const LIST = {
511
613
  records: 'entities',
@@ -539,11 +641,37 @@ export const READ = {
539
641
  }
540
642
 
541
643
  /**
542
- * A Model's definition — `GET /models/@scope/name`. **MEASURED.**
543
- *
544
- * `{ model: {…}, sections: [{ id, name, kind, is_brief, parent_section_id,
545
- * fields, other_data, constraints }] }` — `kind` is `single` or `multi`;
546
- * `other_data.append_only` marks an insert-only section.
644
+ * A Model's definition — `GET /models/@scope/name`. **MEASURED** 2026-10-14, on data
645
+ * schemas registered the way a site's are.
646
+ *
647
+ * `{ model, sections }`:
648
+ *
649
+ * - **`model`** — `{ id, uuid, name, data, version, owner_id, unit_id, owned,
650
+ * owner_cardinality, unit_cardinality, role, created_at, updated_at }`. `data` holds
651
+ * what the schema declares of the Model — `label`, `description`, `icon`, `plural`
652
+ * among them — and `grantable`.
653
+ * - **each section** — `{ id, model_id, name, kind, is_brief, parent_section_id, fields,
654
+ * other_data, constraints }`. `kind` is `single`, `multi`, or `binder`: a section that
655
+ * holds only sections. A nested section is a row of its own, `parent_section_id`
656
+ * naming its parent; top-level rows come first, and each group by name. ⛔ **A
657
+ * section's uuid is not in the definition** — a field whose choices are a section's
658
+ * items names that section's uuid itself (below).
659
+ * - **`other_data`** — what the schema declares of the section: `label`, `description`,
660
+ * `append_only` (insert-only), `allow_self_nesting` (its items nest — `self_nesting:
661
+ * true` in a schema), `date_field`.
662
+ * - **`constraints`** — `{ rules: [...] }`, or `{}` with none: the section's own rules
663
+ * first, then `{ kind: 'one_of', field, values }` for each field that declares an
664
+ * `enum`. ⛔ **An `enum` is not on its field** — it is that rule.
665
+ * - **each field** — `{ key, required, multi, type: { id: -1, name, kind, data } }`. ⭐
666
+ * **`type.data` is the field's whole declaration**: `key`, `kind`, and what the field
667
+ * declares — `required`, `multi`, `label`, `description`, `localized`, `format`,
668
+ * `max_length`, `min`, `max` among them. `type` is a wrapper kept from an older
669
+ * shape: its `id` is always `-1`, and its `name` is the kind.
670
+ * - **a field of kind `item_ref`** — a choice from a list — carries `section_uuid`, the
671
+ * section whose items are its choices (a schema's `options:` path becomes it), and
672
+ * `entity_uuid` when it pins the entry that holds them. Its choices are
673
+ * `ROUTES.sectionItems(section_uuid)`. A field of kind `entity_ref` carries the
674
+ * `model_uuid` it pins (read in the backend's source).
547
675
  *
548
676
  * ⭐ **Readable by every signed-in account, whatever it may create** — closing a
549
677
  * Model gates creating its entries, never reading its definition (ruled 2026-10-14;
@@ -559,6 +687,67 @@ export const SCHEMA = {
559
687
  name: 'name',
560
688
  kind: 'kind',
561
689
  parent: 'parent_section_id',
690
+ isBrief: 'is_brief',
691
+ fields: 'fields',
692
+ otherData: 'other_data',
693
+ constraints: 'constraints',
694
+ rules: 'rules',
695
+ /** A field's declaration is `field[fieldType][fieldDecl]` — `field.type.data`. */
696
+ fieldType: 'type',
697
+ fieldDecl: 'data',
698
+ /** The rule an `enum` becomes, and the keys it names its field and values by. */
699
+ oneOf: 'one_of',
700
+ ruleField: 'field',
701
+ ruleValues: 'values',
702
+ /** On an `item_ref` field: the section its choices are in, and the entry it pins. */
703
+ choicesSection: 'section_uuid',
704
+ choicesEntity: 'entity_uuid',
705
+ }
706
+
707
+ /**
708
+ * A Section's items — `GET /sections/{uuid}/items`. **MEASURED** 2026-10-14.
709
+ *
710
+ * The choices of an `item_ref` field: the field declares the section by uuid
711
+ * (`SCHEMA.choicesSection`), and that uuid is all the route needs — never the Model,
712
+ * which the section determines.
713
+ *
714
+ * `{ items: [{ uuid, entity, parent?, data, has_children? }], next_cursor }`:
715
+ *
716
+ * - **`uuid`** is what an `item_ref` field stores — its value is that string. `entity`
717
+ * is the entry that holds the item; `parent`, the item it sits under (absent at a
718
+ * root); `has_children`, whether items of the same section sit under it — only for a
719
+ * section whose items nest. `data` is projected to `locale` as every read is.
720
+ * - **Whose items:** those of every entry of the section's Model the viewer may read —
721
+ * the list's rule (`SCOPE`'s `accessible`) — or, with `entity`, that entry's alone:
722
+ * `404` when the viewer may not read it, and when it is of another Model.
723
+ * - **One level at a time.** Without `parent`: the section's top level — every item of a
724
+ * list that does not nest, the roots of one that does, and all the items of a section
725
+ * nested in another. With `parent`: the items under that item — the next level of a
726
+ * nesting list, or a **dependent list's** choices under the item chosen in the field
727
+ * it depends on. A parent the viewer cannot read, or that does not exist, gives an
728
+ * empty page.
729
+ * - **In the section's own order** — an author's arrangement, by entry.
730
+ * - **`field` + `q`** — the items whose `field` (a single `string` or `text` field of
731
+ * the section) holds `q`, ignoring case, anywhere in it; a localized field is matched
732
+ * in the `locale` asked for, and in any language without one. ⚠️ **Such a page may be
733
+ * short while more follow**: only `next_cursor: null` ends the list.
734
+ * - **Paging** — `limit` 50 by default, at most 1000; `cursor` is the opaque
735
+ * `next_cursor` of the page before, and `null` on the last page.
736
+ *
737
+ * Refused: signed out `401`; a section uuid that is not one `400 "invalid section
738
+ * uuid: …"`, an unknown one `404 { kind: 'section' }`; a stale cursor `400 "cursor
739
+ * expired, restart pagination"`; a `binder`, which holds no items, `400 Validation`; a
740
+ * `q` with no `field` `400 Validation`; a `field` the section does not declare, or that
741
+ * is not a single `string` or `text`, `400 Validation` naming it in `field`.
742
+ */
743
+ export const SECTION_ITEMS = {
744
+ items: 'items',
745
+ next: 'next_cursor',
746
+ uuid: 'uuid',
747
+ entity: 'entity',
748
+ parent: 'parent',
749
+ data: 'data',
750
+ hasChildren: 'has_children',
562
751
  }
563
752
 
564
753
  /**
@@ -570,12 +759,26 @@ export const SCHEMA = {
570
759
  * | `409 Conflict` + `item_id`, `current_updated_at` | that item changed since this viewer's token — the ONLY stale-token answer. `item_id` since 2026-09-18; an older backend sends the token alone |
571
760
  * | `409 Conflict`, no token | an invitation that no longer waits — answered, or withdrawn (`INVITATIONS`, 2026-10-12) |
572
761
  * | `409 Append-Only Section` + `section` | an insert-only section: its items can be added and moved, not edited or deleted |
573
- * | `409 Schema Rule Violation` | a rule of the Model: a second item in a one-item section, a section of another Model, a reference that still points here |
762
+ * | `409 Schema Rule Violation` | a rule of the Model: a second item in a one-item section, a section of another Model, a reference that still points here, an item of a nested section without a parent of its parent section |
763
+ * | `409 Bad Reference` | a reference the field does not accept — for an `item_ref`, an item of another section than the one it pins (MEASURED 2026-10-14) |
574
764
  * | `403 Forbidden` + `op`, `target` | may read it, may not do this to it — `edit`, `delete`, `use_model` (create) |
575
765
  * | `403 Email Not Verified` | right password, unverified address |
576
766
  * | `403 CSRF Header Required` | a cookie-authenticated mutation without `X-Uniweb-Csrf` |
577
- * | `400 Validation` + `field` | content that does not fit the Model (`field: 'data.minutes'`) |
767
+ * | `400 Validation` + `field` | content that does not fit the Model (`field: 'data.minutes'`) — see below |
578
768
  * | `404 Not Found` + `kind`, `key` | nothing here for you — not found or not permitted |
769
+ *
770
+ * ⭐ **A field's rule, refused** — `400 Validation`, `reason: 'invalid_field'`, `rule`
771
+ * naming the declaration the value broke, and the declaration's bound beside it
772
+ * (MEASURED 2026-10-14):
773
+ *
774
+ * | rule | the problem's own keys |
775
+ * |---|---|
776
+ * | `required` | `field: 'data.title'` |
777
+ * | `max_length` — in characters, per language for a localized value | `field: 'data.title'` (`'data.label.en'`), `max_length` |
778
+ * | `min` · `max` | `field`, and `min` or `max` |
779
+ * | `format` — a `date` that is not `YYYY-MM-DD`, a calendar day; `2024-05` is refused | `field`, `format: 'date'` |
780
+ * | `type` | `field` |
781
+ * | `one_of` — a value outside a field's `enum` | ⚠️ **no `field`**: the field is named only in `detail` (`item.level = "x" is not one of the allowed values …`) |
579
782
  */
580
783
  export const PROBLEM = {
581
784
  csrf: 'CSRF Header Required',
@@ -603,4 +806,14 @@ export const PROBLEM = {
603
806
  * `op-field-names` — see `FIELD` and `OP`.)* A new entry needs `we`, `from` and
604
807
  * `breaks`; `tests/wire.test.js` pins the set, so adding one is deliberate.
605
808
  */
606
- export const ASSUMPTIONS = []
809
+ export const ASSUMPTIONS = [
810
+ {
811
+ id: 'elevation-by-provider',
812
+ we:
813
+ "send the page's address without its query or fragment as `redirect_uri`, and a fresh `state` of our own per attempt, which we never read back — the session's own `elevation.until` says whether it worked; we then take `?elevate=ok`, `?error=oauth_step_up_not_linked` and our `state` off the address bar",
814
+ from:
815
+ 'the backend names the route, its two parameters and the two answers (2026-10-14), and says it works as step-up\'s provider flow does; what `state` must be, and how an answer joins a `redirect_uri` that has a query of its own, it does not say — a sign-in through a provider sends a fresh `state` per attempt and gets it back',
816
+ breaks:
817
+ "a backend that refuses a `state` it did not mint, or this site's origin as a `redirect_uri`, answers the navigation with a page of its own: the viewer is not elevated, and nothing else changes",
818
+ },
819
+ ]