@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 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). A new sign-up must be
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/api",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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/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
- * `creatable_by: 'unit_members'` Models are created by the operator only; sign-up
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
- if (!store.mayCreate(model)) {
290
- return problem({
291
- status: 403,
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: `creatable_by` (`any_user`, the default, or
11
- * `unit_members` — the operator only) and `sections`, the Model's sections as
12
- * the framework lowers them. With `sections`, writes are shape-checked.
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, so the rule on `@/track` lets them author the programme.
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 — the same rule refuses them, server-side, and that
22
- // refusal is the demo. They may record attending, which anyone may.
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': { creatable_by?, sections?, append_only?, migration_debt? } }`.
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 lets one member do to
103
- * ANOTHER member's entities. Absent — the default — nothing: each member's are private.
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
- if (seed.memberFloor != null && !['read', 'edit'].includes(seed.memberFloor)) {
115
- throw new Error(`[uniweb/api mock] memberFloor is 'read' or 'edit', or absent — got ${JSON.stringify(seed.memberFloor)}`)
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
- /** What membership lets one member do to another's entities: `null` (nothing), `read` or `edit`. */
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
- mayCreate(model) {
306
- if (!this.account) return false
307
- return model.creatable_by === 'any_user' || this.account.operator
308
- }
309
-
310
- /** The definition is readable for the Models the viewer may create, and by the operator. */
311
- maySeeSchema(model) {
312
- return !!this.account && (this.account.operator || model.creatable_by === 'any_user')
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.memberFloor != null)
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.memberFloor === 'edit')
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] || typeof out[key] !== 'object') continue
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]