@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 CHANGED
@@ -52,13 +52,16 @@ function Account() {
52
52
  ```
53
53
 
54
54
  `viewer` is flat — `viewer.uuid`, `viewer.username`, `viewer.handle`, and `viewer.roles`,
55
- a list of `{ role, scope_unit_id }`. ⭐ **Roles describe the session, and a session
56
- signed in on a site holds a member's rights — the operator's too.** A site runs your
57
- foundation's code with the signed-in member's session, so no administrator's role
58
- rides with it: `viewer.roles` never names one there, and an ordinary member holds none
59
- (`[]`). `viewer.workspace` — the site service's home org, `{ handle: 'home', … }` — is
60
- the same for everyone. To decide whether to show an edit control for a particular
61
- record, read that record: `entity.can_edit` is the backend's own answer.
55
+ a list of `{ role, scope_unit_id }`. ⭐ **Roles describe the session, and on a site a
56
+ session holds the account's roles as a reader's until it is elevated.** A site runs
57
+ your foundation's code with the signed-in member's session, so an administrator signed
58
+ in on it reads what their role reaches — the operator reads every member's content —
59
+ and changes nothing through the role until they confirm it is them (*Acting as an
60
+ administrator*, below). Their own content they edit, delete and share as always. An
61
+ ordinary member holds no role (`[]`). `viewer.workspace` — the site service's home org,
62
+ `{ handle: 'home', … }` — is the same for everyone. To decide whether to show an edit
63
+ control for a particular record, read that record: `entity.can_edit` is the backend's
64
+ own answer.
62
65
 
63
66
  **Signing up** — `useSignUp().signUp({ username, email, password })` answers
64
67
  `{ status: 'verification_required', email }`, the same answer for an address already in
@@ -146,6 +149,42 @@ function Invitations() {
146
149
  - ⭐ Draw it wherever a signed-in member lands, not only after an `?offer=` link: an
147
150
  invitation sent to an account comes with no link to follow.
148
151
 
152
+ **Acting as an administrator** — an administrator on the site reads what their role
153
+ reaches; to change something through it — remove a member's post, grade a submission —
154
+ they confirm it is them, and for a while the session acts through the role.
155
+ `useElevation()` offers it:
156
+
157
+ ```jsx
158
+ import { useElevation } from '@uniweb/api'
159
+
160
+ function AdminMode() {
161
+ const { available, elevated, until, elevate, lower, error } = useElevation()
162
+ if (!available) return null
163
+ if (elevated) {
164
+ return <p>Acting as administrator until {new Date(until).toLocaleTimeString()}. <button onClick={lower}>Stop</button></p>
165
+ }
166
+ return <ConfirmItsYou onSubmit={(password, code) => elevate({ password, code })} refused={error?.kind === 'auth'} />
167
+ }
168
+ ```
169
+
170
+ - `available` — the account holds a role that does more than read. Offer it only then.
171
+ - `elevate({ password, code })` — the account's password again, and `code` when it has a
172
+ second factor. Nothing tells you whether it has one, so offer the field to whoever
173
+ might. A wrong password or code is `error.kind === 'auth'`, one answer for both.
174
+ - It lasts as long as the backend decides; `until` says when it ends. `elevated` turns
175
+ false by itself then, and the session is asked again. Elevating again starts the time
176
+ again; nothing extends it on its own. `lower()` ends it early.
177
+ - While it lasts, `viewer.roles` names the role, and every record read before is read
178
+ again — `entity.can_edit` is the record's answer for the session as it is now.
179
+ - `rolesInFull` — draw controls that act through a role from this: true while elevated,
180
+ and always on a backend that asks for no elevation.
181
+ - An account with no password elevates through its provider:
182
+ `elevateWithProvider('google')` goes to the provider's sign-in and back to this page.
183
+ If the identity that comes back is not this account's, `error.extensions.reason` is
184
+ `'oauth_step_up_not_linked'`.
185
+
186
+ Elevating does not open the backend's administration — the Admin link does.
187
+
149
188
  **The Admin link** — some of the site's members administer its backend, the operator
150
189
  among them. For them, `adminHref` is the address that takes them to the backend's
151
190
  administration, already signed in; for everyone else it is `null`, so draw the link
@@ -160,8 +199,8 @@ const { adminHref } = useSession()
160
199
  ⛔ **Navigate to it — a link, or `location.assign(adminHref)` — never `fetch` it.** It
161
200
  answers with a redirect carrying a one-time credential that no script on the site is
162
201
  to hold. The link decides what to offer, not what is allowed: on the site, an
163
- administrator's session acts as a member's, and administering happens at the other
164
- end of the link.
202
+ administrator's session acts through their role only while elevated, and administering
203
+ the backend happens at the other end of the link.
165
204
 
166
205
  ## Reading
167
206
 
@@ -178,10 +217,10 @@ live source — no `backend` service, or nobody signed in — so render your sit
178
217
  Showing "nothing yet" for the first case tells a visitor their content is missing
179
218
  when it is simply not being asked for.
180
219
 
181
- **Whose records.** Everyone signed in on the site — its operator too — reads their own
182
- entities and what was shared with them; other members' are private unless the site's
183
- service was set up to let members read each other's. `scope: 'mine'` lists only the
184
- viewer's own.
220
+ **Whose records.** Everyone signed in on the site reads their own entities, what was
221
+ shared with them, and what a role of theirs reaches — the operator's reaches every
222
+ member's; other members' are private unless the site's service was set up to let members
223
+ read each other's. `scope: 'mine'` lists only the viewer's own.
185
224
 
186
225
  A record is a summary: `record.brief` holds the fields of the Model's brief section. It
187
226
  carries no items — read the entity for those. `hasMore` is true when a page came back
@@ -205,6 +244,54 @@ the item.
205
244
  Its `absent` covers both not-found and not-permitted, on purpose: render your paywall
206
245
  or sign-in prompt on it and never say "deleted".
207
246
 
247
+ ### A Model's definition — what a form is built from
248
+
249
+ ```jsx
250
+ import { useDefinition } from '@uniweb/api'
251
+
252
+ const { status, definition } = useDefinition('@acme/cv')
253
+ const brief = definition?.sections.find((s) => s.brief)
254
+ // brief.fields → [{ key: 'title', kind: 'string', required: true, label: 'Title', max_length: 250 }, …]
255
+ ```
256
+
257
+ Each field is its declaration as the backend stores it — `key`, `kind`, `required`,
258
+ `multi`, and what the schema declares: `label`, `description`, `localized`, `format`,
259
+ `max_length`, `min`, `max`, and `enum`. Each section is `{ name, path, kind, brief,
260
+ fields, rules }`, plus its `label` and `description`; `path` is the name a write takes,
261
+ `parent/child` for a nested section. ⭐ **These are the rules every write is checked
262
+ against**, so a form that reads them checks what the backend will. `readDefinition({
263
+ schema })` is the same outside React.
264
+
265
+ ### A field's choices
266
+
267
+ A field that picks from a list — in a schema, `type: item_ref` with `options:` naming a
268
+ section of a lists type — stores the uuid of the item chosen. Its choices are that
269
+ section's items:
270
+
271
+ ```jsx
272
+ import { useChoices } from '@uniweb/api'
273
+
274
+ const { items, hasMore } = useChoices({ schema: '@acme/cv', field: 'country', q, searchIn: 'label' })
275
+ // items[i].uuid — the value to store; items[i].data — the choice, in the site's language
276
+
277
+ // a dependent list: the provinces of the country chosen above
278
+ const provinces = useChoices(country ? { schema: '@acme/cv', field: 'province', parent: country } : null)
279
+ ```
280
+
281
+ - **A page at a time** — `limit`, 50 by default and at most 1000; pass `nextCursor` back as
282
+ `cursor`. ⚠️ A searched page can be short while more follow: `hasMore` is the end, never
283
+ the page's length.
284
+ - **A search** — `q` anywhere in the choices' field `searchIn`, ignoring case; a
285
+ localized field in the site's language.
286
+ - **One level at a time** — `parent` reads the items under one item: the next level of a
287
+ list whose items nest (each says `has_children`), or a dependent field's choices under
288
+ the item chosen in the field it depends on.
289
+ - **Whose lists** — the items of every entry of the lists type the viewer may read.
290
+ - **Read back**, the field answers `{ item, brief }`: the chosen item's uuid and its data.
291
+
292
+ `listChoices` is the same outside React, and `listSectionItems({ section })` reads a
293
+ section by its uuid.
294
+
208
295
  ## Writing
209
296
 
210
297
  An entity's content is **items, each in a section** of its Model. A section that holds
@@ -258,6 +345,80 @@ Every failure is an `ApiError` with a `kind`: `auth` (sign in), `unverified`, `a
258
345
  (`error.extensions.field` names a field the Model refused), `conflict`, `rule`,
259
346
  `rate-limited`, `unavailable`, `disabled`. Branch on `kind`; `detail` is prose.
260
347
 
348
+ ## Rules, lists, and what stays in your foundation
349
+
350
+ The backend checks every write against the Model's declaration, and refuses to register
351
+ a schema with a declaration it does not take. So where a rule is written decides who
352
+ keeps it.
353
+
354
+ ### On a field — checked on every write
355
+
356
+ | declare | the backend |
357
+ |---|---|
358
+ | `required: true` | refuses a write without the field |
359
+ | `max_length` on `string`, `text` | counts characters — each language of a localized value on its own |
360
+ | `min`, `max` on `int`, `decimal` | refuses a value outside them |
361
+ | `format` — `email`, `url` on `string` and `text`; `currency` on `string`; `markdown`, `html` on `text` | checks `email`, `url` and `currency` (an ISO 4217 code); `markdown` and `html` mark what the text is |
362
+ | `localized: true` on text | holds one string for every language, or one per language |
363
+ | `enum: [...]` | keeps the field to those values — in the definition, as its section's `one_of` rule |
364
+ | `type: item_ref`, `options: '@acme/lists/countries'` | keeps the value to an item of that section — an item of another is `409`, `kind: 'rule'` |
365
+
366
+ A refused value is `kind: 'invalid'`, its `extensions` naming the field (`data.title`,
367
+ `data.label.fr` for one language), the `rule` it broke and the bound
368
+ (`{ rule: 'max_length', max_length: 250 }`). ⚠️ An `enum`'s refusal, `rule: 'one_of'`,
369
+ names its field only in `detail`.
370
+
371
+ ⛔ **A declaration the backend does not take refuses the whole schema** when it reaches
372
+ the backend, naming the field and the key: a `format` its kind does not have
373
+ (`year-month` on a `date`, `year` on an `int`), a key it does not know (`help`), or one it
374
+ does not check yet (`pattern`, `min_length`).
375
+
376
+ ⚠️ **A `date` is a calendar day**, `YYYY-MM-DD`: `2024-05` is refused. A year alone is an
377
+ `int`, with `min` and `max`.
378
+
379
+ ### On a section — rules across fields, and across items
380
+
381
+ A section's `constraints` — a list of rules, in its definition as `rules`:
382
+
383
+ | rule | keeps |
384
+ |---|---|
385
+ | `{ kind: 'exclusive', fields: [...] }` | at most one of the fields set — "another organization" beside a picked one |
386
+ | `{ kind: 'required_one_of', fields: [...] }` | at least one of them set |
387
+ | `{ kind: 'required_when', field, when: { field, equals } }` | the field set whenever another field equals a value |
388
+ | `{ kind: 'field_order', before, after }` | one date no later than another |
389
+ | `{ kind: 'unique_field', field }`, `{ kind: 'unique_combination', fields }` | a value — or values together — once among an entry's items in the section |
390
+ | `{ kind: 'max_items', value }`, `{ kind: 'min_items', value }` | how many items the section holds — `min_items` refuses a delete below it, and does not make anyone add one |
391
+ | `{ kind: 'show_when', field, when: { field, equals } }` | nothing: when a form shows a field — read it from the definition |
392
+
393
+ ### Shared lists
394
+
395
+ Put the lists your fields pick from in **one lists type, a section per list** — not a
396
+ type per list — and its entries where the people who should see them can read them.
397
+ Then:
398
+
399
+ - **A field that picks from one** is `type: item_ref` with `options:` naming its section
400
+ (`'@acme/lists/countries'`, a nested one by its path), and `useChoices` offers it.
401
+ - **A list too long for a dropdown** is searched: `q` with `searchIn`, a page at a time.
402
+ - **A dependent list** — a country, then one of its provinces — is a section nested in
403
+ the other: `countries` holding `subdivisions`, a field picking from each. The second
404
+ field's choices are `useChoices({ …, parent: country })`, and it holds only a province.
405
+ - **A list in levels** — a discipline, then its branches — is a section whose items nest
406
+ (`self_nesting: true`): read it a level at a time with `parent`.
407
+ - **A list people may add to** is one they may edit: who may write an entry's items is
408
+ who may edit the entry. They add a choice with `useEntityWriter` on the lists entry,
409
+ and `refresh()` reads the choices again.
410
+
411
+ ### What stays in your foundation
412
+
413
+ The backend keeps content and checks it. How a field is edited is your foundation's — the
414
+ toolbar of a rich text field, a default for a new entry, a field shown read-only, an
415
+ autocomplete, a value computed from others (a currency converted, shares summed to 100
416
+ before an entry is complete). None of these is a declaration the backend keeps.
417
+
418
+ ⭐ **Who may change a field is who may edit its entry** — the backend decides access per
419
+ entry, never per field. A field only some people may change belongs on an entry only they
420
+ edit.
421
+
261
422
  ## The service on your machine
262
423
 
263
424
  Building against a live service is slow and puts a shared database behind your
@@ -304,13 +465,16 @@ production, and nothing in your site's configuration changes between the two.
304
465
 
305
466
  ⭐ **The mock answers what the backend answers** — the same statuses, bodies and
306
467
  refusals, for every route this package uses. It enforces what your schemas declare —
307
- which sections are insert-only, the fields a section takes — and it is as strict as
308
- the backend about requests, so a mistake fails here rather than in front of a user.
309
- Everyone signed in — the operator too, as on a real site — reads and writes their own
310
- entities and nothing of anyone else's (`memberFloor: 'read'` in the seed models a
311
- service set up to let members read each other's — or per Model,
312
- `memberFloor: { '@acme/course': 'read' }`, so the operator's content is shared while
313
- each member's own records stay private). The mail the backend would send — a sign-up's
468
+ which sections are insert-only, the fields a section takes and their rules (`required`,
469
+ `max_length`, `min` and `max`, a date's form, an `enum`, the `email` and `url` formats, a
470
+ choice from a list) — and it is as strict as the backend about requests, so a mistake
471
+ fails here rather than in front of a user.
472
+ Everyone signed in reads and writes their own entities, and a member nothing of anyone
473
+ else's (`memberFloor: 'read'` in the seed models a service set up to let members read
474
+ each other's — or per Model, `memberFloor: { '@acme/course': 'read' }`, so the
475
+ operator's content is shared while each member's own records stay private). The
476
+ operator, as on a real site, reads every member's entities and changes them only once
477
+ elevated, with its password, for fifteen minutes. The mail the backend would send — a sign-up's
314
478
  confirmation, a password reset — is printed where the mock runs, in the terminal of
315
479
  `uniweb dev`, its link on your site's root as the backend's is, so following it is
316
480
  trying your page; the messages are in `mock.outbox` too. To try an invitation, seed one:
@@ -321,6 +485,20 @@ from the start.
321
485
  The operator's Admin link answers too, landing on a page of the mock's own — the backend's
322
486
  administration is not part of it. State is in memory: restart to reset.
323
487
 
488
+ Write a seed's schemas as you write them for a backend — `type: 'int'`, `multiple: true`,
489
+ `options:` — and the mock's definition answers what a backend keeps of them, so a key no
490
+ backend takes is left out of it (a backend refuses it when you register). Seed a list's
491
+ choices as items of a lists entry, each item holding the items that sit under it:
492
+
493
+ ```js
494
+ { section: 'countries', data: { code: 'CA', label: { en: 'Canada' } }, items: [
495
+ { section: 'countries/subdivisions', data: { name: { en: 'Ontario' } } },
496
+ ] }
497
+ ```
498
+
499
+ A field whose `options:` names no section of a seeded Model is reported when the mock
500
+ starts, and offers no choices — a backend refuses to register it.
501
+
324
502
  There is also a standalone server, for a frontend that is not a Uniweb site — `--site`
325
503
  names that frontend's origin, where the mail's links land:
326
504
 
@@ -332,7 +510,8 @@ npx uniweb-api-mock --port 8787 --site http://localhost:3000
332
510
 
333
511
  `@uniweb/api/client` carries the same operations as plain functions and imports no
334
512
  React: `probeSession`, `signIn`, `listEntities`, `readEntity`, `writeItems`,
335
- `createEntity`, `deleteEntity`, `adminHref`, for the links the backend mails,
513
+ `createEntity`, `deleteEntity`, `adminHref`, for elevation, `elevate`, `lower`,
514
+ `elevateWithProvider`, `isElevated` and `rolesInFull`, for the links the backend mails,
336
515
  `mailedLink`, `verifyEmail`, `claimOffer` and `forgetMailedLink`, and for invitations,
337
516
  `listInvitations`, `acceptInvitation` and `declineInvitation`.
338
517
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/api",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "The client for a Uniweb site's own backend — session, records, entities — imported by a foundation the way it imports @uniweb/kit.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,7 +34,7 @@
34
34
  "node": ">=22.12"
35
35
  },
36
36
  "dependencies": {
37
- "@uniweb/core": "~1.1.0"
37
+ "@uniweb/core": "~1.2.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "react": "^19.0.0"
package/src/client.js CHANGED
@@ -15,10 +15,11 @@ import { getUniweb, deriveCacheKey } from '@uniweb/core'
15
15
  import { resolveService } from '@uniweb/core'
16
16
  import { ApiError } from './errors.js'
17
17
  import { composeUrl, isCrossOrigin, readBody, UNSAFE } from './http.js'
18
- import { AUTH, ROUTES, PARAM, FIELD, LIST, OP, GUARDED_OPS, CREATE, READ, SCHEMA, PAGE, TOTP, VIEWER } from './wire.js'
18
+ import { AUTH, ROUTES, PARAM, FIELD, LIST, OP, GUARDED_OPS, CREATE, READ, SCHEMA, SECTION_ITEMS, PAGE, TOTP, VIEWER } from './wire.js'
19
19
  import { Ledger } from './ledger.js'
20
20
  import { verifyEmailWith, claimOfferWith } from './mailed-links.js'
21
21
  import { listInvitationsWith, acceptInvitationWith, declineInvitationWith } from './invitations.js'
22
+ import { viewerElevation, watchLapse, elevateWith, lowerWith, elevateWithProviderOf } from './elevation.js'
22
23
 
23
24
  /**
24
25
  * The site service this package reads its base from — the only name it owns.
@@ -140,6 +141,68 @@ function labelSections(items, sections) {
140
141
  }
141
142
  }
142
143
 
144
+ /** The values a `one_of` rule allows — `values`, or the `options: [{ value, label }]` form's values. */
145
+ function allowedValues(rule) {
146
+ if (Array.isArray(rule?.[SCHEMA.ruleValues])) return rule[SCHEMA.ruleValues]
147
+ if (Array.isArray(rule?.options)) return rule.options.map((o) => o?.value)
148
+ return []
149
+ }
150
+
151
+ /**
152
+ * A definition as a form reads it (`ApiClient.readDefinition`): each field its own
153
+ * declaration, its `enum` folded back from its section's `one_of` rule; each section
154
+ * with what the schema declares of it, its `path`, and its rules.
155
+ *
156
+ * @param {object} body - `GET /models/@scope/name`, as the backend answers it (`SCHEMA`)
157
+ */
158
+ function formOf(body) {
159
+ const rows = Array.isArray(body?.[SCHEMA.sections]) ? body[SCHEMA.sections] : []
160
+ const byId = new Map(rows.map((s) => [s[SCHEMA.id], s]))
161
+ const pathOf = (row) => {
162
+ const names = []
163
+ const seen = new Set()
164
+ for (let at = row; at && !seen.has(at[SCHEMA.id]); at = byId.get(at[SCHEMA.parent])) {
165
+ seen.add(at[SCHEMA.id])
166
+ names.unshift(at[SCHEMA.name])
167
+ }
168
+ return names.join('/')
169
+ }
170
+ const sections = rows.map((row) => {
171
+ const declared = row?.[SCHEMA.constraints]?.[SCHEMA.rules]
172
+ const rules = Array.isArray(declared) ? declared : []
173
+ const enums = new Map(
174
+ rules
175
+ .filter((r) => r?.kind === SCHEMA.oneOf && typeof r[SCHEMA.ruleField] === 'string')
176
+ .map((r) => [r[SCHEMA.ruleField], allowedValues(r)]),
177
+ )
178
+ const fields = (Array.isArray(row?.[SCHEMA.fields]) ? row[SCHEMA.fields] : []).map((f) => {
179
+ const declaration = f?.[SCHEMA.fieldType]?.[SCHEMA.fieldDecl] ?? {}
180
+ const field = {
181
+ ...declaration,
182
+ key: f?.key ?? declaration.key,
183
+ kind: declaration.kind ?? f?.[SCHEMA.fieldType]?.kind,
184
+ required: f?.required === true || declaration.required === true,
185
+ multi: f?.multi === true || declaration.multi === true,
186
+ }
187
+ if (enums.has(field.key)) field.enum = enums.get(field.key)
188
+ return field
189
+ })
190
+ const other = row?.[SCHEMA.otherData]
191
+ return {
192
+ ...(other && typeof other === 'object' ? other : {}),
193
+ id: row[SCHEMA.id],
194
+ name: row[SCHEMA.name],
195
+ path: pathOf(row),
196
+ kind: row[SCHEMA.kind],
197
+ brief: row[SCHEMA.isBrief] === true,
198
+ parent: row[SCHEMA.parent] ?? null,
199
+ rules,
200
+ fields,
201
+ }
202
+ })
203
+ return { model: body?.model ?? null, sections }
204
+ }
205
+
143
206
  export class ApiClient {
144
207
  /**
145
208
  * @param {object} uniweb - the page's `Uniweb` singleton
@@ -219,6 +282,8 @@ export class ApiClient {
219
282
  const error = next.error ?? null
220
283
  if (cur.status === next.status && cur.viewer === viewer && cur.error === error) return cur
221
284
  this._session = Object.freeze({ status: next.status, viewer, error })
285
+ // An elevated session is asked again when its elevation runs out (`./elevation.js`).
286
+ watchLapse(this)
222
287
  for (const fn of this._listeners) fn()
223
288
  return this._session
224
289
  }
@@ -356,6 +421,7 @@ export class ApiClient {
356
421
  roles: Array.isArray(me?.roles) ? me.roles : [],
357
422
  workspace: viewerWorkspace(me?.workspace),
358
423
  adminApp: me?.[VIEWER.capabilities]?.[VIEWER.adminApp] === true,
424
+ elevation: viewerElevation(me?.[VIEWER.elevation]),
359
425
  })
360
426
  if (this._session.viewer && this._session.viewer.uuid !== viewer.uuid) this.forgetViewer()
361
427
  return this.setSession({ status: 'authenticated', viewer, error: null })
@@ -427,6 +493,35 @@ export class ApiClient {
427
493
  }
428
494
  }
429
495
 
496
+ /**
497
+ * Elevate the session — `{ password, code? }` — so it acts through the account's roles
498
+ * for a while. Answers `{ until }`. A refused credential throws `kind: 'auth'`, one
499
+ * answer for a wrong password or code (`./elevation.js`).
500
+ *
501
+ * @param {{ password: string, code?: string }} credentials
502
+ * @returns {Promise<{ until: string|null }>}
503
+ */
504
+ elevate(credentials) {
505
+ return elevateWith(this, credentials)
506
+ }
507
+
508
+ /** Lower the session before its time runs out. */
509
+ lower() {
510
+ return lowerWith(this)
511
+ }
512
+
513
+ /**
514
+ * Elevate through the account's provider — a navigation to its sign-in, which comes
515
+ * back to the page elevated. For an account with no password.
516
+ *
517
+ * @param {string} provider
518
+ * @param {{ returnTo?: string }} [options]
519
+ * @returns {string|null} the address navigated to
520
+ */
521
+ elevateWithProvider(provider, options) {
522
+ return elevateWithProviderOf(this, provider, options)
523
+ }
524
+
430
525
  /**
431
526
  * Sign up — `{ username, email, password }`, passed through unchanged.
432
527
  *
@@ -688,9 +783,9 @@ export class ApiClient {
688
783
  * a `401` too, never an empty list (so `records: []` means empty).
689
784
  *
690
785
  * `scope` decides whose: the default, `accessible`, is everything the viewer may
691
- * read — signed in on a site, their own entities and what was shared with them, the
692
- * operator's session included (`VIEWER` in `./wire.js`). `mine` is only what the
693
- * viewer owns.
786
+ * read — signed in on a site, their own entities, what was shared with them, and what
787
+ * a role of theirs reaches, elevated or not: the operator's, every member's (`SCOPE`
788
+ * in `./wire.js`). `mine` is only what the viewer owns.
694
789
  *
695
790
  * ## Paging, as far as it can honestly be absorbed
696
791
  *
@@ -737,23 +832,182 @@ export class ApiClient {
737
832
  */
738
833
  sections(schema, signal) {
739
834
  if (!schema) return Promise.reject(ApiError.invalid('No Model', 'sections needs a schema'))
740
- const held = this._schemas.get(schema)
741
- if (held) return held
742
- const pending = this.request('GET', ROUTES.schema(schema), { signal }).then((body) =>
835
+ return this._definition(schema, signal).then((body) =>
743
836
  (Array.isArray(body?.[SCHEMA.sections]) ? body[SCHEMA.sections] : []).map((s) => ({
744
837
  id: s[SCHEMA.id],
745
838
  name: s[SCHEMA.name],
746
839
  parent: s[SCHEMA.parent] ?? null,
747
840
  })),
748
841
  )
842
+ }
843
+
844
+ /** Drop a Model's kept definition, so the next read asks the backend again. */
845
+ forgetDefinition(schema) {
846
+ this._schemas.delete(schema)
847
+ }
848
+
849
+ /** The definition as the backend answers it — read once per viewer, and kept. */
850
+ _definition(schema, signal) {
851
+ const held = this._schemas.get(schema)
852
+ if (held) return held
853
+ const pending = this.request('GET', ROUTES.schema(schema), { signal })
749
854
  this._schemas.set(schema, pending)
750
- // A failure is not kept: the next write asks again.
855
+ // A failure is not kept: the next read asks again.
751
856
  pending.catch(() => {
752
857
  if (this._schemas.get(schema) === pending) this._schemas.delete(schema)
753
858
  })
754
859
  return pending
755
860
  }
756
861
 
862
+ /**
863
+ * A Model's definition — what an editor builds a form from.
864
+ *
865
+ * ```js
866
+ * const { sections } = await readDefinition({ schema: '@acme/cv' })
867
+ * const brief = sections.find((s) => s.brief)
868
+ * // brief.fields → [{ key: 'title', kind: 'string', required: true, label: 'Title', max_length: 250 }, …]
869
+ * ```
870
+ *
871
+ * Each section is `{ id, name, path, kind, brief, parent, rules, fields }` plus what the
872
+ * schema declares of it — `label`, `description`, `append_only`, `allow_self_nesting`:
873
+ * `path` is the name a write takes (`parent/child` for a nested section), `parent` the
874
+ * parent section's id, `rules` its rules (`[]` with none). Each field is its declaration
875
+ * as the backend stores it — `key`, `kind`, `required`, `multi`, and what it declares:
876
+ * `label`, `description`, `localized`, `format`, `max_length`, `min`, `max` — with
877
+ * `enum`, the values its section's `one_of` rule allows, when it has one. A field of
878
+ * kind `item_ref` is a choice from a list: `section_uuid` names the section whose items
879
+ * are its choices (`listChoices`), and `entity_uuid` the entry that holds them, when the
880
+ * field pins one. `model` is the Model's row.
881
+ *
882
+ * ⭐ **These are the rules the backend applies to every write**, so a form that reads
883
+ * them checks what will be checked. What a form does with them — how a field is edited,
884
+ * what it shows — is the foundation's.
885
+ *
886
+ * Read once per viewer and kept. Signed out, the backend answers `401` (`auth`); an
887
+ * unknown Model is `absent`.
888
+ *
889
+ * @param {object} args
890
+ * @param {string} args.schema - the Model, e.g. `@acme/cv`
891
+ * @param {AbortSignal} [args.signal]
892
+ * @returns {Promise<{ model: object|null, sections: object[] }>}
893
+ */
894
+ async readDefinition({ schema, signal } = {}) {
895
+ if (!schema) throw ApiError.invalid('No Model', 'readDefinition needs a schema')
896
+ return formOf(await this._definition(schema, signal))
897
+ }
898
+
899
+ /**
900
+ * One page of a section's items — the choices an `item_ref` field offers, read by the
901
+ * section the field names (`section_uuid` on its declaration). `listChoices` finds
902
+ * that section for you from a field.
903
+ *
904
+ * Each item is `{ uuid, entity, parent?, data, has_children? }`: `uuid` is the value the
905
+ * field stores; `data` the item's content, in the site's language (`locale`, as every
906
+ * read); `parent` the item it sits under; `has_children` — for a list whose items nest
907
+ * — whether items sit under it.
908
+ *
909
+ * - **Whose items:** every entry of the section's Model the viewer may read, or only
910
+ * `entity`'s.
911
+ * - **One level at a time:** without `parent`, the section's top level; with it, the
912
+ * items under that item — the next level of a nesting list, or a dependent list's
913
+ * choices under the item chosen in the field it depends on.
914
+ * - **A search:** `q` matched, ignoring case, anywhere in the section's field `field`
915
+ * (a `string` or `text` field), in the site's language for a localized one.
916
+ * - **Paging:** pass `nextCursor` back as `cursor`. ⚠️ A searched page can be short
917
+ * while more follow, so `hasMore` — a cursor came back — is the only end signal.
918
+ *
919
+ * @param {object} args
920
+ * @param {string} args.section - the section's uuid
921
+ * @param {string} [args.entity] - only this entry's items
922
+ * @param {string} [args.parent] - the uuid of the item whose children to read
923
+ * @param {string} [args.field] - the field `q` is matched in
924
+ * @param {string} [args.q] - the search term
925
+ * @param {string} [args.cursor] - the previous page's `nextCursor`
926
+ * @param {number} [args.limit] - default 50, at most 1000
927
+ * @param {AbortSignal} [args.signal]
928
+ * @returns {Promise<{ items: object[], nextCursor: string|null, hasMore: boolean }>}
929
+ */
930
+ async listSectionItems({ section, entity, parent, field, q, cursor, limit, signal } = {}) {
931
+ if (!section) throw ApiError.invalid('No Section', 'listSectionItems needs a section uuid')
932
+ const query = {
933
+ [PARAM.entity]: entity || undefined,
934
+ [PARAM.parent]: parent || undefined,
935
+ [PARAM.field]: field || undefined,
936
+ [PARAM.q]: q || undefined,
937
+ [PARAM.cursor]: cursor || undefined,
938
+ [PARAM.limit]: limit ?? PAGE.size,
939
+ ...this._localeQuery(),
940
+ }
941
+ const body = await this.request('GET', ROUTES.sectionItems(section), { query, signal })
942
+ const items = Array.isArray(body?.[SECTION_ITEMS.items]) ? body[SECTION_ITEMS.items] : []
943
+ const nextCursor = typeof body?.[SECTION_ITEMS.next] === 'string' ? body[SECTION_ITEMS.next] : null
944
+ return { items, nextCursor, hasMore: nextCursor != null }
945
+ }
946
+
947
+ /**
948
+ * The choices a field offers — the field's own list, read from the Model's definition.
949
+ *
950
+ * ```js
951
+ * const { items } = await listChoices({ schema: '@acme/cv', field: 'country', q: 'can', searchIn: 'label' })
952
+ * // items[i].uuid is the value to store in `country`
953
+ * const provinces = await listChoices({ schema: '@acme/cv', field: 'province', parent: country })
954
+ * ```
955
+ *
956
+ * `field` is a field of kind `item_ref` — in a schema, `type: item_ref` with
957
+ * `options: '@scope/lists/countries'` — and `section` names its section when several
958
+ * declare a field of that name. Everything else is `listSectionItems`'s: `parent` reads
959
+ * the items under an item, which is how a dependent list follows the field it depends
960
+ * on; `q` searches the choices' field `searchIn`.
961
+ *
962
+ * @param {object} args
963
+ * @param {string} args.schema - the Model whose field offers the choices
964
+ * @param {string} args.field - the field's key
965
+ * @param {string} [args.section] - the field's section, by name or path
966
+ * @param {string} [args.parent]
967
+ * @param {string} [args.q]
968
+ * @param {string} [args.searchIn] - the field of the choices `q` is matched in
969
+ * @param {string} [args.cursor]
970
+ * @param {number} [args.limit]
971
+ * @param {AbortSignal} [args.signal]
972
+ * @returns {Promise<{ items: object[], nextCursor: string|null, hasMore: boolean }>}
973
+ */
974
+ async listChoices({ schema, field, section, parent, q, searchIn, cursor, limit, signal } = {}) {
975
+ if (!schema) throw ApiError.invalid('No Model', 'listChoices needs the schema whose field offers the choices')
976
+ if (!field) throw ApiError.invalid('No Field', 'listChoices needs the field whose choices to list')
977
+ const { sections } = await this.readDefinition({ schema, signal })
978
+ const found = sections
979
+ .filter((s) => section == null || s.name === section || s.path === section)
980
+ .flatMap((s) => s.fields.filter((f) => f.key === field))
981
+ if (found.length !== 1) {
982
+ throw ApiError.invalid(
983
+ 'Unknown Field',
984
+ found.length
985
+ ? `${schema} declares '${field}' in several sections — name the section`
986
+ : `${schema} has no field '${field}'${section != null ? ` in section '${section}'` : ''}`,
987
+ )
988
+ }
989
+ const [declared] = found
990
+ const pinned = declared[SCHEMA.choicesSection]
991
+ if (!pinned) {
992
+ throw ApiError.invalid(
993
+ 'No Choices',
994
+ declared.kind === 'item_ref'
995
+ ? `'${field}' of ${schema} names no list its choices are in`
996
+ : `'${field}' of ${schema} is not a choice from a list — its kind is ${declared.kind}`,
997
+ )
998
+ }
999
+ return this.listSectionItems({
1000
+ section: pinned,
1001
+ entity: declared[SCHEMA.choicesEntity],
1002
+ parent,
1003
+ field: searchIn,
1004
+ q,
1005
+ cursor,
1006
+ limit,
1007
+ signal,
1008
+ })
1009
+ }
1010
+
757
1011
  /**
758
1012
  * The numeric id of a section, by name — or by `parent/child` path for a nested
759
1013
  * one — which is how the item route names a section.
@@ -1023,6 +1277,12 @@ export const signIn = (credentials) => required().signIn(credentials)
1023
1277
  export const completeChallenge = (code) => required().completeChallenge(code)
1024
1278
  /** @see ApiClient#signOut */
1025
1279
  export const signOut = () => required().signOut()
1280
+ /** @see ApiClient#elevate — on any instance, an older copy's too */
1281
+ export const elevate = (credentials) => elevateWith(required(), credentials)
1282
+ /** @see ApiClient#lower — on any instance, an older copy's too */
1283
+ export const lower = () => lowerWith(required())
1284
+ /** @see ApiClient#elevateWithProvider — on any instance, an older copy's too */
1285
+ export const elevateWithProvider = (provider, options) => elevateWithProviderOf(required(), provider, options)
1026
1286
  /** @see ApiClient#signUp */
1027
1287
  export const signUp = (fields) => required().signUp(fields)
1028
1288
  /** @see ApiClient#requestPasswordReset */
@@ -1043,6 +1303,12 @@ export const declineInvitation = (id) => declineInvitationWith(required(), id)
1043
1303
  export const readEntity = (args) => required().readEntity(args)
1044
1304
  /** @see ApiClient#listEntities */
1045
1305
  export const listEntities = (args) => required().listEntities(args)
1306
+
1307
+ export const readDefinition = (args) => required().readDefinition(args)
1308
+
1309
+ export const listSectionItems = (args) => required().listSectionItems(args)
1310
+
1311
+ export const listChoices = (args) => required().listChoices(args)
1046
1312
  /** @see ApiClient#writeItems */
1047
1313
  export const writeItems = (args) => required().writeItems(args)
1048
1314
  /** @see ApiClient#createEntity */
@@ -1053,5 +1319,6 @@ export const deleteEntity = (args) => required().deleteEntity(args)
1053
1319
  export const adminHref = () => adminHrefOf(getClient())
1054
1320
 
1055
1321
  export { mailedLink, forgetMailedLink } from './mailed-links.js'
1322
+ export { isElevated, rolesInFull, elevationReturn } from './elevation.js'
1056
1323
  export { ApiError, kindOf } from './errors.js'
1057
1324
  export { Ledger } from './ledger.js'