@oimlsmart/platform-server 0.2.15 → 0.2.16

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.15",
3
+ "version": "0.2.16",
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
@@ -28,7 +28,7 @@
28
28
  // key on the RAW binding (a rejected chain still evicts itself).
29
29
  // ═══════════════════════════════════════════════════════════════════
30
30
 
31
- import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types'
31
+ import type { D1Database, D1PreparedStatement, D1Result } from '@cloudflare/workers-types'
32
32
  import {
33
33
  APPEND_EVENTS_CHUNK,
34
34
  DEMO_PASSWORD,
@@ -1831,6 +1831,24 @@ export class D1ServerStore implements ServerStore {
1831
1831
  return res.results.map(D1ServerStore.toIdentityLink)
1832
1832
  }
1833
1833
 
1834
+ /** The bulk list-endpoint variant (identity's TODO.restructure/06):
1835
+ * ONE read for the whole set (the per-row loop's O(rows) D1 round
1836
+ * trips collapsed); every requested id answers, an unknown id as the
1837
+ * empty array; each account's links keep the per-id read's own
1838
+ * (linked_at, provider) order. */
1839
+ async listIdentityLinksBulk(userIds: string[]): Promise<Map<string, IdentityLink[]>> {
1840
+ const answer = new Map<string, IdentityLink[]>(userIds.map(id => [id, []]))
1841
+ if (userIds.length === 0) return answer
1842
+ const placeholders = userIds.map(() => '?').join(',')
1843
+ const res = await this.stmt(`SELECT * FROM identity_links WHERE user_id IN (${placeholders}) ORDER BY linked_at, provider`, ...userIds)
1844
+ .all<Record<string, unknown>>()
1845
+ for (const row of res.results) {
1846
+ const link = D1ServerStore.toIdentityLink(row)
1847
+ answer.get(link.userId)?.push(link)
1848
+ }
1849
+ return answer
1850
+ }
1851
+
1834
1852
  async findIdentityLink(provider: string, providerAccountId: string): Promise<IdentityLink | null> {
1835
1853
  const row = await this.stmt('SELECT * FROM identity_links WHERE provider = ? AND provider_account_id = ?', provider, providerAccountId)
1836
1854
  .first<Record<string, unknown>>()
@@ -1941,6 +1959,41 @@ export class D1ServerStore implements ServerStore {
1941
1959
  return { password: (pw?.n ?? 0) > 0, links: links?.n ?? 0, passkeys: passkeys?.n ?? 0 }
1942
1960
  }
1943
1961
 
1962
+ /** The bulk list-endpoint variant (identity's TODO.restructure/06): the
1963
+ * three grouped counts ride ONE batch — a single D1 round trip for
1964
+ * the whole set, never three per account. Every requested id answers;
1965
+ * an absent row reads as zero (the per-id read's posture). */
1966
+ async countSignInMethodsBulk(userIds: string[]): Promise<Map<string, { password: boolean; links: number; passkeys: number }>> {
1967
+ const answer = new Map<string, { password: boolean; links: number; passkeys: number }>(
1968
+ userIds.map(id => [id, { password: false, links: 0, passkeys: 0 }]),
1969
+ )
1970
+ if (userIds.length === 0) return answer
1971
+ const placeholders = userIds.map(() => '?').join(',')
1972
+ const grouped = (table: string) =>
1973
+ `SELECT user_id, COUNT(*) AS n FROM ${table} WHERE user_id IN (${placeholders}) GROUP BY user_id`
1974
+ const [pw, links, passkeys] = await this.db.batch<Record<string, unknown>>([
1975
+ this.stmt(grouped('passwords'), ...userIds),
1976
+ this.stmt(grouped('identity_links'), ...userIds),
1977
+ this.stmt(grouped('webauthn_credentials'), ...userIds),
1978
+ ])
1979
+ const tally = (res: D1Result<Record<string, unknown>>): Map<string, number> => {
1980
+ const m = new Map<string, number>()
1981
+ for (const row of res.results) m.set(String(row.user_id), Number(row.n))
1982
+ return m
1983
+ }
1984
+ const pwBy = tally(pw)
1985
+ const linksBy = tally(links)
1986
+ const passkeysBy = tally(passkeys)
1987
+ for (const id of userIds) {
1988
+ answer.set(id, {
1989
+ password: (pwBy.get(id) ?? 0) > 0,
1990
+ links: linksBy.get(id) ?? 0,
1991
+ passkeys: passkeysBy.get(id) ?? 0,
1992
+ })
1993
+ }
1994
+ return answer
1995
+ }
1996
+
1944
1997
  async createEnrollmentToken(input: {
1945
1998
  token: string
1946
1999
  userId: string
@@ -139,6 +139,38 @@ export function countSignInMethods(userId: string): { password: boolean; links:
139
139
  return { password: pw.n > 0, links: links.n, passkeys: passkeys.n }
140
140
  }
141
141
 
142
+ /** The bulk list-endpoint variant (identity's TODO.restructure/06): one
143
+ * grouped read per underlying table — three statements for the whole
144
+ * set, never three per account. Every requested id answers; an absent
145
+ * row reads as zero (the per-id read's posture). */
146
+ export function countSignInMethodsBulk(userIds: string[]): Map<string, { password: boolean; links: number; passkeys: number }> {
147
+ const answer = new Map<string, { password: boolean; links: number; passkeys: number }>(
148
+ userIds.map(id => [id, { password: false, links: 0, passkeys: 0 }]),
149
+ )
150
+ if (userIds.length === 0) return answer
151
+ const db = getDb()
152
+ const placeholders = userIds.map(() => '?').join(',')
153
+ const counts = (table: string): Map<string, number> => {
154
+ const m = new Map<string, number>()
155
+ const rows = db
156
+ .prepare(`SELECT user_id, COUNT(*) AS n FROM ${table} WHERE user_id IN (${placeholders}) GROUP BY user_id`)
157
+ .all(...userIds) as Array<{ user_id: string; n: number }>
158
+ for (const row of rows) m.set(row.user_id, row.n)
159
+ return m
160
+ }
161
+ const pw = counts('passwords')
162
+ const links = counts('identity_links')
163
+ const passkeys = counts('webauthn_credentials')
164
+ for (const id of userIds) {
165
+ answer.set(id, {
166
+ password: (pw.get(id) ?? 0) > 0,
167
+ links: links.get(id) ?? 0,
168
+ passkeys: passkeys.get(id) ?? 0,
169
+ })
170
+ }
171
+ return answer
172
+ }
173
+
142
174
  export function createEnrollmentToken(input: {
143
175
  token: string
144
176
  userId: string
@@ -120,6 +120,24 @@ export function listIdentityLinks(userId: string): IdentityLink[] {
120
120
  return rows.map(toIdentityLink)
121
121
  }
122
122
 
123
+ /** The bulk list-endpoint variant (identity's TODO.restructure/06): the
124
+ * same rows for every id, ONE read for the whole set, grouped in
125
+ * memory. The single ORDER BY keeps each account's links in the
126
+ * per-id read's own (linked_at, provider) order. */
127
+ export function listIdentityLinksBulk(userIds: string[]): Map<string, IdentityLink[]> {
128
+ const answer = new Map<string, IdentityLink[]>(userIds.map(id => [id, []]))
129
+ if (userIds.length === 0) return answer
130
+ const placeholders = userIds.map(() => '?').join(',')
131
+ const rows = getDb()
132
+ .prepare(`SELECT * FROM identity_links WHERE user_id IN (${placeholders}) ORDER BY linked_at, provider`)
133
+ .all(...userIds) as Array<Record<string, unknown>>
134
+ for (const row of rows) {
135
+ const link = toIdentityLink(row)
136
+ answer.get(link.userId)?.push(link)
137
+ }
138
+ return answer
139
+ }
140
+
123
141
  export function findIdentityLink(provider: string, providerAccountId: string): IdentityLink | null {
124
142
  const row = getDb().prepare('SELECT * FROM identity_links WHERE provider = ? AND provider_account_id = ?')
125
143
  .get(provider, providerAccountId) as Record<string, unknown> | undefined
@@ -154,6 +154,7 @@ import {
154
154
  findIdentityLink,
155
155
  getIdentityProvider,
156
156
  listIdentityLinks,
157
+ listIdentityLinksBulk,
157
158
  listIdentityProviders,
158
159
  setIdentityProviderEnabled,
159
160
  upsertIdentityProvider,
@@ -163,6 +164,7 @@ import {
163
164
  completeEmailChange,
164
165
  completeEnrollment,
165
166
  countSignInMethods,
167
+ countSignInMethodsBulk,
166
168
  createEmailChangeToken,
167
169
  createEnrollmentToken,
168
170
  createOpAccount,
@@ -759,6 +761,9 @@ export function createSqliteServerStore(): ServerStore {
759
761
  async listIdentityLinks(userId: string): Promise<IdentityLink[]> {
760
762
  return listIdentityLinks(userId)
761
763
  },
764
+ async listIdentityLinksBulk(userIds: string[]): Promise<Map<string, IdentityLink[]>> {
765
+ return listIdentityLinksBulk(userIds)
766
+ },
762
767
  async findIdentityLink(provider: string, providerAccountId: string): Promise<IdentityLink | null> {
763
768
  return findIdentityLink(provider, providerAccountId)
764
769
  },
@@ -792,6 +797,9 @@ export function createSqliteServerStore(): ServerStore {
792
797
  async countSignInMethods(userId: string): Promise<{ password: boolean; links: number; passkeys: number }> {
793
798
  return countSignInMethods(userId)
794
799
  },
800
+ async countSignInMethodsBulk(userIds: string[]): Promise<Map<string, { password: boolean; links: number; passkeys: number }>> {
801
+ return countSignInMethodsBulk(userIds)
802
+ },
795
803
  async createEnrollmentToken(input: {
796
804
  token: string
797
805
  userId: string
package/src/store.ts CHANGED
@@ -843,7 +843,7 @@ export type AddAccountEmailResult =
843
843
  | 'present'
844
844
  /** Another account holds the address (as its primary or an
845
845
  * additional), or it IS this account's primary — an address names at
846
- * most one account across the estate. */
846
+ * most one account across the platform. */
847
847
  | 'conflict'
848
848
 
849
849
  // ── strong authentication: the factor registry (TODO.identity-sso/02 + /03)
@@ -933,7 +933,7 @@ export interface MfaPending {
933
933
  // ── the personal access tokens (TODO.identity-features/08) ───────────
934
934
  // The developer surface: an ACCOUNT-minted credential for programmatic
935
935
  // access (the lab CLI, scripts, the agent pipelines). The GitHub
936
- // fine-grained pattern mapped to the estate:
936
+ // fine-grained pattern mapped to the platform:
937
937
  //
938
938
  // - the PAT NEVER rides a request directly — it exchanges at the OP's
939
939
  // token endpoint (the RFC 8693 grant, subject_token_type
@@ -961,7 +961,7 @@ export const PAT_ACTION_CLASSES = ['read', 'write', 'admin'] as const
961
961
  export type PatActionClass = (typeof PAT_ACTION_CLASSES)[number]
962
962
 
963
963
  /** One parsed scope: the service (a registered application-class OIDC
964
- * client id — the estate's service registry IS the OP's client
964
+ * client id — the platform's service registry IS the OP's client
965
965
  * registry) × the action class. */
966
966
  export interface PatScope {
967
967
  service: string
@@ -1263,7 +1263,7 @@ export interface OrgRegistryContact {
1263
1263
  * the org claim's value against its own participant registry directly:
1264
1264
  * the same string on both sides, so the mapping is identity, never a
1265
1265
  * lookup table). `kind` names the participant kind for a participant
1266
- * org (NULL = a non-participant org — the estate operator's own org, a
1266
+ * org (NULL = a non-participant org — the platform operator's own org, a
1267
1267
  * scheme consumer); the program side bounds the assignable per-org
1268
1268
  * roles by it. `participantRef` is the OPTIONAL annotation documenting
1269
1269
  * which participant record the org mirrors (the link's documentation,
@@ -1316,7 +1316,7 @@ export interface OrgRegistryOrg {
1316
1316
  * organization — the OP-minted org id, never an instance-minted one.
1317
1317
  * The hub's OWN row (the certificate_holder_orgs table): the registrar's
1318
1318
  * act extracts the descriptor the federation registration package carried
1319
- * (source 'registration'), or the estate admin's claim confirmation writes
1319
+ * (source 'registration'), or the register operator's claim confirmation writes
1320
1320
  * it for a legacy row (source 'claim'). ONE row per certificate — the
1321
1321
  * first attribution wins, a later writer never overwrites silently.
1322
1322
  *
@@ -1330,7 +1330,7 @@ export interface CertificateHolderOrg {
1330
1330
  orgName: string
1331
1331
  source: 'registration' | 'claim'
1332
1332
  attributedAt: string
1333
- /** The registrar / the confirming estate admin (the actor's name). */
1333
+ /** The registrar / the confirming register operator (the actor's name). */
1334
1334
  attributedBy: string | null
1335
1335
  /** The confirming claim (source 'claim' only). */
1336
1336
  claimId: string | null
@@ -1338,7 +1338,7 @@ export interface CertificateHolderOrg {
1338
1338
 
1339
1339
  /** The legacy-row claim act's lifecycle: PENDING (the manufacturer org's
1340
1340
  * administrator claimed the row by holder-name match — a claim is a
1341
- * claim until confirmed) → CONFIRMED (the estate admin's act; the
1341
+ * claim until confirmed) → CONFIRMED (the register operator's act; the
1342
1342
  * attribution row lands) / REFUSED (terminal for THAT claim, with the
1343
1343
  * written reason; a fresh claim may follow). */
1344
1344
  export type CertificateHolderClaimState = 'pending' | 'confirmed' | 'refused'
@@ -1893,6 +1893,13 @@ export interface ServerStore {
1893
1893
  // ── the linked identities (TODO.identity/02's shape, 08's flows) ──
1894
1894
  /** The account's linked upstream identities (the account surface). */
1895
1895
  listIdentityLinks(userId: string): Promise<IdentityLink[]>
1896
+ /** The bulk list-endpoint variant (identity's TODO.restructure/06):
1897
+ * the same rows as listIdentityLinks for every id, ONE read for the
1898
+ * whole set — the per-row loop's O(rows) store-call disease's
1899
+ * kernel-side answer. Every requested id answers (an unknown id
1900
+ * honestly as the empty array, the per-id read's own posture); an
1901
+ * empty array answers an empty map. */
1902
+ listIdentityLinksBulk(userIds: string[]): Promise<Map<string, IdentityLink[]>>
1896
1903
  /** THE match rule's read: resolve (provider, providerAccountId) → the
1897
1904
  * link (and thereby the account). NEVER match by email alone. */
1898
1905
  findIdentityLink(provider: string, providerAccountId: string): Promise<IdentityLink | null>
@@ -1938,6 +1945,13 @@ export interface ServerStore {
1938
1945
  * sign-in method (passwordless), so the at-least-one-way-in guard
1939
1946
  * reads it alongside the password and the links. */
1940
1947
  countSignInMethods(userId: string): Promise<{ password: boolean; links: number; passkeys: number }>
1948
+ /** The bulk list-endpoint variant (identity's TODO.restructure/06):
1949
+ * the same counts for every id, one grouped read per underlying
1950
+ * table (the D1 leg rides them in ONE batch) instead of three reads
1951
+ * per account. Every requested id answers; an absent row reads as
1952
+ * zero (the per-id read's posture); an empty array answers an empty
1953
+ * map. */
1954
+ countSignInMethodsBulk(userIds: string[]): Promise<Map<string, { password: boolean; links: number; passkeys: number }>>
1941
1955
  /** The enrollment links (invite-only). The token arrives from the
1942
1956
  * caller (auth/op/accounts.ts's mint); expires_at = now + ttlMs. */
1943
1957
  createEnrollmentToken(input: {
@@ -2411,7 +2425,7 @@ export interface ServerStore {
2411
2425
  }): Promise<CertificateHolderOrg | null>
2412
2426
  getCertificateHolderOrg(certificateId: string): Promise<CertificateHolderOrg | null>
2413
2427
  /** The surface's reads: one org's attributions (the manufacturer cone),
2414
- * or every row (the estate cone — no filter). */
2428
+ * or every row (the register-operator cone — no filter). */
2415
2429
  listCertificateHolderOrgs(filter?: { orgId?: string }): Promise<CertificateHolderOrg[]>
2416
2430
  /** File the legacy-row claim (the manufacturer org admin's act). */
2417
2431
  createCertificateHolderClaim(input: {
@@ -2422,7 +2436,7 @@ export interface ServerStore {
2422
2436
  claimedBy: string
2423
2437
  }): Promise<CertificateHolderClaim>
2424
2438
  getCertificateHolderClaim(id: string): Promise<CertificateHolderClaim | null>
2425
- /** The queues: the estate admin's pending list (state filter), the
2439
+ /** The queues: the register operator's pending list (state filter), the
2426
2440
  * claiming org's own claims (claimantOrgId filter), one certificate's
2427
2441
  * claim history (certificateId filter). */
2428
2442
  listCertificateHolderClaims(filter?: {
@@ -2430,7 +2444,7 @@ export interface ServerStore {
2430
2444
  claimantOrgId?: string
2431
2445
  certificateId?: string
2432
2446
  }): Promise<CertificateHolderClaim[]>
2433
- /** The estate admin's decision — ATOMIC on 'pending': an
2447
+ /** The register operator's decision — ATOMIC on 'pending': an
2434
2448
  * already-decided claim answers null (a double confirm/refuse loses
2435
2449
  * the race honestly). */
2436
2450
  decideCertificateHolderClaim(
@@ -26,9 +26,9 @@ export const APP_ROLES = [
26
26
  // own org's people on the identity service (/op/admin/users).
27
27
  'org_admin',
28
28
  // TODO.adoption/11 — Utilizer/Associate staff: declare Additional
29
- // National Requirements for their country on the ANR registry console
30
- // (the declaration carries the participant it acts for; the CS
31
- // registry's approval is the moderation gate).
29
+ // National Requirements for their country on the Member portal (the
30
+ // declaration carries the participant it acts for; the CS registry's
31
+ // approval is the moderation gate).
32
32
  'scheme_participant',
33
33
  // TODO.adoption/05 — the market-surveillance authority account: the
34
34
  // register's authority audience for the schemes that reserve their
@@ -51,14 +51,20 @@ export function roleHome(role: string | null | undefined): string {
51
51
  case 'applicant': return '/app/portal'
52
52
  case 'ia_officer': return '/app/ia'
53
53
  case 'tl_operator': return '/app/lab'
54
- case 'biml_officer': return '/app/biml'
54
+ // The registration officer's desk: the former /app/biml portal, now a
55
+ // carve-out of the CS console (the six-portal taxonomy).
56
+ case 'biml_officer': return '/app/cs/registration'
55
57
  case 'cs_admin': return '/app/cs'
58
+ // The scheme-desk roles land on the /app/cs hub; their working desks
59
+ // (the approval pipeline, the participant registry, the ANR review
60
+ // queue) sit one prefix below it.
56
61
  case 'mc_member':
57
- case 'rc_member': return '/app/cs/approvals'
58
- case 'executive_secretary': return '/app/cs/participants'
59
- // TODO.adoption/11 — the Utilizer/Associate staffer lands on the ANR
60
- // registry console (the only /app/cs surface the role may enter).
61
- case 'scheme_participant': return '/app/cs/anr'
62
+ case 'rc_member': return '/app/cs'
63
+ case 'executive_secretary': return '/app/cs'
64
+ // TODO.adoption/11 — the Utilizer/Associate staffer lands on the
65
+ // Member portal (the ANR declaration desk moved there from the scheme
66
+ // console).
67
+ case 'scheme_participant': return '/app/member'
62
68
  // TODO.adoption/05 — the market-surveillance authority's surface is
63
69
  // the register (its read-only authority view).
64
70
  case 'market_surveillance': return '/app/register'
@@ -94,7 +100,11 @@ export interface RoleSectionRule {
94
100
  * Secretary's surface. Both stay open to cs_admin (scheme operations).
95
101
  * TODO.roadmap/45: the scheme-operations console (/app/cs/operations —
96
102
  * appeals, complaints, misuse, deregistration) is the Executive
97
- * Secretary's post-issuance surface.
103
+ * Secretary's post-issuance surface. The registration desk
104
+ * (/app/cs/registration — the former /app/biml portal) is the
105
+ * registration officer's surface. The Member portal (/app/member) is the
106
+ * Utilizer/Associate staffer's surface; the ANR declaration desk moved
107
+ * there from /app/cs/anr (the review queue stays scheme-side).
98
108
  */
99
109
  export const ROLE_SECTION_RULES: RoleSectionRule[] = [
100
110
  // TODO.federation/02 — the engagement conversion hands off to the
@@ -117,8 +127,13 @@ export const ROLE_SECTION_RULES: RoleSectionRule[] = [
117
127
  // TODO.cs-e2e/05a — /app/lab/projects/<id> (the laboratory's scoped TEP
118
128
  // context view) rides the /app/lab prefix: the laboratory's own section.
119
129
  { prefix: '/app/lab', roles: ['tl_operator'] },
130
+ // The Member portal: the Utilizer/Associate staffer's surface (the ANR
131
+ // declaration desk moved here from the CS console — the six-portal
132
+ // taxonomy).
133
+ { prefix: '/app/member', roles: ['scheme_participant'] },
120
134
  // TODO.register/02 — the register's owner view: the manufacturer org's
121
- // own rows, the IA desk's issued cone, the estate's all-seeing cone +
135
+ // own rows, the IA desk's issued cone, the register operator's all-seeing
136
+ // cone +
122
137
  // the holder-claim queue. The module path rule (auth/modules.ts) rides
123
138
  // the register module.
124
139
  { prefix: '/app/my-certificates', roles: ['applicant', 'ia_officer', 'case_officer', 'certification_officer', 'signatory', 'biml_officer', 'cs_admin'] },
@@ -129,10 +144,15 @@ export const ROLE_SECTION_RULES: RoleSectionRule[] = [
129
144
  { prefix: '/app/cs/approvals', roles: ['mc_member', 'rc_member', 'executive_secretary', 'cs_admin'] },
130
145
  { prefix: '/app/cs/participants', roles: ['executive_secretary', 'cs_admin'] },
131
146
  { prefix: '/app/cs/operations', roles: ['executive_secretary', 'cs_admin'] },
132
- // TODO.adoption/11 — the ANR registry console: the Utilizer/Associate
133
- // staffer (scheme_participant) declares here; the CS registry moderates.
147
+ // TODO.adoption/11 — the ANR review queue: the scheme side (the
148
+ // Executive Secretary, open to cs_admin) moderates the member-declared
149
+ // declarations; the declaration desk itself moved to the Member portal.
134
150
  // More specific than /app/cs, so it must come first (first match wins).
135
- { prefix: '/app/cs/anr', roles: ['scheme_participant', 'executive_secretary', 'cs_admin'] },
151
+ { prefix: '/app/cs/anr', roles: ['executive_secretary', 'cs_admin'] },
152
+ // The registration desk: the registration officer's surface (the
153
+ // former /app/biml portal), open to cs_admin for scheme operations.
154
+ // More specific than /app/cs, so it must come first (first match wins).
155
+ { prefix: '/app/cs/registration', roles: ['biml_officer', 'cs_admin'] },
136
156
  // TODO.cs-e2e/13.15 — Data management is the workspace's own export/
137
157
  // import surface, and the profile indicator's degradation banner names
138
158
  // it as the way out for EVERY role; it must stay reachable by every