@oimlsmart/platform-server 0.1.0 → 0.1.2

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.
@@ -0,0 +1,28 @@
1
+ -- Migration 0017 — the org-member DATA CONE (TODO.identity-features/09,
2
+ -- wave A). Every org membership gains a nullable cone: the org
3
+ -- administrator's answer to "what can this member see and do" —
4
+ -- org-wide (the default; every existing membership keeps it SILENTLY,
5
+ -- NULL reads as org-wide) / assigned (the member sees the org's rows
6
+ -- only where NAMED — the operator on the test run, the assignment's
7
+ -- performer, the engagement's inquirer) / read-only (the orthogonal
8
+ -- modifier, composable: the member reads per the scope, the write gate
9
+ -- refuses them). The canonical spellings: NULL | 'assigned' |
10
+ -- 'read-only' | 'assigned+read-only' (the kernel's parseOrgMemberCone
11
+ -- owns the grammar; a malformed cell fails CLOSED — the narrowest cone,
12
+ -- never a silent re-widen).
13
+ --
14
+ -- EXPAND-ONLY, as every file in this set: a new nullable column, never
15
+ -- a renumber, never a rewrite — wrangler keys the live registry's
16
+ -- bookkeeping on the file name.
17
+ --
18
+ -- THE MONOREPO MIRROR: this file is authored HERE (this repo owns the
19
+ -- live D1 since the wave-03 cutover); the byte-identical mirror in the
20
+ -- smart monorepo's browser/server/db/migrations/ + the kernel's
21
+ -- schema.sql end-state land as the coordinator's follow-up in the
22
+ -- kernel-extraction wave (agent-585's re-home of packages/platform-
23
+ -- server). Until then the kernel's defensive ensures (the
24
+ -- migrateAuthTables / ensureMembershipSupport posture) grow the column
25
+ -- on the platform's dev/test stores, and the byte-identity tripwire
26
+ -- (src/__tests__/id-migrations-ssot.test.ts) carries this file as a
27
+ -- dated, wave-referenced local-only allowance.
28
+ ALTER TABLE org_memberships ADD COLUMN cone TEXT;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimlsmart/platform-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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
@@ -62,6 +62,7 @@ import {
62
62
  type InstrumentRegistrationScopeStatus,
63
63
  type PlatformEvent,
64
64
  resolveOrgContext,
65
+ parseOrgMemberCone,
65
66
  type RecoveryCodeState,
66
67
  type ServerStore,
67
68
  type SessionView,
@@ -258,6 +259,14 @@ export class D1ServerStore implements ServerStore {
258
259
  ).run()
259
260
  await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_org ON org_memberships (org_id, state)').run()
260
261
  await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_user ON org_memberships (user_id, state)').run()
262
+ // TODO.identity-features/09 (the org-member data cone): the cone
263
+ // column arrives with migration 0017 — a dev D1 predating it
264
+ // grows the column here. NULL = org-wide: existing memberships
265
+ // keep their posture silently.
266
+ const membershipCols = await this.db.prepare('PRAGMA table_info(org_memberships)').all<{ name: string }>()
267
+ if (!membershipCols.results.some(c => c.name === 'cone')) {
268
+ await this.db.prepare('ALTER TABLE org_memberships ADD COLUMN cone TEXT').run()
269
+ }
261
270
  const sessionCols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
262
271
  if (!sessionCols.results.some(c => c.name === 'active_org')) {
263
272
  await this.db.prepare('ALTER TABLE sessions ADD COLUMN active_org TEXT').run()
@@ -569,7 +578,9 @@ export class D1ServerStore implements ServerStore {
569
578
  // The stale stamp never lingers (the membership ended mid-session).
570
579
  await this.stmt('UPDATE sessions SET active_org = NULL WHERE user_id = ? AND active_org = ?', payload.id, activeOrg).run()
571
580
  }
572
- return { ...payload, orgId: resolved.orgId, roles: resolved.roles }
581
+ // TODO.identity-features/09: the context membership's cone rides the
582
+ // payload — the entity gates enforce it without a store round-trip.
583
+ return { ...payload, orgId: resolved.orgId, roles: resolved.roles, cone: resolved.cone }
573
584
  }
574
585
 
575
586
  async deleteSession(token: string): Promise<void> {
@@ -1986,6 +1997,9 @@ export class D1ServerStore implements ServerStore {
1986
1997
  userId: row.user_id as string,
1987
1998
  orgId: row.org_id as string,
1988
1999
  roles,
2000
+ // The cone (TODO.identity-features/09): NULL parses to the
2001
+ // org-wide default — a pre-existing membership keeps its posture.
2002
+ cone: parseOrgMemberCone((row.cone as string | null) ?? null),
1989
2003
  state: row.state as OrgMembershipState,
1990
2004
  isPrimary: row.is_primary === 1,
1991
2005
  invitedBy: (row.invited_by as string | null) ?? null,
@@ -2109,6 +2123,16 @@ export class D1ServerStore implements ServerStore {
2109
2123
  return this.getOrgMembership(userId, orgId)
2110
2124
  }
2111
2125
 
2126
+ /** Set the membership's data cone (TODO.identity-features/09): the
2127
+ * canonical spelling, or NULL for the org-wide default. */
2128
+ async setOrgMembershipCone(userId: string, orgId: string, cone: string | null): Promise<OrgMembership | null> {
2129
+ await this.ensureMembershipSupport()
2130
+ const existing = await this.getOrgMembership(userId, orgId)
2131
+ if (!existing) return null
2132
+ await this.stmt('UPDATE org_memberships SET cone = ? WHERE user_id = ? AND org_id = ?', cone, userId, orgId).run()
2133
+ return this.getOrgMembership(userId, orgId)
2134
+ }
2135
+
2112
2136
  /** Remove the row (the declined invitation; the erasure's cleanup). */
2113
2137
  async deleteOrgMembership(userId: string, orgId: string): Promise<boolean> {
2114
2138
  await this.ensureMembershipSupport()
@@ -522,6 +522,12 @@ CREATE TABLE IF NOT EXISTS org_memberships (
522
522
  activated_at TEXT,
523
523
  disabled_at TEXT,
524
524
  disabled_by TEXT,
525
+ -- TODO.identity-features/09 wave A — the org-member DATA CONE
526
+ -- (migration 0017): NULL (org-wide, the default) | 'assigned' |
527
+ -- 'read-only' | 'assigned+read-only'; the kernel's
528
+ -- parseOrgMemberCone owns the grammar; a malformed cell fails
529
+ -- CLOSED to the narrowest cone.
530
+ cone TEXT,
525
531
  UNIQUE (user_id, org_id)
526
532
  );
527
533
  CREATE INDEX IF NOT EXISTS idx_org_memberships_org ON org_memberships (org_id, state);
@@ -107,6 +107,14 @@ function migrateAuthTables(db: Database.Database): void {
107
107
  'active', 1, COALESCE(last_login, created_at)
108
108
  FROM users WHERE org_id IS NOT NULL
109
109
  `)
110
+ // TODO.identity-features/09 (the org-member data cone): the
111
+ // membership's cone column arrives with migration 0017 — a dev file
112
+ // predating it grows the column here. NULL = org-wide: existing
113
+ // memberships keep their posture silently.
114
+ const membershipCols = db.prepare('PRAGMA table_info(org_memberships)').all() as Array<{ name: string }>
115
+ if (membershipCols.length && !membershipCols.some(c => c.name === 'cone')) {
116
+ db.exec('ALTER TABLE org_memberships ADD COLUMN cone TEXT')
117
+ }
110
118
  // TODO.identity-sso/02+03 (the strong-authentication wave): the amr
111
119
  // provenance columns on sessions → codes → access tokens.
112
120
  if (!sessionCols.some(c => c.name === 'amr')) {
@@ -781,13 +789,16 @@ export function findPendingOrgJoinRequestByEmail(email: string): OrgJoinRequest
781
789
  // AS; the columns' last writer never resurrects a disabled membership.
782
790
 
783
791
  import type { OrgMembership, OrgMembershipState } from '../../store'
784
- import { resolveOrgContext } from '../../store'
792
+ import { resolveOrgContext, parseOrgMemberCone } from '../../store'
785
793
 
786
794
  interface OrgMembershipRow {
787
795
  id: string
788
796
  user_id: string
789
797
  org_id: string
790
798
  roles: string
799
+ /** TODO.identity-features/09 — the membership's data cone (the nullable
800
+ * column; absent on a pre-migration row read). */
801
+ cone?: string | null
791
802
  state: OrgMembershipState
792
803
  is_primary: number
793
804
  invited_by: string | null
@@ -803,6 +814,9 @@ function membershipPayload(row: OrgMembershipRow): OrgMembership {
803
814
  userId: row.user_id,
804
815
  orgId: row.org_id,
805
816
  roles: parseRoles(row.roles) ?? [],
817
+ // The cone (TODO.identity-features/09): NULL parses to the org-wide
818
+ // default — a pre-existing membership keeps its posture silently.
819
+ cone: parseOrgMemberCone(row.cone),
806
820
  state: row.state,
807
821
  isPrimary: row.is_primary === 1,
808
822
  invitedBy: row.invited_by,
@@ -844,7 +858,9 @@ function applySessionOrgContext(activeOrg: string | null, payload: AuthUserPaylo
844
858
  if (activeOrg && !(active && active.state === 'active')) {
845
859
  getDb().prepare('UPDATE sessions SET active_org = NULL WHERE user_id = ? AND active_org = ?').run(payload.id, activeOrg)
846
860
  }
847
- return { ...payload, orgId: resolved.orgId, roles: resolved.roles }
861
+ // TODO.identity-features/09: the context membership's cone rides the
862
+ // payload — the entity gates enforce it without a store round-trip.
863
+ return { ...payload, orgId: resolved.orgId, roles: resolved.roles, cone: resolved.cone }
848
864
  }
849
865
 
850
866
  export function listOrgMemberships(userId: string): OrgMembership[] {
@@ -938,6 +954,17 @@ export function setOrgMembershipState(
938
954
  return getOrgMembership(userId, orgId)
939
955
  }
940
956
 
957
+ /** Set the membership's data cone (TODO.identity-features/09): the
958
+ * canonical column spelling, or NULL for the org-wide default. Answers
959
+ * null when no membership exists. */
960
+ export function setOrgMembershipCone(userId: string, orgId: string, cone: string | null): OrgMembership | null {
961
+ const db = getDb()
962
+ const existing = getOrgMembership(userId, orgId)
963
+ if (!existing) return null
964
+ db.prepare('UPDATE org_memberships SET cone = ? WHERE user_id = ? AND org_id = ?').run(cone, userId, orgId)
965
+ return getOrgMembership(userId, orgId)
966
+ }
967
+
941
968
  /** Remove the row (the holder declining an invitation; the erasure's
942
969
  * cleanup). The route refuses the PRIMARY membership. */
943
970
  export function deleteOrgMembership(userId: string, orgId: string): boolean {
@@ -67,6 +67,7 @@ import {
67
67
  setInstrumentRegistrationLifecycle,
68
68
  setOrgMembershipRoles,
69
69
  setOrgMembershipState,
70
+ setOrgMembershipCone,
70
71
  setOrgRegistryOrgState,
71
72
  setSessionActiveOrg,
72
73
  setUserActive,
@@ -412,6 +413,9 @@ export function createSqliteServerStore(): ServerStore {
412
413
  ): Promise<OrgMembership | null> {
413
414
  return setOrgMembershipState(userId, orgId, state, actor)
414
415
  },
416
+ async setOrgMembershipCone(userId: string, orgId: string, cone: string | null): Promise<OrgMembership | null> {
417
+ return setOrgMembershipCone(userId, orgId, cone)
418
+ },
415
419
  async deleteOrgMembership(userId: string, orgId: string): Promise<boolean> {
416
420
  return deleteOrgMembership(userId, orgId)
417
421
  },
package/src/store.ts CHANGED
@@ -40,6 +40,15 @@ export interface AuthUserPayload {
40
40
  * (absent = the primary role only). */
41
41
  roles?: string[]
42
42
  orgId: string | null
43
+ /** TODO.identity-features/09 — the ACTIVE org context's data cone (the
44
+ * membership's posture, resolved by the session-backed read; the OP's
45
+ * `cone` claim carries the same value). ABSENT on constructors that
46
+ * never resolve a membership (the row-backed reads); NULL = the
47
+ * context resolved no membership (an org-free account — the cone
48
+ * never applies to it). The cone only ever NARROWS: the enforcement
49
+ * reads it at the two choke points (the entity API's read/write
50
+ * gates), never as a grant. */
51
+ cone?: OrgMemberCone | null
43
52
  avatarUrl?: string
44
53
  /** TODO.identity/04: the account's sign-in provider family ('demo',
45
54
  * 'github', 'oidc', the OP's account provider) — projected from the
@@ -670,6 +679,73 @@ export interface OrgJoinRequest {
670
679
  * act, never an automatic one). */
671
680
  export type OrgMembershipState = 'invited' | 'active' | 'disabled'
672
681
 
682
+ // ── the per-member data cone (TODO.identity-features/09) ─────────────
683
+ // Every org membership carries a CONE — the org administrator's answer
684
+ // to "what can this member see and do" (the design: the org-wide default
685
+ // keeps today's behavior; 'assigned' narrows the member to the org's
686
+ // rows that NAME them; 'read-only' is the orthogonal modifier that
687
+ // refuses the member's writes). The cone lives on the membership row
688
+ // (org_memberships.cone, a nullable TEXT column — NULL is org-wide, so
689
+ // existing memberships keep their posture silently), rides the OP's
690
+ // claims in the active-org context, and is enforced at the platform's
691
+ // two choke points ONLY (the read gate and the write gate). THE
692
+ // INVARIANT: the cone only ever NARROWS — nothing in this machinery can
693
+ // grant.
694
+
695
+ /** The cone's scope: org-wide (the default — every row the org sees) or
696
+ * assigned (only the org's rows that NAME the member — the operator on
697
+ * the test run, the assignment's performer, the engagement's inquirer). */
698
+ export type OrgMemberConeScope = 'org-wide' | 'assigned'
699
+
700
+ /** The parsed cone (the payload shape every consumer reads). */
701
+ export interface OrgMemberCone {
702
+ scope: OrgMemberConeScope
703
+ /** The orthogonal modifier: the member reads per the scope but the
704
+ * write gate refuses them (a reviewer's posture). */
705
+ readOnly: boolean
706
+ }
707
+
708
+ /** The DEFAULT cone (a NULL column): org-wide, writable — today's
709
+ * behavior, kept silently by every pre-existing membership. */
710
+ export const ORG_MEMBER_CONE_DEFAULT: OrgMemberCone = { scope: 'org-wide', readOnly: false }
711
+
712
+ /** The FAIL-CLOSED cone: a stored value the parser cannot read narrows
713
+ * to the tightest posture (never a silent re-widen — the platform's
714
+ * standing doctrine: a malformed permission input never grants). */
715
+ export const ORG_MEMBER_CONE_FAIL_CLOSED: OrgMemberCone = { scope: 'assigned', readOnly: true }
716
+
717
+ /** The canonical column spellings (NULL is the default — the column
718
+ * stays NULL for org-wide+writable, keeping the expand-only posture
719
+ * clean). 'org-wide' parses but never encodes (it IS the default). */
720
+ export function encodeOrgMemberCone(cone: OrgMemberCone): string | null {
721
+ if (cone.scope === 'assigned') return cone.readOnly ? 'assigned+read-only' : 'assigned'
722
+ return cone.readOnly ? 'read-only' : null
723
+ }
724
+
725
+ /** Parse the stored cone (total, never throws): NULL/empty/'org-wide'
726
+ * answer the default; a recognized composition parses; ANYTHING else
727
+ * fails CLOSED (the narrowest cone) — a hand-edited or corrupt row
728
+ * narrows the member, it never widens them. */
729
+ export function parseOrgMemberCone(raw: string | null | undefined): OrgMemberCone {
730
+ if (raw === null || raw === undefined) return { ...ORG_MEMBER_CONE_DEFAULT }
731
+ const tokens = raw.split('+').map(t => t.trim()).filter(Boolean)
732
+ if (tokens.length === 0) return { ...ORG_MEMBER_CONE_DEFAULT }
733
+ let scopeSeen: OrgMemberConeScope | null = null
734
+ let readOnly = false
735
+ for (const token of tokens) {
736
+ if (token === 'read-only') {
737
+ if (readOnly) return { ...ORG_MEMBER_CONE_FAIL_CLOSED } // a doubled modifier is not a spelling
738
+ readOnly = true
739
+ } else if (token === 'org-wide' || token === 'assigned') {
740
+ if (scopeSeen !== null) return { ...ORG_MEMBER_CONE_FAIL_CLOSED } // two scopes is not a spelling
741
+ scopeSeen = token
742
+ } else {
743
+ return { ...ORG_MEMBER_CONE_FAIL_CLOSED } // an unknown token
744
+ }
745
+ }
746
+ return { scope: scopeSeen ?? 'org-wide', readOnly }
747
+ }
748
+
673
749
  /** An account's membership in ONE organization (the org_memberships
674
750
  * row): the per-org role set + the lifecycle state. The PRIMARY
675
751
  * membership (isPrimary) is the backward-compatible one: the users
@@ -684,6 +760,10 @@ export interface OrgMembership {
684
760
  /** The PER-ORG role set (JSON on the row): the roles the account holds
685
761
  * when acting AS this org. */
686
762
  roles: string[]
763
+ /** The membership's data cone (TODO.identity-features/09), parsed from
764
+ * the row's nullable column — the DEFAULT object when the column is
765
+ * NULL (org-wide, writable); never null on the payload. */
766
+ cone: OrgMemberCone
687
767
  state: OrgMembershipState
688
768
  isPrimary: boolean
689
769
  invitedBy: string | null
@@ -693,13 +773,16 @@ export interface OrgMembership {
693
773
  disabledBy: string | null
694
774
  }
695
775
 
696
- /** The effective org context: the org the account acts AS and the role
697
- * set that context carries. The session payloads and the OP's token
698
- * claims both resolve through resolveOrgContext, so the two never
699
- * drift. */
776
+ /** The effective org context: the org the account acts AS, the role
777
+ * set that context carries, and the context membership's data cone
778
+ * (TODO.identity-features/09 NULL when NO membership row resolved:
779
+ * the pre-memberships dual-read and the org-free account carry no
780
+ * cone). The session payloads and the OP's token claims both resolve
781
+ * through resolveOrgContext, so the two never drift. */
700
782
  export interface OrgContextResolution {
701
783
  orgId: string | null
702
784
  roles: string[]
785
+ cone: OrgMemberCone | null
703
786
  }
704
787
 
705
788
  /**
@@ -733,14 +816,16 @@ export function resolveOrgContext(
733
816
  const m = context.active
734
817
  if (m && m.orgId === context.activeOrg && m.state === 'active') {
735
818
  const global = user.orgId ? [] : accountRoles
736
- return { orgId: context.activeOrg, roles: [...new Set([...m.roles, ...global])] }
819
+ return { orgId: context.activeOrg, roles: [...new Set([...m.roles, ...global])], cone: m.cone }
737
820
  }
738
821
  }
739
- if (!user.orgId) return { orgId: null, roles: accountRoles }
822
+ if (!user.orgId) return { orgId: null, roles: accountRoles, cone: null }
740
823
  const p = context.primary
741
- if (!p) return { orgId: user.orgId, roles: accountRoles }
742
- if (p.state !== 'active') return { orgId: null, roles: [] }
743
- return { orgId: user.orgId, roles: [...new Set([...accountRoles, ...p.roles])] }
824
+ // The pre-memberships dual-read (no primary row) carries NO cone the
825
+ // org-wide default the enforcement reads for a cone-less context.
826
+ if (!p) return { orgId: user.orgId, roles: accountRoles, cone: null }
827
+ if (p.state !== 'active') return { orgId: null, roles: [], cone: null }
828
+ return { orgId: user.orgId, roles: [...new Set([...accountRoles, ...p.roles])], cone: p.cone }
744
829
  }
745
830
 
746
831
  // ── the organization registry (TODO.identity-features/05) ────────────
@@ -1539,6 +1624,13 @@ export interface ServerStore {
1539
1624
  state: OrgMembershipState,
1540
1625
  actor?: string | null,
1541
1626
  ): Promise<OrgMembership | null>
1627
+ /** Set the membership's data cone (TODO.identity-features/09): the
1628
+ * CANONICAL column spelling (encodeOrgMemberCone's answer — NULL is
1629
+ * the org-wide default) or NULL to clear. The input's validation is
1630
+ * the ROUTE's (parse + re-encode; the store writes what it is given,
1631
+ * the parser's fail-closed posture backstops a bad cell). Answers
1632
+ * null when no membership exists. */
1633
+ setOrgMembershipCone(userId: string, orgId: string, cone: string | null): Promise<OrgMembership | null>
1542
1634
  /** Remove the row — the holder declining an invitation, and the
1543
1635
  * erasure's cleanup. (The routes refuse the PRIMARY membership: the
1544
1636
  * primary binding moves through the account's role/org assignment,