@oimlsmart/platform-server 0.2.7 → 0.2.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimlsmart/platform-server",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "The OIML SMART platform server kernel: the store seam (ServerStore + the D1 and SQLite implementations), the canonical D1 migration set both deployments apply, the instance profile, the mailer, the RBAC map, the OIDC/OAuth client cones, and the shared role/permission vocabulary. Consumed by the smart monorepo (browser/) and the identity service.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/store/d1.ts CHANGED
@@ -32,6 +32,7 @@ import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types'
32
32
  import {
33
33
  DEMO_PASSWORD,
34
34
  EVENTS_BULK_KEY_CHUNK,
35
+ PUT_ENTITIES_CHUNK,
35
36
  StoreUnavailable,
36
37
  type AccountEmail,
37
38
  type AddAccountEmailResult,
@@ -44,6 +45,7 @@ import {
44
45
  type EnrollmentToken,
45
46
  type EntityChange,
46
47
  type EntityRow,
48
+ type EntityWriteInput,
47
49
  type EventEntityKey,
48
50
  type EventKeyFilter,
49
51
  type FederationPeer,
@@ -413,6 +415,14 @@ function boundedD1Writes(binding: D1Database, budgetMs: number): D1Database {
413
415
  }
414
416
  }
415
417
 
418
+ /** The entity write's two statements, ONE textual source for putEntity
419
+ * and putEntities alike (the multi-row write must land each row
420
+ * byte-identically to the single-row verb — the upsert, then its
421
+ * journal entry). */
422
+ const ENTITY_UPSERT_SQL = `INSERT INTO entities (store, id, org_id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))
423
+ ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`
424
+ const ENTITY_CHANGE_SQL = 'INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)'
425
+
416
426
  export class D1ServerStore implements ServerStore {
417
427
  /** The RAW binding — the ensure memos (and d1StoreFor's map) key on
418
428
  * it: the facade below is per-instance and would never hit. */
@@ -3450,15 +3460,34 @@ export class D1ServerStore implements ServerStore {
3450
3460
  // all-or-nothing, the same atomicity the SQLite path gets from its
3451
3461
  // transaction.
3452
3462
  await this.db.batch([
3453
- this.stmt(
3454
- `INSERT INTO entities (store, id, org_id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))
3455
- ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`,
3456
- store, id, orgId, data,
3457
- ),
3458
- this.stmt('INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)', store, 'persist', id),
3463
+ this.stmt(ENTITY_UPSERT_SQL, store, id, orgId, data),
3464
+ this.stmt(ENTITY_CHANGE_SQL, store, 'persist', id),
3459
3465
  ])
3460
3466
  }
3461
3467
 
3468
+ async putEntities(store: string, rows: readonly EntityWriteInput[]): Promise<void> {
3469
+ // The seam's contract, mechanically: each row lands exactly as
3470
+ // putEntity would land it (the upsert, then its journal entry), the
3471
+ // statements ride each batch in INPUT order, and the chunks issue
3472
+ // SERIALLY — so the journal's seq order IS the input's row order
3473
+ // (the audit-chain property the CSV commit relies on). The chunk is
3474
+ // the atomic unit: a D1 batch lands all-or-nothing; a failed chunk
3475
+ // throws with its rows unlanded, earlier chunks standing, later
3476
+ // chunks never issued.
3477
+ if (rows.length === 0) return
3478
+ for (let i = 0; i < rows.length; i += PUT_ENTITIES_CHUNK) {
3479
+ const chunk = rows.slice(i, i + PUT_ENTITIES_CHUNK)
3480
+ const statements: D1PreparedStatement[] = []
3481
+ for (const row of chunk) {
3482
+ statements.push(
3483
+ this.stmt(ENTITY_UPSERT_SQL, store, row.id, row.orgId, row.data),
3484
+ this.stmt(ENTITY_CHANGE_SQL, store, 'persist', row.id),
3485
+ )
3486
+ }
3487
+ await this.db.batch(statements)
3488
+ }
3489
+ }
3490
+
3462
3491
  async deleteEntity(store: string, id: string): Promise<boolean> {
3463
3492
  const res = await this.stmt('DELETE FROM entities WHERE store = ? AND id = ?', store, id).run()
3464
3493
  const gone = (res.meta.changes ?? 0) > 0
@@ -13,12 +13,20 @@
13
13
  // ═══════════════════════════════════════════════════════════════════
14
14
 
15
15
  import { getDb } from './store'
16
- import { orgIdOf } from '../../store'
16
+ import { PUT_ENTITIES_CHUNK, orgIdOf } from '../../store'
17
17
 
18
18
  export type { EntityRow, EntityChange } from '../../store'
19
19
  export { ORG_FIELDS, CATALOG_STORES, orgIdOf } from '../../store'
20
20
 
21
- import type { EntityRow, EntityChange } from '../../store'
21
+ import type { EntityRow, EntityChange, EntityWriteInput } from '../../store'
22
+
23
+ /** The entity write's two statements, ONE textual source for putEntity
24
+ * and putEntities alike (the multi-row write must land each row
25
+ * byte-identically to the single-row verb — the upsert, then its
26
+ * journal entry). */
27
+ const ENTITY_UPSERT_SQL = `INSERT INTO entities (store, id, org_id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))
28
+ ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`
29
+ const ENTITY_CHANGE_SQL = 'INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)'
22
30
 
23
31
  export function listEntities(store: string): EntityRow[] {
24
32
  // The ORDER BY is the seam's contract (the 0.2.3 pin, the D1 half's
@@ -40,15 +48,35 @@ export function getEntity(store: string, id: string): EntityRow | undefined {
40
48
  export function putEntity(store: string, id: string, orgId: string | null, data: string): void {
41
49
  const db = getDb()
42
50
  const write = db.transaction(() => {
43
- db.prepare(
44
- `INSERT INTO entities (store, id, org_id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))
45
- ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`,
46
- ).run(store, id, orgId, data)
47
- db.prepare('INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)').run(store, 'persist', id)
51
+ db.prepare(ENTITY_UPSERT_SQL).run(store, id, orgId, data)
52
+ db.prepare(ENTITY_CHANGE_SQL).run(store, 'persist', id)
48
53
  })
49
54
  write()
50
55
  }
51
56
 
57
+ /** The multi-row write (the seam's putEntities, the 2026-09-07 audit's
58
+ * J1): each row lands exactly as putEntity would land it, in INPUT
59
+ * order, one transaction per PUT_ENTITIES_CHUNK rows — the chunk is
60
+ * the atomic unit, matching the D1 batch's all-or-nothing; a failed
61
+ * chunk throws with its rows unlanded, earlier chunks standing, later
62
+ * chunks never issued. The chunks run serially, so the journal's seq
63
+ * order IS the input's row order. */
64
+ export function putEntities(store: string, rows: readonly EntityWriteInput[]): void {
65
+ if (rows.length === 0) return
66
+ const db = getDb()
67
+ const upsert = db.prepare(ENTITY_UPSERT_SQL)
68
+ const journal = db.prepare(ENTITY_CHANGE_SQL)
69
+ for (let i = 0; i < rows.length; i += PUT_ENTITIES_CHUNK) {
70
+ const chunk = rows.slice(i, i + PUT_ENTITIES_CHUNK)
71
+ db.transaction(() => {
72
+ for (const row of chunk) {
73
+ upsert.run(store, row.id, row.orgId, row.data)
74
+ journal.run(store, 'persist', row.id)
75
+ }
76
+ })()
77
+ }
78
+ }
79
+
52
80
  export function deleteEntity(store: string, id: string): boolean {
53
81
  const db = getDb()
54
82
  let gone = false
@@ -86,6 +86,7 @@ import {
86
86
  latestChangeSeq,
87
87
  latestChangeSeqFor,
88
88
  listEntities,
89
+ putEntities,
89
90
  putEntity,
90
91
  } from './sqlite/entities'
91
92
  import {
@@ -186,7 +187,7 @@ import {
186
187
  updateOpAccount,
187
188
  updateUserName,
188
189
  } from './sqlite/op-accounts-store'
189
- import { installStore, type AccountEmail, type AddAccountEmailResult, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type ConsumeOidcRefreshTokenResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EventEntityKey, type EventKeyFilter, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type NotifyDelivery, type NotifyDeliveryStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcConsentGrant, type OidcKeyRow, type OidcRefreshToken, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PersonalAccessToken, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
190
+ import { installStore, type AccountEmail, type AddAccountEmailResult, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type ConsumeOidcRefreshTokenResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EntityWriteInput, type EventEntityKey, type EventKeyFilter, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type NotifyDelivery, type NotifyDeliveryStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcConsentGrant, type OidcKeyRow, type OidcRefreshToken, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PersonalAccessToken, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
190
191
  import {
191
192
  advanceWebauthnCounter,
192
193
  consumeMfaPending,
@@ -1014,6 +1015,9 @@ export function createSqliteServerStore(): ServerStore {
1014
1015
  async putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void> {
1015
1016
  putEntity(store, id, orgId, data)
1016
1017
  },
1018
+ async putEntities(store: string, rows: readonly EntityWriteInput[]): Promise<void> {
1019
+ putEntities(store, rows)
1020
+ },
1017
1021
  async deleteEntity(store: string, id: string): Promise<boolean> {
1018
1022
  return deleteEntity(store, id)
1019
1023
  },
package/src/store.ts CHANGED
@@ -175,6 +175,26 @@ export interface EntityChange {
175
175
  at: string
176
176
  }
177
177
 
178
+ /** One row of the multi-row write (putEntities): putEntity's (id,
179
+ * orgId, data) legs — the store is the call's, once for the whole
180
+ * batch. */
181
+ export interface EntityWriteInput {
182
+ id: string
183
+ orgId: string | null
184
+ data: string
185
+ }
186
+
187
+ /** The multi-row write's batch chunk, in ROWS (putEntities): each row
188
+ * contributes two statements (the upsert + its journal entry), so a
189
+ * chunk is one db.batch of 2 × PUT_ENTITIES_CHUNK statements — the
190
+ * same conservative order as EVENTS_BULK_KEY_CHUNK. The chunk bounds
191
+ * the ATOMIC unit (a D1 batch is all-or-nothing; the SQLite half's
192
+ * per-chunk transaction matches it) and keeps one batch's latency
193
+ * well inside the write budget at demo-hub latency. Chunks issue
194
+ * SERIALLY, in input order — the journal's seq order IS the input
195
+ * order, and parallel chunks would forfeit it. */
196
+ export const PUT_ENTITIES_CHUNK = 50
197
+
178
198
  // ── the platform event store (TODO.notify/01) ────────────────────────
179
199
 
180
200
  /** A notifiable platform event (TODO.notify/00's event model): one row
@@ -2349,6 +2369,33 @@ export interface ServerStore {
2349
2369
  listEntities(store: string): Promise<EntityRow[]>
2350
2370
  getEntity(store: string, id: string): Promise<EntityRow | undefined>
2351
2371
  putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void>
2372
+ /** The MULTI-ROW write (the 2026-09-07 performance audit's J1 — the
2373
+ * CSV registration commit awaited putEntity per imported row, ~10⁴
2374
+ * rows = minutes of serial D1 writes at demo latency): every row
2375
+ * lands exactly as putEntity would land it (the upsert + its
2376
+ * journal 'persist' entry, one journal row per input row, a
2377
+ * duplicate id re-journals and the last write wins), but the rows
2378
+ * ride ONE db.batch per PUT_ENTITIES_CHUNK rows instead of one
2379
+ * round trip per row.
2380
+ *
2381
+ * The ordering contract — a semantic property of the audit chain,
2382
+ * never an implementation detail: the journal's seq order IS the
2383
+ * input's row order. The statements ride each batch in input order
2384
+ * and the chunks issue SERIALLY (parallel chunks would interleave
2385
+ * the seq assignment); a caller parallelizing the call itself
2386
+ * forfeits the contract.
2387
+ *
2388
+ * The failure contract: the chunk is the ATOMIC unit (a D1 batch is
2389
+ * all-or-nothing; the SQLite half wraps each chunk in a
2390
+ * transaction). A failed chunk lands NOTHING of its rows and the
2391
+ * call throws — but earlier chunks' writes STAND, and later chunks
2392
+ * never issue. A caller needing the whole import all-or-nothing
2393
+ * keeps it under one chunk (or compensates above the seam; the
2394
+ * retry re-journals, the same caveat as any putEntity retry — the
2395
+ * bounded-write facade's StoreUnavailable "may have landed"
2396
+ * posture). An empty rows list resolves without issuing a
2397
+ * statement. */
2398
+ putEntities(store: string, rows: readonly EntityWriteInput[]): Promise<void>
2352
2399
  deleteEntity(store: string, id: string): Promise<boolean>
2353
2400
  changesAfter(seq: number, limit?: number): Promise<EntityChange[]>
2354
2401
  /** The GLOBAL journal high-water: the bootstrap snapshot's ETag leg
@@ -428,3 +428,87 @@ export const CREATION_PERMISSIONS: Record<string, readonly ActionPermission[]> =
428
428
  export const FIELD_GUARD_PERMISSIONS: Record<string, Record<string, readonly ActionPermission[]>> = {
429
429
  testReports: { signature_acknowledgment: ['tr.sign'] },
430
430
  }
431
+
432
+ // ── The store-wide write gate (the 2026-09-07 security cone audit's
433
+ // F2) ────────────────────────────────────────────────────────────
434
+ // ORG_FIELDS (store.ts) cones the org-fielded workflow stores and
435
+ // STORE_MACHINES + CREATION/FIELD_GUARD gate the machinated ones — but
436
+ // both leave holes the audit named: a store with NO org fields and NO
437
+ // machine answered a generic PUT/DELETE from ANY authenticated role
438
+ // (viewer included), and a machinated store gated only its status moves
439
+ // — a field update without a status change, or a creation the CREATION
440
+ // map does not name, computed NO requirement at all. The worst
441
+ // amplification: organizations carries the submission_public_keys the
442
+ // intake's signature check trusts.
443
+ //
444
+ // THIS map is the write side's store-class declaration for the stores
445
+ // the org cone cannot carry: EVERY generic write to a listed store
446
+ // (create, in-place update, delete — status change or not) requires the
447
+ // actor to hold ANY of the named permissions, on top of every other
448
+ // gate that fires (the machine's transitions, the creation acts, the
449
+ // field guards). The consumer's entities router composes the classes:
450
+ // org-fielded → the org cone; catalog → the catalog write gate;
451
+ // machinated → the machine's edges; listed here → this gate; NONE of
452
+ // those → the generic write refuses outright (deny-by-default). The
453
+ // gate is uniform over roles: the platform roles pass because their map
454
+ // grants the permission, never because the gate looks away.
455
+
456
+ /** The store-wide write requirements (store → any-of permission set).
457
+ * The assignments follow the app's actual write callers, audited
458
+ * 2026-09-07:
459
+ *
460
+ * - the participants register (PD-03/PD-04/PD-08/PD-09): the Executive
461
+ * Secretary administers (participants.manage), the RC assesses
462
+ * (participants.review), the MC votes (participants.decide);
463
+ * - the scheme-operations records (B 18 §7 — appeals, complaints,
464
+ * misuse, deregistrations) are the operations console's;
465
+ * - the §15.8 registered copies record at the registration act (the
466
+ * IA's local register-with-BIML fallback holds certificate.register)
467
+ * and at the operations console's corrections;
468
+ * - the ANR validation letters record at issuance (the issuing
469
+ * officer's certificate.issue) and at the operations console;
470
+ * - the ANR declarations: the Utilizer/Associate staffer declares
471
+ * (anr.declare), the registry moderates (anr.review) — the DRAFT's
472
+ * in-place edits ride the declarer's permission;
473
+ * - the instrument register's parties (organizations, manufacturers)
474
+ * are instance-settings/registry records — organizations in
475
+ * particular carries submission_public_keys, the intake signature
476
+ * trust root, so its writes are the instance's own administration;
477
+ * the manufacturer org rows are created at application intake (the
478
+ * wizard's quick-create, the IA desk's white-gloves entry);
479
+ * - the per-model determination records are the evaluation desk's
480
+ * (er.review / the TR review that stamps them);
481
+ * - the lab's work records (form instances, verification records, the
482
+ * module-B markings/sealings/calibration records) ride their work
483
+ * permissions; the IA's review desks write form instances on the
484
+ * review/reopen legs;
485
+ * - the certificate's document lists and annexes are the issuers'. */
486
+ export const STORE_WRITE_PERMISSIONS: Record<string, readonly ActionPermission[]> = {
487
+ organizations: ['instance.settings'],
488
+ manufacturers: ['application.submit', 'application.review', 'instance.settings'],
489
+ experts: ['participants.manage'],
490
+ utilizers: ['participants.manage'],
491
+ associates: ['participants.manage'],
492
+ categorySchemes: ['participants.manage'],
493
+ approvalVotes: ['participants.decide'],
494
+ competenceEvidence: ['participants.review', 'participants.manage'],
495
+ participantApplications: ['participants.review', 'participants.manage'],
496
+ participantDeclarations: ['participants.manage'],
497
+ operatedSchemes: ['instance.settings'],
498
+ certificateDocumentLists: ['certificate.issue'],
499
+ certificateAnnexes: ['certificate.issue', 'certificate.manage'],
500
+ registeredCopies: ['certificate.register', 'operations.manage'],
501
+ validationLetters: ['certificate.issue', 'operations.manage'],
502
+ deregistrations: ['operations.manage'],
503
+ appeals: ['operations.manage'],
504
+ complaints: ['operations.manage'],
505
+ misuseCases: ['operations.manage'],
506
+ anrDeclarations: ['anr.declare', 'anr.review'],
507
+ testReportDeterminations: ['tr.review', 'er.review'],
508
+ formInstances: ['run.perform', 'tr.review', 'er.review'],
509
+ verificationRecords: ['verification.perform'],
510
+ certificateRegistrations: ['certificate.register'],
511
+ markings: ['markings.manage'],
512
+ sealings: ['markings.manage'],
513
+ calibrationRecords: ['markings.manage'],
514
+ }