@uniweb/api 0.4.0 → 0.5.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 +12 -8
- package/package.json +2 -2
- package/src/client.js +19 -7
- package/src/hooks/useRecords.js +3 -2
- package/src/mock/index.js +8 -5
- package/src/mock/store.js +48 -11
- package/src/wire.js +39 -17
package/README.md
CHANGED
|
@@ -53,8 +53,9 @@ function Account() {
|
|
|
53
53
|
|
|
54
54
|
`viewer` is flat — `viewer.uuid`, `viewer.username`, `viewer.handle`, and `viewer.roles`,
|
|
55
55
|
a list of `{ role, scope_unit_id }`. A member holds no roles (`[]`); the site's operator
|
|
56
|
-
holds `system_admin`. ⚠️ `viewer.
|
|
57
|
-
|
|
56
|
+
holds `system_admin`. ⚠️ `viewer.workspace` — the site service's home org,
|
|
57
|
+
`{ handle: 'home', … }` — is the same for every member, so it cannot tell an operator
|
|
58
|
+
from a member. To decide whether to show an edit control for
|
|
58
59
|
a particular record, read that record: `entity.can_edit` is the backend's own answer.
|
|
59
60
|
|
|
60
61
|
**Signing up** — `useSignUp().signUp({ username, email, password })` answers
|
|
@@ -83,9 +84,10 @@ live source — no `api` service, or nobody signed in — so render your site's
|
|
|
83
84
|
Showing "nothing yet" for the first case tells a visitor their content is missing
|
|
84
85
|
when it is simply not being asked for.
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
site's
|
|
88
|
-
|
|
87
|
+
**Whose records.** A member reads their own entities and what was shared with them;
|
|
88
|
+
other members' are private unless the site's service was set up to let members read
|
|
89
|
+
each other's. The site's operator reads everything. `scope: 'mine'` lists only the
|
|
90
|
+
viewer's own.
|
|
89
91
|
|
|
90
92
|
A record is a summary: `record.brief` holds the fields of the Model's brief section. It
|
|
91
93
|
carries no items — read the entity for those. `hasMore` is true when a page came back
|
|
@@ -202,9 +204,11 @@ will in production, and your site's configuration is identical either way.
|
|
|
202
204
|
refusals, for every route this package uses. It enforces what your schemas declare —
|
|
203
205
|
who may create entries, which sections are insert-only, the fields a section takes —
|
|
204
206
|
and it is as strict as the backend about requests, so a mistake fails here rather
|
|
205
|
-
than in front of a user. Members read
|
|
206
|
-
|
|
207
|
-
|
|
207
|
+
than in front of a user. Members read and write their own entities and nothing of each
|
|
208
|
+
other's; the operator reads and writes everything (`memberFloor: 'read'` in the seed
|
|
209
|
+
models a service set up to let members read each other's). A new sign-up must be
|
|
210
|
+
verified: the link is in `mock.outbox` (the standalone server prints it). State is in
|
|
211
|
+
memory: restart to reset.
|
|
208
212
|
|
|
209
213
|
There is also a standalone server, for a frontend that is not a Uniweb site:
|
|
210
214
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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.29.
|
|
37
|
+
"@uniweb/core": "^0.29.8"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"react": "^19.0.0"
|
package/src/client.js
CHANGED
|
@@ -93,6 +93,16 @@ const LOADING = Object.freeze({ status: 'loading', viewer: null, error: null })
|
|
|
93
93
|
* from `website.config` on each call, so the editor's `Website.rebuild()`
|
|
94
94
|
* needs no hook here.
|
|
95
95
|
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `/auth/me`'s `workspace` — `{ unit_uuid, handle }`, both null when none — as the
|
|
99
|
+
* viewer carries it: `{ unitUuid, handle }`, or `null` for none (`VIEWER`).
|
|
100
|
+
*/
|
|
101
|
+
function viewerWorkspace(ws) {
|
|
102
|
+
if (!ws || typeof ws !== 'object' || (ws.unit_uuid == null && ws.handle == null)) return null
|
|
103
|
+
return Object.freeze({ unitUuid: ws.unit_uuid ?? null, handle: ws.handle ?? null })
|
|
104
|
+
}
|
|
105
|
+
|
|
96
106
|
export class ApiClient {
|
|
97
107
|
/**
|
|
98
108
|
* @param {object} uniweb - the page's `Uniweb` singleton
|
|
@@ -307,7 +317,7 @@ export class ApiClient {
|
|
|
307
317
|
const viewer = Object.freeze({
|
|
308
318
|
...account,
|
|
309
319
|
roles: Array.isArray(me?.roles) ? me.roles : [],
|
|
310
|
-
|
|
320
|
+
workspace: viewerWorkspace(me?.workspace),
|
|
311
321
|
})
|
|
312
322
|
if (this._session.viewer && this._session.viewer.uuid !== viewer.uuid) this.forgetViewer()
|
|
313
323
|
return this.setSession({ status: 'authenticated', viewer, error: null })
|
|
@@ -567,9 +577,9 @@ export class ApiClient {
|
|
|
567
577
|
* there is nothing to list — the backend answers `401`, and a lapsed session is
|
|
568
578
|
* a `401` too, never an empty list (so `records: []` means empty).
|
|
569
579
|
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
580
|
+
* `scope` decides whose: the default, `accessible`, is everything the viewer may
|
|
581
|
+
* read — for a member of a site, their own entities and what was shared with them;
|
|
582
|
+
* for the operator, everything. `mine` is only what the viewer owns.
|
|
573
583
|
*
|
|
574
584
|
* ## Paging, as far as it can honestly be absorbed
|
|
575
585
|
*
|
|
@@ -701,9 +711,11 @@ export class ApiClient {
|
|
|
701
711
|
* error is thrown. Retrying automatically would succeed by overwriting a change
|
|
702
712
|
* nobody looked at. ⇒ We remove the *bookkeeping* and leave the *decision*.
|
|
703
713
|
*
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
714
|
+
* The stale `409` names its item (`item_id`), and that is the item rebased — in a
|
|
715
|
+
* batch, which stops at its first stale op, the one that was stale. ⚠️ **A backend
|
|
716
|
+
* from before 2026-09-18 names none.** Then a single guarded op is rebased on its
|
|
717
|
+
* own item, and a batch is not rebased at all: a guessed item would put one item's
|
|
718
|
+
* token on another.
|
|
707
719
|
*
|
|
708
720
|
* @param {object} args
|
|
709
721
|
* @param {string} args.schema - the entity's Model
|
package/src/hooks/useRecords.js
CHANGED
|
@@ -34,8 +34,9 @@ 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
|
-
*
|
|
38
|
-
*
|
|
37
|
+
* The default scope is everything the viewer may read — for a member, their own
|
|
38
|
+
* entities and what was shared with them; for the operator, everything.
|
|
39
|
+
* `scope: 'mine'` is only the viewer's own.
|
|
39
40
|
*
|
|
40
41
|
* Cached under a key scoped to the viewer, so a sign-in re-reads the list for who
|
|
41
42
|
* is now looking, and a write through `useEntityWriter` drops it.
|
package/src/mock/index.js
CHANGED
|
@@ -36,8 +36,9 @@ import { DEFAULT_SEED } from './seed.js'
|
|
|
36
36
|
*
|
|
37
37
|
* ## What it models, and what it does not
|
|
38
38
|
*
|
|
39
|
-
* One operator (`operator: true` on a seeded account) and members;
|
|
40
|
-
*
|
|
39
|
+
* One operator (`operator: true` on a seeded account) and members; a member reads
|
|
40
|
+
* and writes their own entities and nothing of another's (unless the seed's
|
|
41
|
+
* `memberFloor` says the service lets them), the operator reads and writes all;
|
|
41
42
|
* `creatable_by: 'unit_members'` Models are created by the operator only; sign-up
|
|
42
43
|
* leaves an account unverified until the link in `mock.outbox` is followed. It
|
|
43
44
|
* does not model entitlements behind `via`, nested sections, or second factors.
|
|
@@ -323,7 +324,8 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
|
|
|
323
324
|
if (!b || !Array.isArray(b.uuids)) return badRequest('invalid batch body: missing field `uuids`')
|
|
324
325
|
const invalid = b.uuids.find((u) => !isUuid(u))
|
|
325
326
|
if (invalid) return badRequest(`invalid uuid: ${invalid}`)
|
|
326
|
-
|
|
327
|
+
// Unreadable ones drop, like missing ones — the batch shows what the caller can see.
|
|
328
|
+
const entities = b.uuids.map((u) => store.entities.get(u)).filter((e) => e && store.mayRead(e))
|
|
327
329
|
return json(200, { entities: entities.map((e) => store.read(e, { canEdit: false })) })
|
|
328
330
|
}
|
|
329
331
|
|
|
@@ -369,8 +371,9 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
|
|
|
369
371
|
if (via && !isUuid(via)) return badRequest(`invalid via uuid: ${via}`)
|
|
370
372
|
const found = target(one[1], q.get(PARAM.model))
|
|
371
373
|
if (found.response) return found.response
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
+
// Not found and not permitted are one answer. The mock models no entitlements:
|
|
375
|
+
// a `via` read is the same read, once the container exists.
|
|
376
|
+
if (!store.mayRead(found.entity)) return notFound('entity', one[1])
|
|
374
377
|
if (via && !store.entities.has(via)) return notFound('entity', one[1])
|
|
375
378
|
return json(200, store.read(found.entity, { locales: locales(q), withItems: depth === 'shallow' || depth === 'deep' }))
|
|
376
379
|
}
|
package/src/mock/store.js
CHANGED
|
@@ -13,13 +13,16 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
|
|
|
13
13
|
* is the entity's `brief`, which the backend maintains — derived here on read.
|
|
14
14
|
* - **Ids are integers, entities are addressed by UUID.** A section, an item, an
|
|
15
15
|
* entity and an account each have an integer id; an entity's path id is its UUID.
|
|
16
|
-
* - **
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* - **A member reads and writes their own entities, and nothing of another's.**
|
|
17
|
+
* Every member of a site acts in the site's one unit, and membership of it
|
|
18
|
+
* conveys nothing by itself: another member's entity is private to its owner
|
|
19
|
+
* unless shared. A service can be set up to let members read (or edit) each
|
|
20
|
+
* other's — the seed's `memberFloor` models that choice. *(Until 2026-09-18 the
|
|
21
|
+
* backend gave every member read access to every other member's entities.)*
|
|
20
22
|
* - **The operator** — the account that runs the site's service — holds
|
|
21
23
|
* `system_admin`, creates the Models only the operator may, and may write
|
|
22
|
-
* anything.
|
|
24
|
+
* anything. It is a member of the site's unit like everyone else, so what it
|
|
25
|
+
* makes is its own. A seeded account is the operator with `operator: true`.
|
|
23
26
|
*
|
|
24
27
|
* ⛔ **Seeded fixtures plus in-memory mutation, and deliberately not a database.**
|
|
25
28
|
* A mock that grows a schema and migrations becomes a second implementation that
|
|
@@ -29,9 +32,20 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
|
|
|
29
32
|
* same read), nested sections, second factors, reference fields.
|
|
30
33
|
*/
|
|
31
34
|
|
|
32
|
-
/** The unit every account of a site acts in —
|
|
35
|
+
/** The unit every account of a site acts in — an entity's `unit_id`. */
|
|
33
36
|
export const SITE_UNIT = 1
|
|
34
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The service's home org — `workspace` on `/auth/me`. A site's service is founded with
|
|
40
|
+
* one, handle `home`, and enrolls every account in it, the owner included, so a request
|
|
41
|
+
* that names no workspace lands there. Its own value: it is minted in the service's
|
|
42
|
+
* database, and has nothing to do with the workspace the site was authored in.
|
|
43
|
+
*/
|
|
44
|
+
export const HOME_ORG = Object.freeze({
|
|
45
|
+
unit_uuid: '01926d5e-0000-7000-8000-0000000000a0',
|
|
46
|
+
handle: 'home',
|
|
47
|
+
})
|
|
48
|
+
|
|
35
49
|
/** The gap between neighbouring items' order numbers, as the backend spaces them. */
|
|
36
50
|
const GAP = 1_000_000
|
|
37
51
|
|
|
@@ -85,6 +99,8 @@ export class MockStore {
|
|
|
85
99
|
* seed's items and `append_only` use.
|
|
86
100
|
* @param {object[]} [seed.entities] - `{ uuid?, model, owner?, items?: [{ section, data, parent? }] }`.
|
|
87
101
|
* `uuid` must be a UUID. (`data`, the older spelling, becomes the brief section's item.)
|
|
102
|
+
* @param {'read'|'edit'} [seed.memberFloor] - what membership lets one member do to
|
|
103
|
+
* ANOTHER member's entities. Absent — the default — nothing: each member's are private.
|
|
88
104
|
* @param {object} [options]
|
|
89
105
|
* @param {string} [options.signedInAs] - start with this account already signed in.
|
|
90
106
|
*/
|
|
@@ -95,6 +111,11 @@ export class MockStore {
|
|
|
95
111
|
for (const a of seed.accounts || []) this.addAccount(a, { verified: true })
|
|
96
112
|
|
|
97
113
|
this.schemas = seed.schemas || {}
|
|
114
|
+
if (seed.memberFloor != null && !['read', 'edit'].includes(seed.memberFloor)) {
|
|
115
|
+
throw new Error(`[uniweb/api mock] memberFloor is 'read' or 'edit', or absent — got ${JSON.stringify(seed.memberFloor)}`)
|
|
116
|
+
}
|
|
117
|
+
/** What membership lets one member do to another's entities: `null` (nothing), `read` or `edit`. */
|
|
118
|
+
this.memberFloor = seed.memberFloor ?? null
|
|
98
119
|
this.models = new Map()
|
|
99
120
|
const itemSections = new Map()
|
|
100
121
|
for (const e of seed.entities || []) {
|
|
@@ -159,7 +180,9 @@ export class MockStore {
|
|
|
159
180
|
return {
|
|
160
181
|
account: this.identity(account),
|
|
161
182
|
roles: account.operator ? [{ role: 'system_admin', scope_unit_id: null }] : [],
|
|
162
|
-
|
|
183
|
+
// A request that names no workspace — as this package's never do — lands in the
|
|
184
|
+
// home org, which every account belongs to. Both null would mean a non-member.
|
|
185
|
+
workspace: { ...HOME_ORG },
|
|
163
186
|
}
|
|
164
187
|
}
|
|
165
188
|
|
|
@@ -406,16 +429,29 @@ export class MockStore {
|
|
|
406
429
|
return siblings[siblings.length - 1].order_number + GAP
|
|
407
430
|
}
|
|
408
431
|
|
|
432
|
+
/** The operator, the owner — or any member, when the service lets members read each other's. */
|
|
433
|
+
mayRead(entity) {
|
|
434
|
+
const me = this.account
|
|
435
|
+
return !!me && (me.operator || entity.owner_id === me.id || this.memberFloor != null)
|
|
436
|
+
}
|
|
437
|
+
|
|
409
438
|
mayEdit(entity) {
|
|
410
439
|
const me = this.account
|
|
411
|
-
return !!me && (me.operator || entity.owner_id === me.id)
|
|
440
|
+
return !!me && (me.operator || entity.owner_id === me.id || this.memberFloor === 'edit')
|
|
412
441
|
}
|
|
413
442
|
|
|
414
|
-
/**
|
|
443
|
+
/**
|
|
444
|
+
* Which branch lets the viewer read a row, as the backend names it — the first to
|
|
445
|
+
* answer in its order: `owner`, `grant`, `rbac`, `unit_member`. The operator's own rows
|
|
446
|
+
* are `owner` like anyone's, and the rest it reads as `system_admin`. *(Until
|
|
447
|
+
* 2026-09-18 the operator was not a member of the site's unit, and ownership speaks
|
|
448
|
+
* only for a member — so its own rows answered `rbac` too.)*
|
|
449
|
+
*/
|
|
415
450
|
via(entity) {
|
|
416
451
|
const me = this.account
|
|
452
|
+
if (entity.owner_id === me?.id) return 'owner'
|
|
417
453
|
if (me?.operator) return 'rbac'
|
|
418
|
-
return
|
|
454
|
+
return 'unit_member'
|
|
419
455
|
}
|
|
420
456
|
|
|
421
457
|
/** The entity's summary: its brief section's item, projected like any read. */
|
|
@@ -506,7 +542,7 @@ export class MockStore {
|
|
|
506
542
|
list(model, { scope, limit, offset, paginate, locales }) {
|
|
507
543
|
const me = this.account
|
|
508
544
|
const rows = [...this.entities.values()]
|
|
509
|
-
.filter((e) => e.model === model.name && (scope !== 'mine' || e.owner_id === me?.id))
|
|
545
|
+
.filter((e) => e.model === model.name && this.mayRead(e) && (scope !== 'mine' || e.owner_id === me?.id))
|
|
510
546
|
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : b.id - a.id))
|
|
511
547
|
const page = paginate ? rows.slice(offset, offset + limit) : rows
|
|
512
548
|
// `matched` counts the rows in THIS answer — the page, when paging.
|
|
@@ -620,6 +656,7 @@ export class MockStore {
|
|
|
620
656
|
const expected = op[FIELD.precondition]
|
|
621
657
|
if (expected != null && expected !== item.updated_at) {
|
|
622
658
|
return refuse(409, 'Conflict', 'item changed since your last read — refetch and retry', {
|
|
659
|
+
[FIELD.item]: item.id,
|
|
623
660
|
[FIELD.conflictToken]: item.updated_at,
|
|
624
661
|
})
|
|
625
662
|
}
|
package/src/wire.js
CHANGED
|
@@ -35,9 +35,10 @@
|
|
|
35
35
|
* | `matched` was read as the total before paging | it counts the rows in THIS answer — the page when paging. A full page is the only "maybe more" |
|
|
36
36
|
* | a read's items did not seed the concurrency ledger | they carry `updated_at`, the token the first write of an item is guarded by |
|
|
37
37
|
* | every `409` was reported as an edit conflict | only the one carrying `current_updated_at` is; the rest are rules (insert-only, one-item section, a reference) |
|
|
38
|
-
* | a `409` was rebased onto "the first op with an item id" | the stale `409` names
|
|
38
|
+
* | a `409` was rebased onto "the first op with an item id" | only the item the stale `409` names (`item_id`, since 2026-09-18); an older backend names none, and a batch is then not rebased — never by guesswork |
|
|
39
39
|
* | a sign-up answered with the account | it answers `202 { status: 'verification_required', email }`; the account cannot sign in until verified (`403 Email Not Verified`) |
|
|
40
40
|
* | `acting_unit_id` was a membership signal | every signed-in member of a site acts in the same unit — it cannot tell an operator from a member; `roles` can |
|
|
41
|
+
* | the viewer carried `actingUnitId` | it carries `workspace` — where the request landed: on a site's service, its home org (2026-09-23; `acting_unit_id` leaves the wire) |
|
|
41
42
|
*
|
|
42
43
|
* @module @uniweb/api/wire
|
|
43
44
|
*/
|
|
@@ -113,7 +114,7 @@ export const AUTH = {
|
|
|
113
114
|
* | reset request | `{ email }` | `202 { status: 'reset_requested' }` whether or not the address is known |
|
|
114
115
|
* | reset confirm | `{ token, new_password, code? }` | `200 { reset: true }`; a bad token is `400 Validation`; `code` is required when a second factor is enrolled |
|
|
115
116
|
* | logout | none | `204`; with no session to end, `401` |
|
|
116
|
-
* | me | none | `200 { account: { uuid, username, handle }, roles,
|
|
117
|
+
* | me | none | `200 { account: { uuid, username, handle }, roles, workspace: { unit_uuid, handle } }` · `401` |
|
|
117
118
|
*/
|
|
118
119
|
export const AUTH_BODY = {
|
|
119
120
|
login: ['username', 'password'],
|
|
@@ -136,16 +137,24 @@ export const TOTP = {
|
|
|
136
137
|
* `roles` is a list of `{ role, scope_unit_id }` — `role` one of `system_admin`,
|
|
137
138
|
* `unit_admin`, `content_editor`, `user`. An ordinary member holds none: `[]`.
|
|
138
139
|
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
140
|
+
* `workspace` is `{ unit_uuid, handle }` — **the workspace the request works in**. This
|
|
141
|
+
* package's requests name none, and on a site's `api` service such a request lands in
|
|
142
|
+
* the service's **home org**, `handle: 'home'`, in which every account is enrolled.
|
|
143
|
+
* Both `null` there means an account that is not a member — a service account, a
|
|
144
|
+
* revoked membership. (On a backend with no home org, naming none is personal: both
|
|
145
|
+
* `null`.) The viewer carries it as `{ unitUuid, handle }`, or `null` when both are
|
|
146
|
+
* null. *(It replaced `acting_unit_id` on 2026-09-23. MEASURED by backend at the
|
|
147
|
+
* function the route serializes, 2026-09-23.)*
|
|
148
|
+
*
|
|
149
|
+
* ⛔ **Neither is an operator signal.** Every member of a site's service works in the
|
|
150
|
+
* same home org, so it reads the same for the operator and for someone who signed up a
|
|
151
|
+
* minute ago. The operator holds `system_admin`. Better than either: ask about the
|
|
152
|
+
* thing — a single-entity read carries `can_edit`, the write gate's own answer.
|
|
144
153
|
*/
|
|
145
154
|
export const VIEWER = {
|
|
146
155
|
account: 'account',
|
|
147
156
|
roles: 'roles',
|
|
148
|
-
|
|
157
|
+
workspace: 'workspace',
|
|
149
158
|
}
|
|
150
159
|
|
|
151
160
|
/**
|
|
@@ -212,10 +221,14 @@ export const PARAM = {
|
|
|
212
221
|
}
|
|
213
222
|
|
|
214
223
|
/**
|
|
215
|
-
* The list's `scope`.
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
224
|
+
* The list's `scope`. Default `accessible` — everything the viewer may read; `mine`
|
|
225
|
+
* — only what the viewer owns.
|
|
226
|
+
*
|
|
227
|
+
* What a member of a site may read: their own entities, and what was shared with
|
|
228
|
+
* them. Other members' entities are private to their owners unless the site's
|
|
229
|
+
* service was set up to let members read each other's. The operator reads
|
|
230
|
+
* everything. *(A site's service let every member read every other member's
|
|
231
|
+
* entities until 2026-09-18 — measured, and changed on the backend.)*
|
|
219
232
|
*/
|
|
220
233
|
export const SCOPE = {
|
|
221
234
|
mine: 'mine',
|
|
@@ -269,7 +282,11 @@ export const GUARDED_OPS = new Set([OP.update, OP.delete, OP.move])
|
|
|
269
282
|
* known only from the op that named it.
|
|
270
283
|
*/
|
|
271
284
|
export const FIELD = {
|
|
272
|
-
/**
|
|
285
|
+
/**
|
|
286
|
+
* Names the target item on an op (an integer), the item on a write result, and
|
|
287
|
+
* the stale item on a `409` — the last since 2026-09-18; an older backend's `409`
|
|
288
|
+
* carries only `conflictToken`.
|
|
289
|
+
*/
|
|
273
290
|
item: 'item_id',
|
|
274
291
|
/** The new item's uuid, on a create's result. */
|
|
275
292
|
itemUuid: 'item_uuid',
|
|
@@ -325,8 +342,10 @@ export const CREATE = {
|
|
|
325
342
|
* paging; a page that came back full is the only sign there may be more.
|
|
326
343
|
*
|
|
327
344
|
* `via` names why the viewer can read the row — `owner`, `grant`, `rbac`,
|
|
328
|
-
* `unit_member`, `entitlement`; treat an unknown value as "no opinion".
|
|
329
|
-
*
|
|
345
|
+
* `unit_member`, `entitlement`; treat an unknown value as "no opinion". The
|
|
346
|
+
* operator's own rows are `owner` and the rest `rbac` — since 2026-09-18; an older
|
|
347
|
+
* backend answers `rbac` on the operator's own rows too. ⚠️ An empty list means
|
|
348
|
+
* empty: a lapsed session is a `401`, never `200 []`.
|
|
330
349
|
*/
|
|
331
350
|
export const LIST = {
|
|
332
351
|
records: 'entities',
|
|
@@ -343,7 +362,10 @@ export const LIST = {
|
|
|
343
362
|
* section by id only; the Model's definition maps ids to names.
|
|
344
363
|
*
|
|
345
364
|
* `absent` is one word for not-found and not-permitted, by design: both are
|
|
346
|
-
* `404 { kind: 'entity' }`.
|
|
365
|
+
* `404 { kind: 'entity' }`. So is an entity of ANOTHER Model: `?model=` says what
|
|
366
|
+
* the caller takes the entity to be, and a read or a write naming the wrong one is
|
|
367
|
+
* the same `404` — since 2026-09-18; before that the backend answered, stamped with
|
|
368
|
+
* the Model named.
|
|
347
369
|
*/
|
|
348
370
|
export const READ = {
|
|
349
371
|
hydrated: 'hydrated',
|
|
@@ -381,7 +403,7 @@ export const SCHEMA = {
|
|
|
381
403
|
*
|
|
382
404
|
* | answer | means |
|
|
383
405
|
* |---|---|
|
|
384
|
-
* | `409 Conflict` + `current_updated_at` |
|
|
406
|
+
* | `409 Conflict` + `item_id`, `current_updated_at` | that item changed since this viewer's token — the ONLY stale-token answer. `item_id` since 2026-09-18; an older backend sends the token alone |
|
|
385
407
|
* | `409 Append-Only Section` + `section` | an insert-only section: its items can be added and moved, not edited or deleted |
|
|
386
408
|
* | `409 Schema Rule Violation` | a rule of the Model: a second item in a one-item section, a section of another Model, a reference that still points here |
|
|
387
409
|
* | `403 Forbidden` + `op`, `target` | may read it, may not do this to it — `edit`, `delete`, `use_model` (create) |
|