@uniweb/api 0.4.1 → 0.5.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
@@ -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.actingUnitId` is the same for every member of a site, so
57
- it cannot tell an operator from a member. To decide whether to show an edit control for
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
@@ -205,7 +206,9 @@ who may create entries, which sections are insert-only, the fields a section tak
205
206
  and it is as strict as the backend about requests, so a mistake fails here rather
206
207
  than in front of a user. Members read and write their own entities and nothing of each
207
208
  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
+ models a service set up to let members read each other's — or per Model,
210
+ `memberFloor: { '@acme/course': 'read' }`, so the operator's content is shared while
211
+ each member's own records stay private). A new sign-up must be
209
212
  verified: the link is in `mock.outbox` (the standalone server prints it). State is in
210
213
  memory: restart to reset.
211
214
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/api",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "The client for a Uniweb site's own backend — session, records, entities — imported by a foundation the way it imports @uniweb/kit.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,7 +34,7 @@
34
34
  "node": ">=20.19"
35
35
  },
36
36
  "dependencies": {
37
- "@uniweb/core": "^0.29.4"
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
- actingUnitId: me?.acting_unit_id ?? null,
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 })
package/src/mock/store.js CHANGED
@@ -32,9 +32,20 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
32
32
  * same read), nested sections, second factors, reference fields.
33
33
  */
34
34
 
35
- /** The unit every account of a site acts in — `acting_unit_id` on `/auth/me`. */
35
+ /** The unit every account of a site acts in — an entity's `unit_id`. */
36
36
  export const SITE_UNIT = 1
37
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
+
38
49
  /** The gap between neighbouring items' order numbers, as the backend spaces them. */
39
50
  const GAP = 1_000_000
40
51
 
@@ -74,6 +85,9 @@ function canonical(value) {
74
85
  const isMulti = (section) => section?.multiple === true || section?.kind === 'multi'
75
86
  const shortName = (model) => String(model).split('/').pop() || String(model)
76
87
 
88
+ /** A `{ locale: value }` map — a plain object, never an array (a localized list's value). */
89
+ const isLocaleMap = (value) => value != null && typeof value === 'object' && !Array.isArray(value)
90
+
77
91
  export class MockStore {
78
92
  /**
79
93
  * @param {object} seed
@@ -88,8 +102,11 @@ export class MockStore {
88
102
  * seed's items and `append_only` use.
89
103
  * @param {object[]} [seed.entities] - `{ uuid?, model, owner?, items?: [{ section, data, parent? }] }`.
90
104
  * `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.
105
+ * @param {'read'|'edit'|Object<string,'read'|'edit'>} [seed.memberFloor] - what membership
106
+ * lets one member do to ANOTHER member's entities. Absent — the default — nothing: each
107
+ * member's are private. A string applies to every Model; a map `{ '@scope/name': 'read' }`
108
+ * applies per Model, so a service can open the operator's content (courses, lessons) to
109
+ * its members while their own records (progress, submissions) stay theirs alone.
93
110
  * @param {object} [options]
94
111
  * @param {string} [options.signedInAs] - start with this account already signed in.
95
112
  */
@@ -100,10 +117,16 @@ export class MockStore {
100
117
  for (const a of seed.accounts || []) this.addAccount(a, { verified: true })
101
118
 
102
119
  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)}`)
120
+ const floors = seed.memberFloor != null && typeof seed.memberFloor === 'object' ? Object.values(seed.memberFloor) : [seed.memberFloor]
121
+ for (const floor of floors) {
122
+ if (floor != null && !['read', 'edit'].includes(floor)) {
123
+ throw new Error(`[uniweb/api mock] memberFloor is 'read' or 'edit', or absent — per Model or for all — got ${JSON.stringify(seed.memberFloor)}`)
124
+ }
105
125
  }
106
- /** What membership lets one member do to another's entities: `null` (nothing), `read` or `edit`. */
126
+ /**
127
+ * What membership lets one member do to another's entities: `null` (nothing), `read`
128
+ * or `edit` — for every Model, or a map of it per Model.
129
+ */
107
130
  this.memberFloor = seed.memberFloor ?? null
108
131
  this.models = new Map()
109
132
  const itemSections = new Map()
@@ -169,7 +192,9 @@ export class MockStore {
169
192
  return {
170
193
  account: this.identity(account),
171
194
  roles: account.operator ? [{ role: 'system_admin', scope_unit_id: null }] : [],
172
- acting_unit_id: SITE_UNIT,
195
+ // A request that names no workspace — as this package's never do — lands in the
196
+ // home org, which every account belongs to. Both null would mean a non-member.
197
+ workspace: { ...HOME_ORG },
173
198
  }
174
199
  }
175
200
 
@@ -416,15 +441,22 @@ export class MockStore {
416
441
  return siblings[siblings.length - 1].order_number + GAP
417
442
  }
418
443
 
444
+ /** The floor that applies to this entity's Model: `null`, `'read'` or `'edit'`. */
445
+ floorFor(entity) {
446
+ const floor = this.memberFloor
447
+ if (floor == null || typeof floor === 'string') return floor
448
+ return floor[entity.model] ?? null
449
+ }
450
+
419
451
  /** The operator, the owner — or any member, when the service lets members read each other's. */
420
452
  mayRead(entity) {
421
453
  const me = this.account
422
- return !!me && (me.operator || entity.owner_id === me.id || this.memberFloor != null)
454
+ return !!me && (me.operator || entity.owner_id === me.id || this.floorFor(entity) != null)
423
455
  }
424
456
 
425
457
  mayEdit(entity) {
426
458
  const me = this.account
427
- return !!me && (me.operator || entity.owner_id === me.id || this.memberFloor === 'edit')
459
+ return !!me && (me.operator || entity.owner_id === me.id || this.floorFor(entity) === 'edit')
428
460
  }
429
461
 
430
462
  /**
@@ -453,12 +485,18 @@ export class MockStore {
453
485
  * A localized field — declared `localized: true` — answered in the first listed
454
486
  * locale it has, and omitted when it has none: the backend has no fallback of
455
487
  * its own. Without a locale, the whole `{ locale: value }` map.
488
+ *
489
+ * ⛔ Only a plain object is a locale map. A localized LIST (`multiple: true`) holds
490
+ * an array, and an array is a value, not a map of them: read as a map it has no
491
+ * `en`, and the field was DELETED from every read that carried `?locale=` — which
492
+ * the client sends on every browser read. Measured 2026-09-22 on a course brief
493
+ * whose `outcomes` vanished, and on quiz questions whose `options` did.
456
494
  */
457
495
  project(data, section, locales) {
458
496
  if (!locales?.length || !section?.fields || !data || typeof data !== 'object') return data
459
497
  const out = { ...data }
460
498
  for (const [key, field] of Object.entries(section.fields)) {
461
- if (!field?.localized || !out[key] || typeof out[key] !== 'object') continue
499
+ if (!field?.localized || !isLocaleMap(out[key])) continue
462
500
  const hit = locales.find((l) => out[key][l] != null)
463
501
  if (hit) out[key] = out[key][hit]
464
502
  else delete out[key]
package/src/wire.js CHANGED
@@ -38,6 +38,7 @@
38
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, acting_unit_id }` · `401` |
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
- * ⛔ **`acting_unit_id` is NOT a membership or operator signal.** Every signed-in
140
- * member of a site acts in the same unit, so it reads the same for the operator
141
- * and for someone who signed up a minute ago. The operator of a site's `api`
142
- * service holds `system_admin`. Better than either: ask about the thing — a
143
- * single-entity read carries `can_edit`, the write gate's own answer.
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
- actingUnit: 'acting_unit_id',
157
+ workspace: 'workspace',
149
158
  }
150
159
 
151
160
  /**