@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 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
- ⚠️ **Whose records.** The default `scope` is everything the viewer may read, and on a
87
- site's `api` service **members can read each other's entities**. For "my submissions",
88
- "my progress", pass `scope: 'mine'`.
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 each other's entities and write their own; the
206
- operator writes everything. A new sign-up must be verified: the link is in
207
- `mock.outbox` (the standalone server prints it). State is in memory: restart to reset.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/api",
3
- "version": "0.4.0",
3
+ "version": "0.4.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": {
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
- * ⚠️ **`scope` decides whose.** The default, `accessible`, is everything the
571
- * viewer may read — on a site's `api` service that is every member's entities
572
- * of the Model, not only the viewer's. `mine` is only what the viewer owns.
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
- * ⚠️ **The stale `409` does not say which item was stale.** For one guarded op
705
- * that is the op's item; in a batch of several it is unknowable, and rebasing a
706
- * guessed item would put one item's token on another — so a batch is not rebased.
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
@@ -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
- * ⚠️ The default scope is everything the viewer may read, which on a site's `api`
38
- * service includes other members' entities. `scope: 'mine'` is only the viewer's.
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; every
40
- * signed-in account reads every entity, the owner and the operator write it;
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
- const entities = b.uuids.map((u) => store.entities.get(u)).filter(Boolean)
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
- // The mock models no entitlements: a `via` read is the same read, once the
373
- // container exists. Not found and not permitted are one answer, as ever.
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
- * - **Every signed-in account reads every entity.** On a site's `api` service
17
- * every member acts in the site's one unit, and a member of it may read what is
18
- * in it — so members read each other's entities. Writing is for the owner and
19
- * the operator.
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. A seeded account is the operator with `operator: true`.
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
- /** Which branch lets the viewer read a row, as the backend names it. */
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 entity.owner_id === me?.id ? 'owner' : 'unit_member'
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 no item — a batch cannot be rebased by guesswork |
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`. **MEASURED.** Default `accessible` — everything the viewer
216
- * may read. ⚠️ On a site's `api` service that is **every member's** entities of
217
- * the Model, not only the viewer's own: members of a site read each other's
218
- * content. `mine` is only what the viewer owns.
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
- /** Names the target item on an op (an integer), and the item on a write result. */
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". ⚠️ An
329
- * empty list means empty: a lapsed session is a `401`, never `200 []`.
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` | the item changed since this viewer's token — the ONLY stale-token answer |
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) |