@uniweb/api 0.5.0 → 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
@@ -206,7 +206,9 @@ who may create entries, which sections are insert-only, the fields a section tak
206
206
  and it is as strict as the backend about requests, so a mistake fails here rather
207
207
  than in front of a user. Members read and write their own entities and nothing of each
208
208
  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
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
210
212
  verified: the link is in `mock.outbox` (the standalone server prints it). State is in
211
213
  memory: restart to reset.
212
214
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/api",
3
- "version": "0.5.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": {
package/src/mock/store.js CHANGED
@@ -85,6 +85,9 @@ 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
@@ -99,8 +102,11 @@ export class MockStore {
99
102
  * seed's items and `append_only` use.
100
103
  * @param {object[]} [seed.entities] - `{ uuid?, model, owner?, items?: [{ section, data, parent? }] }`.
101
104
  * `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.
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.
104
110
  * @param {object} [options]
105
111
  * @param {string} [options.signedInAs] - start with this account already signed in.
106
112
  */
@@ -111,10 +117,16 @@ export class MockStore {
111
117
  for (const a of seed.accounts || []) this.addAccount(a, { verified: true })
112
118
 
113
119
  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)}`)
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
+ }
116
125
  }
117
- /** 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
+ */
118
130
  this.memberFloor = seed.memberFloor ?? null
119
131
  this.models = new Map()
120
132
  const itemSections = new Map()
@@ -429,15 +441,22 @@ export class MockStore {
429
441
  return siblings[siblings.length - 1].order_number + GAP
430
442
  }
431
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
+
432
451
  /** The operator, the owner — or any member, when the service lets members read each other's. */
433
452
  mayRead(entity) {
434
453
  const me = this.account
435
- 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)
436
455
  }
437
456
 
438
457
  mayEdit(entity) {
439
458
  const me = this.account
440
- 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')
441
460
  }
442
461
 
443
462
  /**
@@ -466,12 +485,18 @@ export class MockStore {
466
485
  * A localized field — declared `localized: true` — answered in the first listed
467
486
  * locale it has, and omitted when it has none: the backend has no fallback of
468
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.
469
494
  */
470
495
  project(data, section, locales) {
471
496
  if (!locales?.length || !section?.fields || !data || typeof data !== 'object') return data
472
497
  const out = { ...data }
473
498
  for (const [key, field] of Object.entries(section.fields)) {
474
- if (!field?.localized || !out[key] || typeof out[key] !== 'object') continue
499
+ if (!field?.localized || !isLocaleMap(out[key])) continue
475
500
  const hit = locales.find((l) => out[key][l] != null)
476
501
  if (hit) out[key] = out[key][hit]
477
502
  else delete out[key]