@uniweb/api 0.5.0 → 0.6.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 +3 -2
- package/package.json +1 -1
- package/src/mock/index.js +4 -10
- package/src/mock/seed.js +7 -8
- package/src/mock/store.js +50 -18
package/README.md
CHANGED
|
@@ -179,7 +179,6 @@ export default createMockBackend({
|
|
|
179
179
|
],
|
|
180
180
|
schemas: {
|
|
181
181
|
'@acme/track': {
|
|
182
|
-
creatable_by: 'unit_members', // the operator only; 'any_user' is the default
|
|
183
182
|
sections: {
|
|
184
183
|
track: { kind: 'single', brief: true, fields: { name: { type: 'string', required: true } } },
|
|
185
184
|
sessions: { kind: 'multi', fields: { title: { type: 'string', required: true } } },
|
|
@@ -206,7 +205,9 @@ who may create entries, which sections are insert-only, the fields a section tak
|
|
|
206
205
|
and it is as strict as the backend about requests, so a mistake fails here rather
|
|
207
206
|
than in front of a user. Members read and write their own entities and nothing of each
|
|
208
207
|
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
|
|
208
|
+
models a service set up to let members read each other's — or per Model,
|
|
209
|
+
`memberFloor: { '@acme/course': 'read' }`, so the operator's content is shared while
|
|
210
|
+
each member's own records stay private). A new sign-up must be
|
|
210
211
|
verified: the link is in `mock.outbox` (the standalone server prints it). State is in
|
|
211
212
|
memory: restart to reset.
|
|
212
213
|
|
package/package.json
CHANGED
package/src/mock/index.js
CHANGED
|
@@ -39,7 +39,7 @@ import { DEFAULT_SEED } from './seed.js'
|
|
|
39
39
|
* One operator (`operator: true` on a seeded account) and members; a member reads
|
|
40
40
|
* and writes their own entities and nothing of another's (unless the seed's
|
|
41
41
|
* `memberFloor` says the service lets them), the operator reads and writes all;
|
|
42
|
-
*
|
|
42
|
+
* anyone signed in may create entries of any Model (Models are open); sign-up
|
|
43
43
|
* leaves an account unverified until the link in `mock.outbox` is followed. It
|
|
44
44
|
* does not model entitlements behind `via`, nested sections, or second factors.
|
|
45
45
|
*
|
|
@@ -286,15 +286,9 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
|
|
|
286
286
|
}
|
|
287
287
|
const model = store.model(q.get(PARAM.model))
|
|
288
288
|
if (!model) return notFound('model', q.get(PARAM.model))
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
title: 'Forbidden',
|
|
293
|
-
detail: `use_model on model ${model.uuid} not permitted`,
|
|
294
|
-
op: 'use_model',
|
|
295
|
-
target: `model ${model.uuid}`,
|
|
296
|
-
})
|
|
297
|
-
}
|
|
289
|
+
// ⭐ No creation gate: Models are open (2026-09-23) — anyone signed in may create
|
|
290
|
+
// entries of any Model. Until then `creatable_by: unit_members` answered `403
|
|
291
|
+
// use_model` here for a member.
|
|
298
292
|
// Any other top-level key is ignored, exactly as the backend ignores it.
|
|
299
293
|
return answer(store.create(model, items.map((i) => ({ ...i, data: i.data ?? {} }))))
|
|
300
294
|
}
|
package/src/mock/seed.js
CHANGED
|
@@ -7,24 +7,24 @@
|
|
|
7
7
|
*
|
|
8
8
|
* - **`accounts`** — `operator: true` marks the account that runs the site's
|
|
9
9
|
* service. Everyone else is a member. Seeded accounts are verified.
|
|
10
|
-
* - **`schemas`** — per Model: `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* - **`schemas`** — per Model: `sections`, the Model's sections as the framework
|
|
11
|
+
* lowers them. With `sections`, writes are shape-checked. Models are open:
|
|
12
|
+
* anyone signed in may create entries of any of them.
|
|
13
13
|
* - **`entities`** — `uuid` must be a UUID, as it is on the wire; content is items,
|
|
14
14
|
* each naming its section. The entity's `brief` is derived from its brief
|
|
15
15
|
* section's item, as the backend derives it.
|
|
16
16
|
*/
|
|
17
17
|
export const DEFAULT_SEED = {
|
|
18
18
|
accounts: [
|
|
19
|
-
// The organiser runs the site,
|
|
19
|
+
// The organiser runs the site, and the programme's tracks are theirs.
|
|
20
20
|
{ username: 'organiser', password: 'organiser', operator: true },
|
|
21
|
-
// The attendee is a member
|
|
22
|
-
//
|
|
21
|
+
// The attendee is a member. They may make entries of their own — recording
|
|
22
|
+
// attending is the demo — and may not edit the organiser's programme: the entry
|
|
23
|
+
// decides, server-side, and that refusal is the other half of the demo.
|
|
23
24
|
{ username: 'attendee', password: 'attendee' },
|
|
24
25
|
],
|
|
25
26
|
schemas: {
|
|
26
27
|
'@/track': {
|
|
27
|
-
creatable_by: 'unit_members',
|
|
28
28
|
sections: {
|
|
29
29
|
track: { kind: 'single', brief: true, fields: { name: { type: 'string', required: true } } },
|
|
30
30
|
sessions: {
|
|
@@ -36,7 +36,6 @@ export const DEFAULT_SEED = {
|
|
|
36
36
|
// Check-ins are insert-only: an attendee may record attending, and nobody —
|
|
37
37
|
// including them — may edit or remove it afterwards.
|
|
38
38
|
'@/attendance': {
|
|
39
|
-
creatable_by: 'any_user',
|
|
40
39
|
sections: {
|
|
41
40
|
attendance: { kind: 'single', brief: true, fields: { who: { type: 'string' } } },
|
|
42
41
|
checkins: { kind: 'multi', append_only: true, fields: { session: { type: 'string' }, at: { type: 'string' } } },
|
package/src/mock/store.js
CHANGED
|
@@ -85,13 +85,17 @@ function canonical(value) {
|
|
|
85
85
|
const isMulti = (section) => section?.multiple === true || section?.kind === 'multi'
|
|
86
86
|
const shortName = (model) => String(model).split('/').pop() || String(model)
|
|
87
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
|
+
|
|
88
91
|
export class MockStore {
|
|
89
92
|
/**
|
|
90
93
|
* @param {object} seed
|
|
91
94
|
* @param {object[]} [seed.accounts] - `{ username, password, email?, handle?, operator? }`.
|
|
92
95
|
* Seeded accounts are verified. (`units: [...]` non-empty, the older spelling,
|
|
93
96
|
* also marks the operator.)
|
|
94
|
-
* @param {object} [seed.schemas] - `{ '@scope/name': {
|
|
97
|
+
* @param {object} [seed.schemas] - `{ '@scope/name': { sections?, append_only?, migration_debt? } }`.
|
|
98
|
+
* ⛔ No `creatable_by` — retired 2026-09-23, Models are open; a seed carrying it is refused.
|
|
95
99
|
* `sections` is the LOWERED sections map (`{ [name]: { kind | multiple, brief, append_only, fields } }`)
|
|
96
100
|
* as the framework's normalizer produces it; with it, writes are shape-checked
|
|
97
101
|
* (`./schema-shape.js`). Without it the sections are inferred: a one-item brief
|
|
@@ -99,8 +103,11 @@ export class MockStore {
|
|
|
99
103
|
* seed's items and `append_only` use.
|
|
100
104
|
* @param {object[]} [seed.entities] - `{ uuid?, model, owner?, items?: [{ section, data, parent? }] }`.
|
|
101
105
|
* `uuid` must be a UUID. (`data`, the older spelling, becomes the brief section's item.)
|
|
102
|
-
* @param {'read'|'edit'} [seed.memberFloor] - what membership
|
|
103
|
-
* ANOTHER member's entities. Absent — the default — nothing: each
|
|
106
|
+
* @param {'read'|'edit'|Object<string,'read'|'edit'>} [seed.memberFloor] - what membership
|
|
107
|
+
* lets one member do to ANOTHER member's entities. Absent — the default — nothing: each
|
|
108
|
+
* member's are private. A string applies to every Model; a map `{ '@scope/name': 'read' }`
|
|
109
|
+
* applies per Model, so a service can open the operator's content (courses, lessons) to
|
|
110
|
+
* its members while their own records (progress, submissions) stay theirs alone.
|
|
104
111
|
* @param {object} [options]
|
|
105
112
|
* @param {string} [options.signedInAs] - start with this account already signed in.
|
|
106
113
|
*/
|
|
@@ -111,10 +118,23 @@ export class MockStore {
|
|
|
111
118
|
for (const a of seed.accounts || []) this.addAccount(a, { verified: true })
|
|
112
119
|
|
|
113
120
|
this.schemas = seed.schemas || {}
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
for (const [name, decl] of Object.entries(this.schemas)) {
|
|
122
|
+
if (decl && (decl.creatable_by !== undefined || decl.creatableBy !== undefined)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`[uniweb/api mock] ${name}: creatable_by is retired — Models are open: anyone signed in may create entries of any Model, and who may see or edit an entry is decided by the entry. Remove it.`
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const floors = seed.memberFloor != null && typeof seed.memberFloor === 'object' ? Object.values(seed.memberFloor) : [seed.memberFloor]
|
|
129
|
+
for (const floor of floors) {
|
|
130
|
+
if (floor != null && !['read', 'edit'].includes(floor)) {
|
|
131
|
+
throw new Error(`[uniweb/api mock] memberFloor is 'read' or 'edit', or absent — per Model or for all — got ${JSON.stringify(seed.memberFloor)}`)
|
|
132
|
+
}
|
|
116
133
|
}
|
|
117
|
-
/**
|
|
134
|
+
/**
|
|
135
|
+
* What membership lets one member do to another's entities: `null` (nothing), `read`
|
|
136
|
+
* or `edit` — for every Model, or a map of it per Model.
|
|
137
|
+
*/
|
|
118
138
|
this.memberFloor = seed.memberFloor ?? null
|
|
119
139
|
this.models = new Map()
|
|
120
140
|
const itemSections = new Map()
|
|
@@ -257,7 +277,6 @@ export class MockStore {
|
|
|
257
277
|
id: (this.ids.model += 1),
|
|
258
278
|
uuid: newUuid(),
|
|
259
279
|
name,
|
|
260
|
-
creatable_by: decl?.creatable_by || 'any_user',
|
|
261
280
|
decl: decl || {},
|
|
262
281
|
sections: [],
|
|
263
282
|
}
|
|
@@ -302,14 +321,14 @@ export class MockStore {
|
|
|
302
321
|
return null
|
|
303
322
|
}
|
|
304
323
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
maySeeSchema(
|
|
312
|
-
return !!this.account
|
|
324
|
+
/**
|
|
325
|
+
* The definition is readable by anyone signed in. ⭐ Models are open (2026-09-23):
|
|
326
|
+
* anyone signed in may create entries of any Model, and what protects content is the
|
|
327
|
+
* entry — `mayEdit`, `mayRead` — never who may create. *(Until then a Model declaring
|
|
328
|
+
* `creatable_by: unit_members` was the operator's alone, and so was its definition.)*
|
|
329
|
+
*/
|
|
330
|
+
maySeeSchema() {
|
|
331
|
+
return !!this.account
|
|
313
332
|
}
|
|
314
333
|
|
|
315
334
|
/** `GET /models/@scope/name`, in the backend's shape. */
|
|
@@ -429,15 +448,22 @@ export class MockStore {
|
|
|
429
448
|
return siblings[siblings.length - 1].order_number + GAP
|
|
430
449
|
}
|
|
431
450
|
|
|
451
|
+
/** The floor that applies to this entity's Model: `null`, `'read'` or `'edit'`. */
|
|
452
|
+
floorFor(entity) {
|
|
453
|
+
const floor = this.memberFloor
|
|
454
|
+
if (floor == null || typeof floor === 'string') return floor
|
|
455
|
+
return floor[entity.model] ?? null
|
|
456
|
+
}
|
|
457
|
+
|
|
432
458
|
/** The operator, the owner — or any member, when the service lets members read each other's. */
|
|
433
459
|
mayRead(entity) {
|
|
434
460
|
const me = this.account
|
|
435
|
-
return !!me && (me.operator || entity.owner_id === me.id || this.
|
|
461
|
+
return !!me && (me.operator || entity.owner_id === me.id || this.floorFor(entity) != null)
|
|
436
462
|
}
|
|
437
463
|
|
|
438
464
|
mayEdit(entity) {
|
|
439
465
|
const me = this.account
|
|
440
|
-
return !!me && (me.operator || entity.owner_id === me.id || this.
|
|
466
|
+
return !!me && (me.operator || entity.owner_id === me.id || this.floorFor(entity) === 'edit')
|
|
441
467
|
}
|
|
442
468
|
|
|
443
469
|
/**
|
|
@@ -466,12 +492,18 @@ export class MockStore {
|
|
|
466
492
|
* A localized field — declared `localized: true` — answered in the first listed
|
|
467
493
|
* locale it has, and omitted when it has none: the backend has no fallback of
|
|
468
494
|
* its own. Without a locale, the whole `{ locale: value }` map.
|
|
495
|
+
*
|
|
496
|
+
* ⛔ Only a plain object is a locale map. A localized LIST (`multiple: true`) holds
|
|
497
|
+
* an array, and an array is a value, not a map of them: read as a map it has no
|
|
498
|
+
* `en`, and the field was DELETED from every read that carried `?locale=` — which
|
|
499
|
+
* the client sends on every browser read. Measured 2026-09-22 on a course brief
|
|
500
|
+
* whose `outcomes` vanished, and on quiz questions whose `options` did.
|
|
469
501
|
*/
|
|
470
502
|
project(data, section, locales) {
|
|
471
503
|
if (!locales?.length || !section?.fields || !data || typeof data !== 'object') return data
|
|
472
504
|
const out = { ...data }
|
|
473
505
|
for (const [key, field] of Object.entries(section.fields)) {
|
|
474
|
-
if (!field?.localized || !out[key]
|
|
506
|
+
if (!field?.localized || !isLocaleMap(out[key])) continue
|
|
475
507
|
const hit = locales.find((l) => out[key][l] != null)
|
|
476
508
|
if (hit) out[key] = out[key][hit]
|
|
477
509
|
else delete out[key]
|