@oimlsmart/platform-server 0.1.1 → 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.
- package/package.json +1 -1
- package/src/store/d1.ts +25 -1
- package/src/store/sqlite/store.ts +29 -2
- package/src/store/sqlite.ts +4 -0
- package/src/store.ts +101 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oimlsmart/platform-server",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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()
|
|
@@ -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
|
-
|
|
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 {
|
package/src/store/sqlite.ts
CHANGED
|
@@ -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
|
|
697
|
-
* set that context carries
|
|
698
|
-
*
|
|
699
|
-
*
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
return { orgId: user.orgId, 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,
|