@uniweb/api 0.6.10 → 0.8.0
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 +48 -26
- package/package.json +2 -2
- package/src/client.js +57 -22
- package/src/hooks/useRecords.js +3 -3
- package/src/hooks/useSession.js +7 -1
- package/src/index.js +2 -1
- package/src/mock/index.js +41 -8
- package/src/mock/node.js +2 -2
- package/src/mock/store.js +28 -16
- package/src/wire.js +66 -18
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# @uniweb/api
|
|
2
2
|
|
|
3
|
-
The client a foundation uses to talk to **its site's `
|
|
3
|
+
The client a foundation uses to talk to **its site's `backend` service** — sign-in, the
|
|
4
4
|
site's members, and the content they create.
|
|
5
5
|
|
|
6
6
|
You import it the way you import `@uniweb/kit`. It is bundled into your foundation,
|
|
7
|
-
tree-shaken, and **inert on a site that has no `
|
|
7
|
+
tree-shaken, and **inert on a site that has no `backend` service**: nothing throws, no request
|
|
8
8
|
leaves, and your components render the version of themselves that does not need one.
|
|
9
9
|
|
|
10
10
|
```bash
|
|
@@ -17,9 +17,9 @@ Ask before you draw. This is a synchronous read of the site's own configuration,
|
|
|
17
17
|
a probe — there is nothing to await.
|
|
18
18
|
|
|
19
19
|
```jsx
|
|
20
|
-
import {
|
|
20
|
+
import { isBackendEnabled } from '@uniweb/kit'
|
|
21
21
|
|
|
22
|
-
if (!
|
|
22
|
+
if (!isBackendEnabled()) return <StaticVersion />
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
⛔ **And when the answer is no, draw nothing** — not a disabled button, and not an
|
|
@@ -52,11 +52,13 @@ 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 }`.
|
|
56
|
-
holds
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
60
62
|
|
|
61
63
|
**Signing up** — `useSignUp().signUp({ username, email, password })` answers
|
|
62
64
|
`{ status: 'verification_required', email }`, the same answer for an address already in
|
|
@@ -69,6 +71,23 @@ already taken fails with status `409`.
|
|
|
69
71
|
second factor is enrolled). **Second factor** — when `signIn` answers
|
|
70
72
|
`{ ok: false, challenge: { kind: 'totp' } }`, finish with `completeChallenge(code)`.
|
|
71
73
|
|
|
74
|
+
**The Admin link** — some of the site's members administer its backend, the operator
|
|
75
|
+
among them. For them, `adminHref` is the address that takes them to the backend's
|
|
76
|
+
administration, already signed in; for everyone else it is `null`, so draw the link
|
|
77
|
+
only when it is set:
|
|
78
|
+
|
|
79
|
+
```jsx
|
|
80
|
+
const { adminHref } = useSession()
|
|
81
|
+
|
|
82
|
+
{adminHref && <a href={adminHref}>Admin</a>}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
⛔ **Navigate to it — a link, or `location.assign(adminHref)` — never `fetch` it.** It
|
|
86
|
+
answers with a redirect carrying a one-time credential that no script on the site is
|
|
87
|
+
to hold. The link decides what to offer, not what is allowed: on the site, an
|
|
88
|
+
administrator's session acts as a member's, and administering happens at the other
|
|
89
|
+
end of the link.
|
|
90
|
+
|
|
72
91
|
## Reading
|
|
73
92
|
|
|
74
93
|
```jsx
|
|
@@ -79,14 +98,14 @@ const { status, records, hasMore } = useRecords({ schema: '@acme/session', scope
|
|
|
79
98
|
```
|
|
80
99
|
|
|
81
100
|
⭐ **`absent` and an empty `ready` are different answers.** `absent` means there is no
|
|
82
|
-
live source — no `
|
|
101
|
+
live source — no `backend` service, or nobody signed in — so render your site's own content.
|
|
83
102
|
`ready` with `records: []` means the service answered and there is nothing there.
|
|
84
103
|
Showing "nothing yet" for the first case tells a visitor their content is missing
|
|
85
104
|
when it is simply not being asked for.
|
|
86
105
|
|
|
87
|
-
**Whose records.**
|
|
88
|
-
other members' are private unless the site's
|
|
89
|
-
|
|
106
|
+
**Whose records.** Everyone signed in on the site — its operator too — reads their own
|
|
107
|
+
entities and what was shared with them; other members' are private unless the site's
|
|
108
|
+
service was set up to let members read each other's. `scope: 'mine'` lists only the
|
|
90
109
|
viewer's own.
|
|
91
110
|
|
|
92
111
|
A record is a summary: `record.brief` holds the fields of the Model's brief section. It
|
|
@@ -170,8 +189,9 @@ Building against a live service is slow and puts a shared database behind your
|
|
|
170
189
|
experiments. Name a local one in `site.yml`:
|
|
171
190
|
|
|
172
191
|
```yaml
|
|
173
|
-
|
|
174
|
-
|
|
192
|
+
services:
|
|
193
|
+
backend: true # ask your host for it, on a site you publish
|
|
194
|
+
$devBackend: ./mock/api.js # what answers it in `uniweb dev`; never published
|
|
175
195
|
```
|
|
176
196
|
|
|
177
197
|
```js
|
|
@@ -181,7 +201,7 @@ import { createMockBackend } from '@uniweb/api/mock'
|
|
|
181
201
|
export default createMockBackend({
|
|
182
202
|
seed: {
|
|
183
203
|
accounts: [
|
|
184
|
-
{ username: 'me', password: 'me', operator: true }, // the site
|
|
204
|
+
{ username: 'me', password: 'me', operator: true }, // runs the site, and gets the Admin link
|
|
185
205
|
{ username: 'member', password: 'member' },
|
|
186
206
|
],
|
|
187
207
|
schemas: {
|
|
@@ -203,20 +223,22 @@ export default createMockBackend({
|
|
|
203
223
|
}).fetch
|
|
204
224
|
```
|
|
205
225
|
|
|
206
|
-
`uniweb dev` mounts it at
|
|
207
|
-
|
|
226
|
+
`uniweb dev` mounts it at an address of its own on the dev server, and hands your site that
|
|
227
|
+
address the way a host hands over its own — same origin, so cookies behave as they will in
|
|
228
|
+
production, and nothing in your site's configuration changes between the two.
|
|
208
229
|
|
|
209
230
|
⭐ **The mock answers what the backend answers** — the same statuses, bodies and
|
|
210
231
|
refusals, for every route this package uses. It enforces what your schemas declare —
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
232
|
+
which sections are insert-only, the fields a section takes — and it is as strict as
|
|
233
|
+
the backend about requests, so a mistake fails here rather than in front of a user.
|
|
234
|
+
Everyone signed in — the operator too, as on a real site — reads and writes their own
|
|
235
|
+
entities and nothing of anyone else's (`memberFloor: 'read'` in the seed models a
|
|
236
|
+
service set up to let members read each other's — or per Model,
|
|
216
237
|
`memberFloor: { '@acme/course': 'read' }`, so the operator's content is shared while
|
|
217
238
|
each member's own records stay private). A new sign-up must be
|
|
218
|
-
verified: the link is in `mock.outbox` (the standalone server prints it).
|
|
219
|
-
|
|
239
|
+
verified: the link is in `mock.outbox` (the standalone server prints it). The
|
|
240
|
+
operator's Admin link answers too, landing on a page of the mock's own — the backend's
|
|
241
|
+
administration is not part of it. State is in memory: restart to reset.
|
|
220
242
|
|
|
221
243
|
There is also a standalone server, for a frontend that is not a Uniweb site:
|
|
222
244
|
|
|
@@ -228,7 +250,7 @@ npx uniweb-api-mock --port 8787
|
|
|
228
250
|
|
|
229
251
|
`@uniweb/api/client` carries the same operations as plain functions and imports no
|
|
230
252
|
React: `probeSession`, `signIn`, `listEntities`, `readEntity`, `writeItems`,
|
|
231
|
-
`createEntity`, `deleteEntity`.
|
|
253
|
+
`createEntity`, `deleteEntity`, `adminHref`.
|
|
232
254
|
|
|
233
255
|
## License
|
|
234
256
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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": ">=20.19"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@uniweb/core": "^0.
|
|
37
|
+
"@uniweb/core": "^0.38.1"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"react": "^19.0.0"
|
package/src/client.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @uniweb/api — the client half. No React in this module.
|
|
3
3
|
*
|
|
4
4
|
* A foundation's client for the site's own backend — the one a host declares
|
|
5
|
-
* as the site service `
|
|
5
|
+
* as the site service `backend`. Absent that declaration the site has no such
|
|
6
6
|
* backend and everything here is inert: no request leaves, and a component
|
|
7
7
|
* renders for that state rather than retrying it.
|
|
8
8
|
*
|
|
@@ -15,11 +15,19 @@ 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 } from './wire.js'
|
|
18
|
+
import { AUTH, ROUTES, PARAM, FIELD, LIST, OP, GUARDED_OPS, CREATE, READ, SCHEMA, PAGE, TOTP, VIEWER } from './wire.js'
|
|
19
19
|
import { Ledger } from './ledger.js'
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The site service this package reads its base from — the only name it owns.
|
|
23
|
+
*
|
|
24
|
+
* ⛔ **It was `api` until 2026-10-07.** A service is named for what it gives the
|
|
25
|
+
* site — `search`, `records` — and this one is the site's own backend; `/api` is
|
|
26
|
+
* only the interface it answers on, which is what this package is named for. A
|
|
27
|
+
* foundation built before then asks for `api`, which no host offers now, and draws
|
|
28
|
+
* no sign-in until it is rebuilt.
|
|
29
|
+
*/
|
|
30
|
+
export const SERVICE_NAME = 'backend'
|
|
23
31
|
|
|
24
32
|
/**
|
|
25
33
|
* The shape of the shared instance, as a contract between copies of this
|
|
@@ -31,10 +39,10 @@ export const CONTRACT = 1
|
|
|
31
39
|
/**
|
|
32
40
|
* Where the site's backend is, if it has one.
|
|
33
41
|
*
|
|
34
|
-
* `resolveService` answers with the host's offer first (`config.services.
|
|
35
|
-
* then the site's own
|
|
36
|
-
* names
|
|
37
|
-
* not an error.
|
|
42
|
+
* `resolveService` answers with the host's offer first (`config.services.backend`),
|
|
43
|
+
* then the site's own address (`backend:` under `services:` in `site.yml`), and
|
|
44
|
+
* `null` when neither names one. Absence is the ordinary state of a site with no
|
|
45
|
+
* backend, not an error.
|
|
38
46
|
*
|
|
39
47
|
* @param {object} website - the active Website, or anything shaped `{ config, basePath }`
|
|
40
48
|
* @returns {string|null} the base every request is made against, or null
|
|
@@ -44,34 +52,35 @@ export function resolveBase(website) {
|
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
/**
|
|
47
|
-
* Does this site have an app backend — the `
|
|
55
|
+
* Does this site have an app backend — the `backend` service?
|
|
48
56
|
*
|
|
49
57
|
* The question to ask before drawing a sign-in affordance or any control only a
|
|
50
58
|
* backend can answer. False means: draw nothing, or the static alternative the
|
|
51
59
|
* site already carries.
|
|
52
60
|
*
|
|
53
61
|
* ⭐ **The website argument is OPTIONAL and defaults to the active one**, so this
|
|
54
|
-
* is the same call as `@uniweb/kit`'s `
|
|
62
|
+
* is the same call as `@uniweb/kit`'s `isBackendEnabled()` — one predicate per
|
|
55
63
|
* service, no arguments, the website resolved for you. Pass one explicitly only
|
|
56
64
|
* when you already hold it, or are working outside a render.
|
|
57
65
|
*
|
|
58
66
|
* ⛔ **THE DEFAULT IS NOT A CONVENIENCE, IT CLOSES A TRAP.** For one commit this
|
|
59
67
|
* took a REQUIRED website while every doc and template showed the no-argument
|
|
60
|
-
* spelling — so
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
68
|
+
* spelling — so the documented call resolved `undefined`, returned `false`
|
|
69
|
+
* forever, and drew no sign-in UI on a site that had a backend. Silent, and
|
|
70
|
+
* indistinguishable from a site with no backend: exactly the invisible absence
|
|
71
|
+
* this predicate exists to prevent. Caught in review the same day; pinned by
|
|
72
|
+
* `tests/enabled.test.js`.
|
|
65
73
|
*
|
|
66
|
-
* ⛔ **Renamed
|
|
67
|
-
* its subject and collided with three unrelated `isEnabled`s in the
|
|
68
|
-
* including the field `useSearch()` returns, which made `uniweb
|
|
69
|
-
* search control as gated when it was gated on this instead.
|
|
74
|
+
* ⛔ **Renamed twice.** From `isEnabled` (2026-09-10): the old name said nothing
|
|
75
|
+
* about its subject and collided with three unrelated `isEnabled`s in the
|
|
76
|
+
* framework — including the field `useSearch()` returns, which made `uniweb
|
|
77
|
+
* doctor` read a search control as gated when it was gated on this instead. Then
|
|
78
|
+
* from `isApiEnabled` (2026-10-07), with the service it asks about (`SERVICE_NAME`).
|
|
70
79
|
*
|
|
71
80
|
* @param {object} [website] - defaults to the active website
|
|
72
81
|
* @returns {boolean}
|
|
73
82
|
*/
|
|
74
|
-
export function
|
|
83
|
+
export function isBackendEnabled(website = getUniweb()?.activeWebsite ?? null) {
|
|
75
84
|
return resolveBase(website) !== null
|
|
76
85
|
}
|
|
77
86
|
|
|
@@ -344,6 +353,7 @@ export class ApiClient {
|
|
|
344
353
|
...account,
|
|
345
354
|
roles: Array.isArray(me?.roles) ? me.roles : [],
|
|
346
355
|
workspace: viewerWorkspace(me?.workspace),
|
|
356
|
+
adminApp: me?.[VIEWER.capabilities]?.[VIEWER.adminApp] === true,
|
|
347
357
|
})
|
|
348
358
|
if (this._session.viewer && this._session.viewer.uuid !== viewer.uuid) this.forgetViewer()
|
|
349
359
|
return this.setSession({ status: 'authenticated', viewer, error: null })
|
|
@@ -618,8 +628,9 @@ export class ApiClient {
|
|
|
618
628
|
* a `401` too, never an empty list (so `records: []` means empty).
|
|
619
629
|
*
|
|
620
630
|
* `scope` decides whose: the default, `accessible`, is everything the viewer may
|
|
621
|
-
* read —
|
|
622
|
-
*
|
|
631
|
+
* read — signed in on a site, their own entities and what was shared with them, the
|
|
632
|
+
* operator's session included (`VIEWER` in `./wire.js`). `mine` is only what the
|
|
633
|
+
* viewer owns.
|
|
623
634
|
*
|
|
624
635
|
* ## Paging, as far as it can honestly be absorbed
|
|
625
636
|
*
|
|
@@ -912,6 +923,28 @@ export function getClient() {
|
|
|
912
923
|
return client
|
|
913
924
|
}
|
|
914
925
|
|
|
926
|
+
/**
|
|
927
|
+
* The Admin link — the address that takes the viewer to the backend's administration,
|
|
928
|
+
* signed in — or `null` when there is none to offer: no backend, nobody signed in, or
|
|
929
|
+
* an account the administration does not admit (`viewer.adminApp`).
|
|
930
|
+
*
|
|
931
|
+
* ⛔ **For a navigation — an `<a href>`, `location.assign` — never a `fetch`.** It
|
|
932
|
+
* answers with a redirect carrying a one-time credential that no script on a site is to
|
|
933
|
+
* hold, and a fetch that follows it spends the code (`AUTH.memberHandoff`).
|
|
934
|
+
*
|
|
935
|
+
* A function of the client's base and a session snapshot, not a method: an instance an
|
|
936
|
+
* older copy of this package created has neither, and works here all the same.
|
|
937
|
+
*
|
|
938
|
+
* @param {ApiClient|null} client
|
|
939
|
+
* @param {object} [session] - a snapshot; the client's current one by default
|
|
940
|
+
* @returns {string|null}
|
|
941
|
+
*/
|
|
942
|
+
export function adminHrefOf(client, session = client?.session) {
|
|
943
|
+
const base = client?.base ?? null
|
|
944
|
+
if (base === null || session?.status !== 'authenticated' || session.viewer?.adminApp !== true) return null
|
|
945
|
+
return composeUrl(base, AUTH.memberHandoff)
|
|
946
|
+
}
|
|
947
|
+
|
|
915
948
|
// ── The functions — the same client, outside React ───────────────────────────
|
|
916
949
|
|
|
917
950
|
function required() {
|
|
@@ -944,6 +977,8 @@ export const writeItems = (args) => required().writeItems(args)
|
|
|
944
977
|
export const createEntity = (args) => required().createEntity(args)
|
|
945
978
|
/** @see ApiClient#deleteEntity */
|
|
946
979
|
export const deleteEntity = (args) => required().deleteEntity(args)
|
|
980
|
+
/** The Admin link for the current viewer, or `null` — never throws; see `adminHrefOf`. */
|
|
981
|
+
export const adminHref = () => adminHrefOf(getClient())
|
|
947
982
|
|
|
948
983
|
export { ApiError, kindOf } from './errors.js'
|
|
949
984
|
export { Ledger } from './ledger.js'
|
package/src/hooks/useRecords.js
CHANGED
|
@@ -18,7 +18,7 @@ const DISABLED = Object.freeze({ status: 'absent', records: NONE, matched: 0, ha
|
|
|
18
18
|
* ## ⭐ `absent` and an empty `ready` are DIFFERENT, and conflating them is the bug
|
|
19
19
|
*
|
|
20
20
|
* `absent` means **there is no live source for this viewer** — a site with no
|
|
21
|
-
* `
|
|
21
|
+
* `backend` service, which is the ordinary standalone case, or nobody signed in, since
|
|
22
22
|
* the backend lists nothing to an anonymous caller (it answers `401`, so no request
|
|
23
23
|
* is sent once the session is known to be anonymous). `ready` with `records: []`
|
|
24
24
|
* means **the source answered, and there is nothing there.**
|
|
@@ -34,8 +34,8 @@ const DISABLED = Object.freeze({ status: 'absent', records: NONE, matched: 0, ha
|
|
|
34
34
|
* entity for those. ⚠️ `matched` counts the records in this answer, not a total:
|
|
35
35
|
* `hasMore` is true when the page came back full.
|
|
36
36
|
*
|
|
37
|
-
* The default scope is everything the viewer may read —
|
|
38
|
-
* entities and what was shared with them
|
|
37
|
+
* The default scope is everything the viewer may read — signed in on a site, their
|
|
38
|
+
* own entities and what was shared with them, the operator included.
|
|
39
39
|
* `scope: 'mine'` is only the viewer's own.
|
|
40
40
|
*
|
|
41
41
|
* Cached under a key scoped to the viewer, so a sign-in re-reads the list for who
|
package/src/hooks/useSession.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useSyncExternalStore } from 'react'
|
|
2
|
-
import { getClient } from '../client.js'
|
|
2
|
+
import { getClient, adminHrefOf } from '../client.js'
|
|
3
3
|
|
|
4
4
|
const NONE = Object.freeze({ status: 'anonymous', viewer: null, error: null })
|
|
5
5
|
const noSubscribe = () => () => {}
|
|
@@ -15,6 +15,10 @@ const noop = async () => NONE
|
|
|
15
15
|
* affordance on false. `error` is set when the backend could not be asked;
|
|
16
16
|
* `refresh()` asks again.
|
|
17
17
|
*
|
|
18
|
+
* `adminHref` is the Admin link — set only for a signed-in account the backend's
|
|
19
|
+
* administration admits, `null` for everyone else, so draw the link only when it is
|
|
20
|
+
* set. ⛔ Navigate to it, never `fetch` it (`adminHrefOf` in `../client.js`).
|
|
21
|
+
*
|
|
18
22
|
* Reads the shared snapshot through `useSyncExternalStore`, so every copy of
|
|
19
23
|
* this package on the page sees one session, and the first render on the
|
|
20
24
|
* server matches the first render in the browser.
|
|
@@ -24,6 +28,7 @@ const noop = async () => NONE
|
|
|
24
28
|
* viewer: object | null,
|
|
25
29
|
* error: Error | null,
|
|
26
30
|
* canSignIn: boolean,
|
|
31
|
+
* adminHref: string | null,
|
|
27
32
|
* signOut: () => Promise<void>,
|
|
28
33
|
* refresh: () => Promise<object>,
|
|
29
34
|
* }}
|
|
@@ -48,6 +53,7 @@ export function useSession() {
|
|
|
48
53
|
viewer: session.viewer,
|
|
49
54
|
error: session.error,
|
|
50
55
|
canSignIn: !!(client && client.enabled),
|
|
56
|
+
adminHref: adminHrefOf(client, session),
|
|
51
57
|
signOut,
|
|
52
58
|
refresh,
|
|
53
59
|
}
|
package/src/index.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
export {
|
|
14
14
|
SERVICE_NAME,
|
|
15
15
|
resolveBase,
|
|
16
|
-
|
|
16
|
+
isBackendEnabled,
|
|
17
17
|
probeSession,
|
|
18
18
|
signIn,
|
|
19
19
|
completeChallenge,
|
|
@@ -26,6 +26,7 @@ export {
|
|
|
26
26
|
writeItems,
|
|
27
27
|
createEntity,
|
|
28
28
|
deleteEntity,
|
|
29
|
+
adminHref,
|
|
29
30
|
ApiError,
|
|
30
31
|
Ledger,
|
|
31
32
|
} from './client.js'
|
package/src/mock/index.js
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
|
-
import { AUTH, ENTITIES, MODELS, PARAM, OP, FIELD, CREATE, PAGE, SCOPE, PROBLEM, GUARDED_OPS } from '../wire.js'
|
|
1
|
+
import { AUTH, ENTITIES, MODELS, PARAM, OP, FIELD, CREATE, PAGE, SCOPE, PROBLEM, GUARDED_OPS, HANDOFF_REFUSAL } from '../wire.js'
|
|
2
2
|
import { MockStore, isUuid, refuse } from './store.js'
|
|
3
3
|
import { DEFAULT_SEED } from './seed.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* Where the mock's Admin link lands. The backend's administration lives on its own
|
|
7
|
+
* origin, outside the API, and the mock models none of it: the handoff's redirect
|
|
8
|
+
* comes here instead, to a page that says so. A path of the mock's, not the backend's.
|
|
9
|
+
*/
|
|
10
|
+
const ADMIN_PAGE = '/admin'
|
|
11
|
+
|
|
12
|
+
const escapeHtml = (value) =>
|
|
13
|
+
String(value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
14
|
+
|
|
15
|
+
function adminPage(account) {
|
|
16
|
+
const who = account ? `Signed in as <b>${escapeHtml(account.handle)}</b>. ` : ''
|
|
17
|
+
return `<!doctype html><meta charset="utf-8"><title>Administration</title>
|
|
18
|
+
<p>${who}On a live site, the Admin link opens the backend's administration here, signed in. The local mock has none to open.</p>
|
|
19
|
+
`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A mock of a site's `backend` service, for local development.
|
|
7
24
|
*
|
|
8
25
|
* ```js
|
|
9
26
|
* import { createMockBackend } from '@uniweb/api/mock'
|
|
@@ -29,19 +46,22 @@ import { DEFAULT_SEED } from './seed.js'
|
|
|
29
46
|
* ## Where it answers
|
|
30
47
|
*
|
|
31
48
|
* Its routes are the API's own — `/auth/me`, `/entities` — under whatever the
|
|
32
|
-
* site's `
|
|
49
|
+
* site's `backend` address is. It accepts a request with the site's `/_api` prefix
|
|
33
50
|
* still on, and one with an extra `/api` after it (how clients before 0.4
|
|
34
51
|
* composed URLs), so it answers the same whether or not the server mounting it
|
|
35
52
|
* strips its path.
|
|
36
53
|
*
|
|
37
54
|
* ## What it models, and what it does not
|
|
38
55
|
*
|
|
39
|
-
* One operator (`operator: true` on a seeded account) and members;
|
|
40
|
-
* and writes their own entities and
|
|
41
|
-
* `memberFloor` says the service lets them)
|
|
56
|
+
* One operator (`operator: true` on a seeded account) and members; everyone signed
|
|
57
|
+
* in — the operator too, as on a real site — reads and writes their own entities and
|
|
58
|
+
* nothing of another's (unless the seed's `memberFloor` says the service lets them);
|
|
42
59
|
* anyone signed in may create entries of any Model (Models are open); sign-up
|
|
43
|
-
* leaves an account unverified until the link in `mock.outbox` is followed.
|
|
44
|
-
*
|
|
60
|
+
* leaves an account unverified until the link in `mock.outbox` is followed. The
|
|
61
|
+
* operator is the account the backend's administration admits, so its Admin link
|
|
62
|
+
* answers — with a redirect to a page of the mock's own, since the administration
|
|
63
|
+
* itself is not modelled. It does not model entitlements behind `via`, nested
|
|
64
|
+
* sections, or second factors.
|
|
45
65
|
*
|
|
46
66
|
* @param {object} [options]
|
|
47
67
|
* @param {object} [options.seed] - accounts, schemas and entities to start from (see `MockStore`)
|
|
@@ -199,6 +219,14 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
|
|
|
199
219
|
const viewer = store.viewer()
|
|
200
220
|
return viewer ? json(200, viewer) : unauthorized()
|
|
201
221
|
}
|
|
222
|
+
if (path === AUTH.memberHandoff && method === 'GET') {
|
|
223
|
+
if (!store.account) return unauthorized()
|
|
224
|
+
if (!store.account.operator) return problem(HANDOFF_REFUSAL)
|
|
225
|
+
// The backend redirects to its administration on its own origin. The mock has
|
|
226
|
+
// none, so the redirect lands on a page of its own that says so (`ADMIN_PAGE`).
|
|
227
|
+
// Relative, so it lands under whatever path the mock is mounted at.
|
|
228
|
+
return new Response(null, { status: 302, headers: { location: `..${ADMIN_PAGE}` } })
|
|
229
|
+
}
|
|
202
230
|
if (path === AUTH.login && method === 'POST') {
|
|
203
231
|
const b = await body(request)
|
|
204
232
|
return fields(b, ['username', 'password'], '{"username": ..., "password": ...}') ?? answer(store.signIn(b))
|
|
@@ -391,6 +419,11 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
|
|
|
391
419
|
}
|
|
392
420
|
}
|
|
393
421
|
|
|
422
|
+
// ── Where the Admin link lands ──────────────────────────────────────────
|
|
423
|
+
if (path === ADMIN_PAGE && method === 'GET') {
|
|
424
|
+
return new Response(adminPage(store.account), { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
425
|
+
}
|
|
426
|
+
|
|
394
427
|
return problem({ status: 404, title: 'Not Found', detail: `no route for ${method} ${path}` })
|
|
395
428
|
}
|
|
396
429
|
|
package/src/mock/node.js
CHANGED
|
@@ -40,7 +40,7 @@ async function send(response, res) {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Connect-style middleware — what Vite's `configureServer` takes. Mount it at the
|
|
43
|
-
* site's `
|
|
43
|
+
* site's `backend` address (`/_api`), which is the base the client composes routes
|
|
44
44
|
* onto: `/_api/auth/me`, `/_api/entities`.
|
|
45
45
|
*
|
|
46
46
|
* ```js
|
|
@@ -76,7 +76,7 @@ export function middleware(mock, { prefix = '/_api' } = {}) {
|
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
78
|
* A standalone server. Resolves once listening; call `close()` to stop. Its routes
|
|
79
|
-
* answer at the root — a site's `
|
|
79
|
+
* answer at the root — a site's `backend` address for it is the server's URL
|
|
80
80
|
* (`http://localhost:8787`), plus `prefix` when one is given.
|
|
81
81
|
*
|
|
82
82
|
* @param {{ fetch: Function }} mock
|
package/src/mock/store.js
CHANGED
|
@@ -6,7 +6,7 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
|
|
|
6
6
|
* entities whose content is items.
|
|
7
7
|
*
|
|
8
8
|
* ⭐ **It stores what the backend stores, the way the backend stores it** —
|
|
9
|
-
* measured 2026-09-18 against a real one set up as a site's `
|
|
9
|
+
* measured 2026-09-18 against a real one set up as a site's `backend` service:
|
|
10
10
|
*
|
|
11
11
|
* - **An entity's content is items, each in a section of its Model.** There is no
|
|
12
12
|
* entity-level data. A `single` section holds one item; its `brief` section's item
|
|
@@ -19,10 +19,12 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
|
|
|
19
19
|
* unless shared. A service can be set up to let members read (or edit) each
|
|
20
20
|
* other's — the seed's `memberFloor` models that choice. *(Until 2026-09-18 the
|
|
21
21
|
* backend gave every member read access to every other member's entities.)*
|
|
22
|
-
* - **The operator** — the account that runs the site's service —
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* - **The operator** — the account that runs the site's service — owns what the
|
|
23
|
+
* seed gives it. ⛔ **Signed in on the site it holds a member's rights**, like
|
|
24
|
+
* everyone: no `system_admin` in `roles`, and nothing of another member's unless
|
|
25
|
+
* shared — a site runs third-party code with its member's session. *(Until
|
|
26
|
+
* 2026-10-10 the operator's session held `system_admin` and read and wrote
|
|
27
|
+
* everything.)* A seeded account is the operator with `operator: true`.
|
|
26
28
|
*
|
|
27
29
|
* ⛔ **Seeded fixtures plus in-memory mutation, and deliberately not a database.**
|
|
28
30
|
* A mock that grows a schema and migrations becomes a second implementation that
|
|
@@ -207,15 +209,21 @@ export class MockStore {
|
|
|
207
209
|
return this.session?.account ?? null
|
|
208
210
|
}
|
|
209
211
|
|
|
210
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* The viewer, in the shape `/auth/me` answers. `roles` are the session's, and a
|
|
214
|
+
* session on a site holds no administrator's — the operator's neither. Whether the
|
|
215
|
+
* backend's administration admits the ACCOUNT is `capabilities.admin_app`: the
|
|
216
|
+
* operator's yes, a member's no.
|
|
217
|
+
*/
|
|
211
218
|
viewer(account = this.account) {
|
|
212
219
|
if (!account) return null
|
|
213
220
|
return {
|
|
214
221
|
account: this.identity(account),
|
|
215
|
-
roles:
|
|
222
|
+
roles: [],
|
|
216
223
|
// A request that names no workspace — as this package's never do — lands in the
|
|
217
224
|
// home org, which every account belongs to. Both null would mean a non-member.
|
|
218
225
|
workspace: { ...HOME_ORG },
|
|
226
|
+
capabilities: { admin_app: account.operator === true },
|
|
219
227
|
}
|
|
220
228
|
}
|
|
221
229
|
|
|
@@ -229,7 +237,8 @@ export class MockStore {
|
|
|
229
237
|
if (!account.verified) return refuse(403, PROBLEM.notVerified, 'verify your email address before signing in')
|
|
230
238
|
this.session = { account }
|
|
231
239
|
const expires = new Date(Date.now() + 30 * 24 * 3600 * 1000).toISOString()
|
|
232
|
-
|
|
240
|
+
// No `token`: a site's backend answers a sign-in with the session cookie alone.
|
|
241
|
+
return { body: { expires_at: expires, account: this.identity(account) } }
|
|
233
242
|
}
|
|
234
243
|
|
|
235
244
|
signOut() {
|
|
@@ -468,28 +477,31 @@ export class MockStore {
|
|
|
468
477
|
return floor[entity.model] ?? null
|
|
469
478
|
}
|
|
470
479
|
|
|
471
|
-
/**
|
|
480
|
+
/**
|
|
481
|
+
* The owner — or any member, when the service lets members read each other's. The
|
|
482
|
+
* operator is a member here like everyone: a session on a site holds no
|
|
483
|
+
* administrator's rights.
|
|
484
|
+
*/
|
|
472
485
|
mayRead(entity) {
|
|
473
486
|
const me = this.account
|
|
474
|
-
return !!me && (
|
|
487
|
+
return !!me && (entity.owner_id === me.id || this.floorFor(entity) != null)
|
|
475
488
|
}
|
|
476
489
|
|
|
477
490
|
mayEdit(entity) {
|
|
478
491
|
const me = this.account
|
|
479
|
-
return !!me && (
|
|
492
|
+
return !!me && (entity.owner_id === me.id || this.floorFor(entity) === 'edit')
|
|
480
493
|
}
|
|
481
494
|
|
|
482
495
|
/**
|
|
483
496
|
* Which branch lets the viewer read a row, as the backend names it — the first to
|
|
484
|
-
* answer in its order: `owner`, `grant`, `rbac`, `unit_member`.
|
|
485
|
-
* are `owner
|
|
486
|
-
* 2026-
|
|
487
|
-
*
|
|
497
|
+
* answer in its order: `owner`, `grant`, `rbac`, `unit_member`. A session's own rows
|
|
498
|
+
* are `owner`, the operator's too, and the rest it reads by the service's floor.
|
|
499
|
+
* *(Until 2026-10-10 the operator's session read the rest as `rbac`, through
|
|
500
|
+
* `system_admin`.)*
|
|
488
501
|
*/
|
|
489
502
|
via(entity) {
|
|
490
503
|
const me = this.account
|
|
491
504
|
if (entity.owner_id === me?.id) return 'owner'
|
|
492
|
-
if (me?.operator) return 'rbac'
|
|
493
505
|
return 'unit_member'
|
|
494
506
|
}
|
|
495
507
|
|
package/src/wire.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* ## ⭐ MEASURED 2026-09-18 — every shape below, in literal responses
|
|
23
23
|
*
|
|
24
24
|
* The whole surface this package touches was exercised against a real backend set
|
|
25
|
-
* up the way a site's `
|
|
25
|
+
* up the way a site's `backend` service is: one operator account, members who sign up
|
|
26
26
|
* themselves, and the site's own Models. Where this module quotes a shape it was
|
|
27
27
|
* copied from a response, not described. That pass corrected the package in these
|
|
28
28
|
* places, each now fixed and pinned by a test:
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
* The lane. ⭐ **RULED** *(2026-09-01)*: this package reads and writes
|
|
48
48
|
* **entities**, reads the definitions of their Models, and touches nothing under
|
|
49
49
|
* `/sites/*`. Those routes create sites, which is the app's job, not a
|
|
50
|
-
* foundation's, and a site's own `
|
|
50
|
+
* foundation's, and a site's own `backend` service has no site to address.
|
|
51
51
|
*
|
|
52
52
|
* ⛔ Do not add a route that is not under `/entities`, `/models` or `/auth`.
|
|
53
53
|
*/
|
|
@@ -59,7 +59,7 @@ export const MODELS = '/models'
|
|
|
59
59
|
/**
|
|
60
60
|
* The base, and how a route joins it. **MEASURED.**
|
|
61
61
|
*
|
|
62
|
-
* The base the site is handed for its `
|
|
62
|
+
* The base the site is handed for its `backend` service is the address of the
|
|
63
63
|
* backend's API route space, and every route here is relative to it:
|
|
64
64
|
* `${base}/auth/me`, `${base}/entities`. On a hosted site that is `/_api` on the
|
|
65
65
|
* site's own origin, which reaches the backend's `/api/…`; wherever a page reaches
|
|
@@ -99,8 +99,32 @@ export const AUTH = {
|
|
|
99
99
|
verify: '/auth/verify',
|
|
100
100
|
resetRequest: '/auth/reset/request',
|
|
101
101
|
resetConfirm: '/auth/reset/confirm',
|
|
102
|
+
/**
|
|
103
|
+
* `GET /auth/member-handoff` — the Admin link (`adminHref` in `./client.js`).
|
|
104
|
+
* ⛔ **A navigation, never a fetch.** For an account the backend's administration
|
|
105
|
+
* admits (`VIEWER`'s `admin_app`), it answers `302` toward that administration, on
|
|
106
|
+
* the backend's own origin, carrying a one-time credential no script on a site is to
|
|
107
|
+
* hold — a fetch learns nothing from the redirect, and one that follows it spends
|
|
108
|
+
* the code. `401` no session · `403` an account it does not admit
|
|
109
|
+
* (`HANDOFF_REFUSAL`) · `404` a backend no site reaches · `503` a backend never told
|
|
110
|
+
* its own origin — each problem JSON, branched on by `title` or `reason`, never
|
|
111
|
+
* `detail`. **MEASURED** by backend, 2026-10-10.
|
|
112
|
+
*/
|
|
113
|
+
memberHandoff: '/auth/member-handoff',
|
|
102
114
|
}
|
|
103
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The handoff's `403`, as sent — an account the administration does not admit.
|
|
118
|
+
* **MEASURED** by backend, 2026-10-10. This package never reads it: the handoff is a
|
|
119
|
+
* navigation, so the browser shows it. The mock answers it.
|
|
120
|
+
*/
|
|
121
|
+
export const HANDOFF_REFUSAL = Object.freeze({
|
|
122
|
+
status: 403,
|
|
123
|
+
title: 'Admin Role Required',
|
|
124
|
+
detail: "the admin app admits a system_admin or an administrator of this deployment's home org",
|
|
125
|
+
reason: 'admin_role_required',
|
|
126
|
+
})
|
|
127
|
+
|
|
104
128
|
/**
|
|
105
129
|
* The bodies of the auth routes. **MEASURED.** This package hands a caller's
|
|
106
130
|
* fields to the backend unchanged, so these are the names a caller must use — a
|
|
@@ -108,13 +132,18 @@ export const AUTH = {
|
|
|
108
132
|
*
|
|
109
133
|
* | route | body | answer |
|
|
110
134
|
* |---|---|---|
|
|
111
|
-
* | login | `{ username, password }` | `200 {
|
|
135
|
+
* | login | `{ username, password }` | `200 { expires_at, account }` + the session cookie · `200 { status: 'totp_required', challenge_token }` · `401` wrong credential · `403 Email Not Verified` |
|
|
112
136
|
* | challenge | `{ challenge_token, code }` | as login's `200`; a bad token or code is `401` |
|
|
113
137
|
* | register | `{ username, email, password }` | `202 { status: 'verification_required', email }` — the same answer for an address already taken; a taken **username** is `409` |
|
|
114
138
|
* | reset request | `{ email }` | `202 { status: 'reset_requested' }` whether or not the address is known |
|
|
115
139
|
* | reset confirm | `{ token, new_password, code? }` | `200 { reset: true }`; a bad token is `400 Validation`; `code` is required when a second factor is enrolled |
|
|
116
140
|
* | logout | none | `204`; with no session to end, `401` |
|
|
117
|
-
* | me | none | `200 { account: { uuid, username, handle }, roles, workspace: { unit_uuid, handle } }` · `401` |
|
|
141
|
+
* | me | none | `200 { account: { uuid, username, handle }, roles, workspace: { unit_uuid, handle }, capabilities: { admin_app } }` · `401` |
|
|
142
|
+
*
|
|
143
|
+
* ⭐ **The session is the cookie, and only the cookie.** A site's backend answers a
|
|
144
|
+
* sign-in with no `token` (since 2026-10-10, MEASURED by backend; `token` was in the
|
|
145
|
+
* reply until then), and this package never read one: it signs in, then asks
|
|
146
|
+
* `/auth/me` who the viewer is.
|
|
118
147
|
*/
|
|
119
148
|
export const AUTH_BODY = {
|
|
120
149
|
login: ['username', 'password'],
|
|
@@ -135,10 +164,16 @@ export const TOTP = {
|
|
|
135
164
|
* The viewer, as `/auth/me` answers. **MEASURED.**
|
|
136
165
|
*
|
|
137
166
|
* `roles` is a list of `{ role, scope_unit_id }` — `role` one of `system_admin`,
|
|
138
|
-
* `unit_admin`, `content_editor`, `user`.
|
|
167
|
+
* `unit_admin`, `content_editor`, `user`. ⭐ **They describe the session, not the
|
|
168
|
+
* account.** On a site's `backend` service a session signed in on the site holds no
|
|
169
|
+
* `system_admin` and no `unit_admin` of the home org — the operator's neither — so
|
|
170
|
+
* `roles` never name an administrator there: a site runs third-party code with its
|
|
171
|
+
* member's session, and a bug in that code must not act as one. An ordinary member
|
|
172
|
+
* holds none: `[]`. *(Since 2026-10-10, MEASURED by backend; until then the operator's
|
|
173
|
+
* session on a site held `system_admin`.)*
|
|
139
174
|
*
|
|
140
175
|
* `workspace` is `{ unit_uuid, handle }` — **the workspace the request works in**. This
|
|
141
|
-
* package's requests name none, and on a site's `
|
|
176
|
+
* package's requests name none, and on a site's `backend` service such a request lands in
|
|
142
177
|
* the service's **home org**, `handle: 'home'`, in which every account is enrolled.
|
|
143
178
|
* Both `null` there means an account that is not a member — a service account, a
|
|
144
179
|
* revoked membership. (On a backend with no home org, naming none is personal: both
|
|
@@ -147,14 +182,26 @@ export const TOTP = {
|
|
|
147
182
|
* function the route serializes, 2026-09-23.)*
|
|
148
183
|
*
|
|
149
184
|
* ⛔ **Neither is an operator signal.** Every member of a site's service works in the
|
|
150
|
-
* same home org, so
|
|
151
|
-
* minute ago
|
|
152
|
-
* thing — a single-entity read carries `can_edit`, the write gate's own answer
|
|
185
|
+
* same home org, so `workspace` reads the same for the operator and for someone who
|
|
186
|
+
* signed up a minute ago, and on a site `roles` name no administrator. Ask about the
|
|
187
|
+
* thing instead — a single-entity read carries `can_edit`, the write gate's own answer
|
|
188
|
+
* for this session.
|
|
189
|
+
*
|
|
190
|
+
* `capabilities.admin_app` is the one fact here about the ACCOUNT rather than the
|
|
191
|
+
* session: `true` when the backend's administration admits it — the operator, and
|
|
192
|
+
* whoever else administers the service. The viewer carries it as `adminApp`, and the
|
|
193
|
+
* package then offers the Admin link (`AUTH.memberHandoff`). It decides what to OFFER,
|
|
194
|
+
* never what is allowed on the site, where the session acts as a member whatever the
|
|
195
|
+
* account holds. `true` or `false` for every signed-in caller, at the same path as on
|
|
196
|
+
* `/session/bootstrap` (MEASURED by backend, 2026-10-10); anything else reads as
|
|
197
|
+
* `false`.
|
|
153
198
|
*/
|
|
154
199
|
export const VIEWER = {
|
|
155
200
|
account: 'account',
|
|
156
201
|
roles: 'roles',
|
|
157
202
|
workspace: 'workspace',
|
|
203
|
+
capabilities: 'capabilities',
|
|
204
|
+
adminApp: 'admin_app',
|
|
158
205
|
}
|
|
159
206
|
|
|
160
207
|
/**
|
|
@@ -224,11 +271,12 @@ export const PARAM = {
|
|
|
224
271
|
* The list's `scope`. Default `accessible` — everything the viewer may read; `mine`
|
|
225
272
|
* — only what the viewer owns.
|
|
226
273
|
*
|
|
227
|
-
* What a
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* entities until 2026-09-18
|
|
274
|
+
* What a session signed in on a site may read — any member's, the operator's
|
|
275
|
+
* included (`VIEWER`): its own entities, and what was shared with it. Other members'
|
|
276
|
+
* entities are private to their owners unless the site's service was set up to let
|
|
277
|
+
* members read each other's. *(A site's service let every member read every other
|
|
278
|
+
* member's entities until 2026-09-18, and the operator's session read everything until
|
|
279
|
+
* 2026-10-10 — both measured, and changed on the backend.)*
|
|
232
280
|
*/
|
|
233
281
|
export const SCOPE = {
|
|
234
282
|
mine: 'mine',
|
|
@@ -342,9 +390,9 @@ export const CREATE = {
|
|
|
342
390
|
* paging; a page that came back full is the only sign there may be more.
|
|
343
391
|
*
|
|
344
392
|
* `via` names why the viewer can read the row — `owner`, `grant`, `rbac`,
|
|
345
|
-
* `unit_member`, `entitlement`; treat an unknown value as "no opinion".
|
|
346
|
-
*
|
|
347
|
-
*
|
|
393
|
+
* `unit_member`, `entitlement`; treat an unknown value as "no opinion". A session's
|
|
394
|
+
* own rows are `owner` — the operator's too. *(Until 2026-10-10 the operator's session
|
|
395
|
+
* on a site read the rest as `rbac`, through `system_admin`.)* ⚠️ An empty list means
|
|
348
396
|
* empty: a lapsed session is a `401`, never `200 []`.
|
|
349
397
|
*/
|
|
350
398
|
export const LIST = {
|