@uniweb/api 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +200 -21
- package/package.json +2 -2
- package/src/client.js +275 -8
- package/src/elevation.js +268 -0
- package/src/hooks/useChoices.js +102 -0
- package/src/hooks/useDefinition.js +78 -0
- package/src/hooks/useElevation.js +147 -0
- package/src/hooks/useRecords.js +2 -2
- package/src/hooks/useSession.js +5 -0
- package/src/index.js +11 -0
- package/src/mock/definition.js +117 -0
- package/src/mock/index.js +74 -7
- package/src/mock/schema-shape.js +139 -6
- package/src/mock/store.js +442 -61
- package/src/wire.js +247 -34
package/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
|
|
56
|
-
|
|
57
|
-
foundation's code with the signed-in member's session, so
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
`memberFloor:
|
|
313
|
-
each
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
692
|
-
*
|
|
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
|
-
|
|
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
|
|
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'
|