@uniweb/api 0.4.0 → 0.4.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 +9 -6
- package/package.json +1 -1
- package/src/client.js +8 -6
- package/src/hooks/useRecords.js +3 -2
- package/src/mock/index.js +8 -5
- package/src/mock/store.js +33 -9
- package/src/wire.js +23 -10
package/README.md
CHANGED
|
@@ -83,9 +83,10 @@ live source — no `api` service, or nobody signed in — so render your site's
|
|
|
83
83
|
Showing "nothing yet" for the first case tells a visitor their content is missing
|
|
84
84
|
when it is simply not being asked for.
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
site's
|
|
88
|
-
|
|
86
|
+
**Whose records.** A member reads their own entities and what was shared with them;
|
|
87
|
+
other members' are private unless the site's service was set up to let members read
|
|
88
|
+
each other's. The site's operator reads everything. `scope: 'mine'` lists only the
|
|
89
|
+
viewer's own.
|
|
89
90
|
|
|
90
91
|
A record is a summary: `record.brief` holds the fields of the Model's brief section. It
|
|
91
92
|
carries no items — read the entity for those. `hasMore` is true when a page came back
|
|
@@ -202,9 +203,11 @@ will in production, and your site's configuration is identical either way.
|
|
|
202
203
|
refusals, for every route this package uses. It enforces what your schemas declare —
|
|
203
204
|
who may create entries, which sections are insert-only, the fields a section takes —
|
|
204
205
|
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
|
-
|
|
206
|
+
than in front of a user. Members read and write their own entities and nothing of each
|
|
207
|
+
other's; the operator reads and writes everything (`memberFloor: 'read'` in the seed
|
|
208
|
+
models a service set up to let members read each other's). A new sign-up must be
|
|
209
|
+
verified: the link is in `mock.outbox` (the standalone server prints it). State is in
|
|
210
|
+
memory: restart to reset.
|
|
208
211
|
|
|
209
212
|
There is also a standalone server, for a frontend that is not a Uniweb site:
|
|
210
213
|
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -567,9 +567,9 @@ export class ApiClient {
|
|
|
567
567
|
* there is nothing to list — the backend answers `401`, and a lapsed session is
|
|
568
568
|
* a `401` too, never an empty list (so `records: []` means empty).
|
|
569
569
|
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
570
|
+
* `scope` decides whose: the default, `accessible`, is everything the viewer may
|
|
571
|
+
* read — for a member of a site, their own entities and what was shared with them;
|
|
572
|
+
* for the operator, everything. `mine` is only what the viewer owns.
|
|
573
573
|
*
|
|
574
574
|
* ## Paging, as far as it can honestly be absorbed
|
|
575
575
|
*
|
|
@@ -701,9 +701,11 @@ export class ApiClient {
|
|
|
701
701
|
* error is thrown. Retrying automatically would succeed by overwriting a change
|
|
702
702
|
* nobody looked at. ⇒ We remove the *bookkeeping* and leave the *decision*.
|
|
703
703
|
*
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
704
|
+
* The stale `409` names its item (`item_id`), and that is the item rebased — in a
|
|
705
|
+
* batch, which stops at its first stale op, the one that was stale. ⚠️ **A backend
|
|
706
|
+
* from before 2026-09-18 names none.** Then a single guarded op is rebased on its
|
|
707
|
+
* own item, and a batch is not rebased at all: a guessed item would put one item's
|
|
708
|
+
* token on another.
|
|
707
709
|
*
|
|
708
710
|
* @param {object} args
|
|
709
711
|
* @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
|
|
@@ -85,6 +88,8 @@ export class MockStore {
|
|
|
85
88
|
* seed's items and `append_only` use.
|
|
86
89
|
* @param {object[]} [seed.entities] - `{ uuid?, model, owner?, items?: [{ section, data, parent? }] }`.
|
|
87
90
|
* `uuid` must be a UUID. (`data`, the older spelling, becomes the brief section's item.)
|
|
91
|
+
* @param {'read'|'edit'} [seed.memberFloor] - what membership lets one member do to
|
|
92
|
+
* ANOTHER member's entities. Absent — the default — nothing: each member's are private.
|
|
88
93
|
* @param {object} [options]
|
|
89
94
|
* @param {string} [options.signedInAs] - start with this account already signed in.
|
|
90
95
|
*/
|
|
@@ -95,6 +100,11 @@ export class MockStore {
|
|
|
95
100
|
for (const a of seed.accounts || []) this.addAccount(a, { verified: true })
|
|
96
101
|
|
|
97
102
|
this.schemas = seed.schemas || {}
|
|
103
|
+
if (seed.memberFloor != null && !['read', 'edit'].includes(seed.memberFloor)) {
|
|
104
|
+
throw new Error(`[uniweb/api mock] memberFloor is 'read' or 'edit', or absent — got ${JSON.stringify(seed.memberFloor)}`)
|
|
105
|
+
}
|
|
106
|
+
/** What membership lets one member do to another's entities: `null` (nothing), `read` or `edit`. */
|
|
107
|
+
this.memberFloor = seed.memberFloor ?? null
|
|
98
108
|
this.models = new Map()
|
|
99
109
|
const itemSections = new Map()
|
|
100
110
|
for (const e of seed.entities || []) {
|
|
@@ -406,16 +416,29 @@ export class MockStore {
|
|
|
406
416
|
return siblings[siblings.length - 1].order_number + GAP
|
|
407
417
|
}
|
|
408
418
|
|
|
419
|
+
/** The operator, the owner — or any member, when the service lets members read each other's. */
|
|
420
|
+
mayRead(entity) {
|
|
421
|
+
const me = this.account
|
|
422
|
+
return !!me && (me.operator || entity.owner_id === me.id || this.memberFloor != null)
|
|
423
|
+
}
|
|
424
|
+
|
|
409
425
|
mayEdit(entity) {
|
|
410
426
|
const me = this.account
|
|
411
|
-
return !!me && (me.operator || entity.owner_id === me.id)
|
|
427
|
+
return !!me && (me.operator || entity.owner_id === me.id || this.memberFloor === 'edit')
|
|
412
428
|
}
|
|
413
429
|
|
|
414
|
-
/**
|
|
430
|
+
/**
|
|
431
|
+
* Which branch lets the viewer read a row, as the backend names it — the first to
|
|
432
|
+
* answer in its order: `owner`, `grant`, `rbac`, `unit_member`. The operator's own rows
|
|
433
|
+
* are `owner` like anyone's, and the rest it reads as `system_admin`. *(Until
|
|
434
|
+
* 2026-09-18 the operator was not a member of the site's unit, and ownership speaks
|
|
435
|
+
* only for a member — so its own rows answered `rbac` too.)*
|
|
436
|
+
*/
|
|
415
437
|
via(entity) {
|
|
416
438
|
const me = this.account
|
|
439
|
+
if (entity.owner_id === me?.id) return 'owner'
|
|
417
440
|
if (me?.operator) return 'rbac'
|
|
418
|
-
return
|
|
441
|
+
return 'unit_member'
|
|
419
442
|
}
|
|
420
443
|
|
|
421
444
|
/** The entity's summary: its brief section's item, projected like any read. */
|
|
@@ -506,7 +529,7 @@ export class MockStore {
|
|
|
506
529
|
list(model, { scope, limit, offset, paginate, locales }) {
|
|
507
530
|
const me = this.account
|
|
508
531
|
const rows = [...this.entities.values()]
|
|
509
|
-
.filter((e) => e.model === model.name && (scope !== 'mine' || e.owner_id === me?.id))
|
|
532
|
+
.filter((e) => e.model === model.name && this.mayRead(e) && (scope !== 'mine' || e.owner_id === me?.id))
|
|
510
533
|
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : b.id - a.id))
|
|
511
534
|
const page = paginate ? rows.slice(offset, offset + limit) : rows
|
|
512
535
|
// `matched` counts the rows in THIS answer — the page, when paging.
|
|
@@ -620,6 +643,7 @@ export class MockStore {
|
|
|
620
643
|
const expected = op[FIELD.precondition]
|
|
621
644
|
if (expected != null && expected !== item.updated_at) {
|
|
622
645
|
return refuse(409, 'Conflict', 'item changed since your last read — refetch and retry', {
|
|
646
|
+
[FIELD.item]: item.id,
|
|
623
647
|
[FIELD.conflictToken]: item.updated_at,
|
|
624
648
|
})
|
|
625
649
|
}
|
package/src/wire.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
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
41
|
*
|
|
@@ -212,10 +212,14 @@ export const PARAM = {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
/**
|
|
215
|
-
* The list's `scope`.
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
215
|
+
* The list's `scope`. Default `accessible` — everything the viewer may read; `mine`
|
|
216
|
+
* — only what the viewer owns.
|
|
217
|
+
*
|
|
218
|
+
* What a member of a site may read: their own entities, and what was shared with
|
|
219
|
+
* them. Other members' entities are private to their owners unless the site's
|
|
220
|
+
* service was set up to let members read each other's. The operator reads
|
|
221
|
+
* everything. *(A site's service let every member read every other member's
|
|
222
|
+
* entities until 2026-09-18 — measured, and changed on the backend.)*
|
|
219
223
|
*/
|
|
220
224
|
export const SCOPE = {
|
|
221
225
|
mine: 'mine',
|
|
@@ -269,7 +273,11 @@ export const GUARDED_OPS = new Set([OP.update, OP.delete, OP.move])
|
|
|
269
273
|
* known only from the op that named it.
|
|
270
274
|
*/
|
|
271
275
|
export const FIELD = {
|
|
272
|
-
/**
|
|
276
|
+
/**
|
|
277
|
+
* Names the target item on an op (an integer), the item on a write result, and
|
|
278
|
+
* the stale item on a `409` — the last since 2026-09-18; an older backend's `409`
|
|
279
|
+
* carries only `conflictToken`.
|
|
280
|
+
*/
|
|
273
281
|
item: 'item_id',
|
|
274
282
|
/** The new item's uuid, on a create's result. */
|
|
275
283
|
itemUuid: 'item_uuid',
|
|
@@ -325,8 +333,10 @@ export const CREATE = {
|
|
|
325
333
|
* paging; a page that came back full is the only sign there may be more.
|
|
326
334
|
*
|
|
327
335
|
* `via` names why the viewer can read the row — `owner`, `grant`, `rbac`,
|
|
328
|
-
* `unit_member`, `entitlement`; treat an unknown value as "no opinion".
|
|
329
|
-
*
|
|
336
|
+
* `unit_member`, `entitlement`; treat an unknown value as "no opinion". The
|
|
337
|
+
* operator's own rows are `owner` and the rest `rbac` — since 2026-09-18; an older
|
|
338
|
+
* backend answers `rbac` on the operator's own rows too. ⚠️ An empty list means
|
|
339
|
+
* empty: a lapsed session is a `401`, never `200 []`.
|
|
330
340
|
*/
|
|
331
341
|
export const LIST = {
|
|
332
342
|
records: 'entities',
|
|
@@ -343,7 +353,10 @@ export const LIST = {
|
|
|
343
353
|
* section by id only; the Model's definition maps ids to names.
|
|
344
354
|
*
|
|
345
355
|
* `absent` is one word for not-found and not-permitted, by design: both are
|
|
346
|
-
* `404 { kind: 'entity' }`.
|
|
356
|
+
* `404 { kind: 'entity' }`. So is an entity of ANOTHER Model: `?model=` says what
|
|
357
|
+
* the caller takes the entity to be, and a read or a write naming the wrong one is
|
|
358
|
+
* the same `404` — since 2026-09-18; before that the backend answered, stamped with
|
|
359
|
+
* the Model named.
|
|
347
360
|
*/
|
|
348
361
|
export const READ = {
|
|
349
362
|
hydrated: 'hydrated',
|
|
@@ -381,7 +394,7 @@ export const SCHEMA = {
|
|
|
381
394
|
*
|
|
382
395
|
* | answer | means |
|
|
383
396
|
* |---|---|
|
|
384
|
-
* | `409 Conflict` + `current_updated_at` |
|
|
397
|
+
* | `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
398
|
* | `409 Append-Only Section` + `section` | an insert-only section: its items can be added and moved, not edited or deleted |
|
|
386
399
|
* | `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
400
|
* | `403 Forbidden` + `op`, `target` | may read it, may not do this to it — `edit`, `delete`, `use_model` (create) |
|