@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 +6 -3
- package/package.json +2 -2
- package/src/client.js +11 -1
- package/src/mock/store.js +48 -10
- package/src/wire.js +16 -7
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
|
|
@@ -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
|
|
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.
|
|
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.
|
|
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 })
|
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 —
|
|
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
|
|
92
|
-
* ANOTHER member's entities. Absent — the default — nothing: each
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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]
|
|
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,
|
|
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
|
/**
|