@oimlsmart/platform-server 0.1.0

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.
Files changed (48) hide show
  1. package/README.md +92 -0
  2. package/migrations/0001_init.sql +69 -0
  3. package/migrations/0002_identity.sql +24 -0
  4. package/migrations/0003_federation_peers.sql +21 -0
  5. package/migrations/0003_users_rbac.sql +8 -0
  6. package/migrations/0004_oidc_op.sql +61 -0
  7. package/migrations/0005_upstream_providers.sql +34 -0
  8. package/migrations/0006_op_accounts.sql +28 -0
  9. package/migrations/0007_org_join_requests.sql +26 -0
  10. package/migrations/0008_op_client_roles.sql +19 -0
  11. package/migrations/0009_account_console.sql +32 -0
  12. package/migrations/0009_sso_states.sql +13 -0
  13. package/migrations/0010_notify_events.sql +23 -0
  14. package/migrations/0011_op_launch.sql +18 -0
  15. package/migrations/0011_org_memberships.sql +62 -0
  16. package/migrations/0012_notify_subscriptions.sql +57 -0
  17. package/migrations/0012_strong_auth.sql +109 -0
  18. package/migrations/0013_org_registry.sql +53 -0
  19. package/migrations/0014_notify_inbox.sql +34 -0
  20. package/migrations/0015_certificate_holder_attribution.sql +63 -0
  21. package/migrations/0016_instrument_registrations.sql +78 -0
  22. package/package.json +52 -0
  23. package/src/client-info.ts +25 -0
  24. package/src/context.ts +31 -0
  25. package/src/github.ts +284 -0
  26. package/src/mailer.ts +309 -0
  27. package/src/oidc.ts +369 -0
  28. package/src/profile/node.ts +83 -0
  29. package/src/profile.ts +582 -0
  30. package/src/rbac/node.ts +42 -0
  31. package/src/rbac.ts +53 -0
  32. package/src/session.ts +45 -0
  33. package/src/store/d1.ts +2850 -0
  34. package/src/store/sqlite/entities.ts +71 -0
  35. package/src/store/sqlite/events.ts +82 -0
  36. package/src/store/sqlite/factors-store.ts +348 -0
  37. package/src/store/sqlite/notify.ts +247 -0
  38. package/src/store/sqlite/op-accounts-store.ts +470 -0
  39. package/src/store/sqlite/op-store.ts +280 -0
  40. package/src/store/sqlite/schema.sql +745 -0
  41. package/src/store/sqlite/store.ts +1390 -0
  42. package/src/store/sqlite/upstream-store.ts +148 -0
  43. package/src/store/sqlite.ts +1027 -0
  44. package/src/store.ts +1826 -0
  45. package/src/vocab/index.ts +12 -0
  46. package/src/vocab/permissions.ts +398 -0
  47. package/src/vocab/rbac.ts +281 -0
  48. package/src/vocab/roles.ts +162 -0
@@ -0,0 +1,280 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The OIDC Provider's SQLite half (TODO.identity/01) — the sync
3
+ // implementations behind the ServerStore OP methods
4
+ // (sqlite-server-store.ts delegates here one-for-one, mirroring
5
+ // store.ts's role for the auth domain).
6
+ //
7
+ // NODE-ONLY: better-sqlite3 through ./store's getDb. The Worker bundle
8
+ // never sees this module (the D1 store implements the same surface in
9
+ // d1-store.ts).
10
+ // ═══════════════════════════════════════════════════════════════════
11
+
12
+ import { getDb } from './store'
13
+ import type {
14
+ OidcAccessToken,
15
+ OidcAuthorization,
16
+ OidcClient,
17
+ OidcClientLaunch,
18
+ OidcCode,
19
+ OidcKeyRow,
20
+ } from '../../store'
21
+
22
+ function toOidcClient(row: Record<string, unknown>): OidcClient {
23
+ return {
24
+ clientId: row.client_id as string,
25
+ name: row.name as string,
26
+ secretHash: (row.secret_hash as string | null) ?? null,
27
+ redirectUris: JSON.parse(row.redirect_uris as string) as string[],
28
+ claimsPolicy: row.claims_policy ? JSON.parse(row.claims_policy as string) as OidcClient['claimsPolicy'] : null,
29
+ // The SSO-home launch metadata (migration 0011): no launch_url = the
30
+ // client never appears on the launcher. A pre-0011 database reads
31
+ // the columns as absent — launch stays null, the honest default.
32
+ launch: row.launch_url
33
+ ? {
34
+ url: row.launch_url as string,
35
+ icon: (row.launch_icon as string | null) ?? null,
36
+ description: (row.launch_description as string | null) ?? null,
37
+ visibility: ((row.launch_visibility as string | null) ?? 'roles') as OidcClientLaunch['visibility'],
38
+ }
39
+ : null,
40
+ status: row.status as OidcClient['status'],
41
+ createdAt: row.created_at as string,
42
+ createdBy: (row.created_by as string | null) ?? null,
43
+ }
44
+ }
45
+
46
+ function toOidcAuthorization(row: Record<string, unknown>): OidcAuthorization {
47
+ return {
48
+ id: row.id as string,
49
+ clientId: row.client_id as string,
50
+ redirectUri: row.redirect_uri as string,
51
+ scope: row.scope as string,
52
+ state: row.state as string,
53
+ nonce: (row.nonce as string | null) ?? null,
54
+ codeChallenge: row.code_challenge as string,
55
+ userId: (row.user_id as string | null) ?? null,
56
+ decision: (row.decision as OidcAuthorization['decision']) ?? null,
57
+ createdAt: row.created_at as string,
58
+ expiresAt: row.expires_at as string,
59
+ }
60
+ }
61
+
62
+ export function getOidcClient(clientId: string): OidcClient | null {
63
+ const row = getDb().prepare('SELECT * FROM oidc_clients WHERE client_id = ?').get(clientId) as Record<string, unknown> | undefined
64
+ return row ? toOidcClient(row) : null
65
+ }
66
+
67
+ export function listOidcClients(): OidcClient[] {
68
+ const rows = getDb().prepare('SELECT * FROM oidc_clients ORDER BY created_at, client_id').all() as Array<Record<string, unknown>>
69
+ return rows.map(toOidcClient)
70
+ }
71
+
72
+ export function upsertOidcClient(input: {
73
+ clientId: string
74
+ name: string
75
+ secretHash: string | null
76
+ redirectUris: string[]
77
+ claimsPolicy: { claims: string[] } | null
78
+ createdBy?: string | null
79
+ }): OidcClient {
80
+ getDb().prepare(`
81
+ INSERT INTO oidc_clients (client_id, name, secret_hash, redirect_uris, claims_policy, created_by)
82
+ VALUES (?, ?, ?, ?, ?, ?)
83
+ ON CONFLICT (client_id) DO UPDATE SET
84
+ name = excluded.name,
85
+ secret_hash = excluded.secret_hash,
86
+ redirect_uris = excluded.redirect_uris,
87
+ claims_policy = excluded.claims_policy
88
+ `).run(
89
+ input.clientId,
90
+ input.name,
91
+ input.secretHash,
92
+ JSON.stringify(input.redirectUris),
93
+ input.claimsPolicy ? JSON.stringify(input.claimsPolicy) : null,
94
+ input.createdBy ?? null,
95
+ )
96
+ return getOidcClient(input.clientId)!
97
+ }
98
+
99
+ export function setOidcClientStatus(clientId: string, status: OidcClient['status']): OidcClient | null {
100
+ const res = getDb().prepare('UPDATE oidc_clients SET status = ? WHERE client_id = ?').run(status, clientId)
101
+ return res.changes > 0 ? getOidcClient(clientId) : null
102
+ }
103
+
104
+ /** The SSO-home launch metadata write (migration 0011): the launcher's
105
+ * card, or null to take the client off it. The registry upsert above
106
+ * never touches these columns, so a re-seed keeps the admin's edits. */
107
+ export function setOidcClientLaunch(clientId: string, launch: OidcClientLaunch | null): OidcClient | null {
108
+ const res = getDb().prepare(`
109
+ UPDATE oidc_clients SET launch_url = ?, launch_icon = ?, launch_description = ?, launch_visibility = ?
110
+ WHERE client_id = ?
111
+ `).run(
112
+ launch?.url ?? null,
113
+ launch?.icon ?? null,
114
+ launch?.description ?? null,
115
+ launch?.visibility ?? 'roles',
116
+ clientId,
117
+ )
118
+ return res.changes > 0 ? getOidcClient(clientId) : null
119
+ }
120
+
121
+ export function createOidcAuthorization(input: {
122
+ id: string
123
+ clientId: string
124
+ redirectUri: string
125
+ scope: string
126
+ state: string
127
+ nonce: string | null
128
+ codeChallenge: string
129
+ userId: string | null
130
+ ttlMs: number
131
+ }): OidcAuthorization {
132
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
133
+ getDb().prepare(`
134
+ INSERT INTO oidc_authorizations
135
+ (id, client_id, redirect_uri, scope, state, nonce, code_challenge, user_id, expires_at)
136
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
137
+ `).run(
138
+ input.id, input.clientId, input.redirectUri, input.scope, input.state,
139
+ input.nonce, input.codeChallenge, input.userId, expiresAt,
140
+ )
141
+ return getOidcAuthorization(input.id)!
142
+ }
143
+
144
+ export function getOidcAuthorization(id: string): OidcAuthorization | null {
145
+ const row = getDb().prepare('SELECT * FROM oidc_authorizations WHERE id = ?').get(id) as Record<string, unknown> | undefined
146
+ return row ? toOidcAuthorization(row) : null
147
+ }
148
+
149
+ export function decideOidcAuthorization(
150
+ id: string,
151
+ decision: { userId: string; decision: 'allow' | 'deny' },
152
+ ): OidcAuthorization | null {
153
+ // The decision binds to the row's OWN account (userId must equal the
154
+ // row's stamped user) and flips atomically — a decided or
155
+ // cross-account row loses the race.
156
+ const res = getDb().prepare(
157
+ 'UPDATE oidc_authorizations SET decision = ? WHERE id = ? AND decision IS NULL AND user_id = ?',
158
+ ).run(decision.decision, id, decision.userId)
159
+ return res.changes > 0 ? getOidcAuthorization(id) : null
160
+ }
161
+
162
+ export function createOidcCode(input: {
163
+ code: string
164
+ clientId: string
165
+ redirectUri: string
166
+ scope: string
167
+ nonce: string | null
168
+ codeChallenge: string
169
+ userId: string
170
+ /** TODO.identity/11: the session's stamped active-org context at the
171
+ * consent decision (NULL = the primary context). */
172
+ contextOrg?: string | null
173
+ /** TODO.identity-sso/02+03: the consenting session's amr provenance
174
+ * (stored as JSON; the token endpoint emits it as the ID token's
175
+ * amr). Absent = no provenance recorded. */
176
+ amr?: string[] | null
177
+ ttlMs: number
178
+ }): void {
179
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
180
+ getDb().prepare(`
181
+ INSERT INTO oidc_codes (code, client_id, redirect_uri, scope, nonce, code_challenge, user_id, context_org, amr, expires_at)
182
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
183
+ `).run(input.code, input.clientId, input.redirectUri, input.scope, input.nonce, input.codeChallenge, input.userId,
184
+ input.contextOrg ?? null, input.amr?.length ? JSON.stringify(input.amr) : null, expiresAt)
185
+ }
186
+
187
+ /** Atomically consume the code: the UPDATE flips consumed_at exactly
188
+ * once — a replay loses the race and answers null (→ invalid_grant).
189
+ * An expired code is consumed too (never a second chance). */
190
+ export function consumeOidcCode(code: string): OidcCode | null {
191
+ const db = getDb()
192
+ const res = db.prepare("UPDATE oidc_codes SET consumed_at = datetime('now') WHERE code = ? AND consumed_at IS NULL").run(code)
193
+ if (res.changes === 0) return null
194
+ const row = db.prepare('SELECT * FROM oidc_codes WHERE code = ?').get(code) as Record<string, unknown> | undefined
195
+ if (!row) return null
196
+ if (new Date(row.expires_at as string).getTime() <= Date.now()) return null
197
+ return {
198
+ code: row.code as string,
199
+ clientId: row.client_id as string,
200
+ redirectUri: row.redirect_uri as string,
201
+ scope: row.scope as string,
202
+ nonce: (row.nonce as string | null) ?? null,
203
+ codeChallenge: row.code_challenge as string,
204
+ userId: row.user_id as string,
205
+ contextOrg: (row.context_org as string | null) ?? null,
206
+ amr: parseJsonStringList(row.amr),
207
+ expiresAt: row.expires_at as string,
208
+ }
209
+ }
210
+
211
+ export function createOidcAccessToken(input: {
212
+ token: string
213
+ userId: string
214
+ clientId: string
215
+ scope: string
216
+ /** The granting code's context (userinfo answers the ID token's
217
+ * claims). */
218
+ contextOrg?: string | null
219
+ /** TODO.identity-sso/02+03: the authorizing authentication's amr —
220
+ * userinfo answers the same truth the ID token carried. */
221
+ amr?: string[] | null
222
+ ttlMs: number
223
+ }): void {
224
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
225
+ getDb().prepare(
226
+ 'INSERT INTO oidc_access_tokens (token, user_id, client_id, scope, context_org, amr, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
227
+ ).run(input.token, input.userId, input.clientId, input.scope, input.contextOrg ?? null,
228
+ input.amr?.length ? JSON.stringify(input.amr) : null, expiresAt)
229
+ }
230
+
231
+ export function getOidcAccessToken(token: string): OidcAccessToken | null {
232
+ const row = getDb().prepare(
233
+ "SELECT * FROM oidc_access_tokens WHERE token = ? AND expires_at > datetime('now')",
234
+ ).get(token) as Record<string, unknown> | undefined
235
+ if (!row) return null
236
+ return {
237
+ token: row.token as string,
238
+ userId: row.user_id as string,
239
+ clientId: row.client_id as string,
240
+ scope: row.scope as string,
241
+ contextOrg: (row.context_org as string | null) ?? null,
242
+ amr: parseJsonStringList(row.amr),
243
+ expiresAt: row.expires_at as string,
244
+ }
245
+ }
246
+
247
+ /** The amr column's honest parse (a JSON array of strings, else null —
248
+ * the provenance is absent on rows that predate the wave). */
249
+ function parseJsonStringList(raw: unknown): string[] | null {
250
+ if (typeof raw !== 'string' || !raw) return null
251
+ try {
252
+ const parsed = JSON.parse(raw) as unknown
253
+ if (!Array.isArray(parsed)) return null
254
+ const list = parsed.filter((v): v is string => typeof v === 'string')
255
+ return list.length ? list : null
256
+ } catch {
257
+ return null
258
+ }
259
+ }
260
+
261
+ export function listOidcKeys(): OidcKeyRow[] {
262
+ const rows = getDb().prepare('SELECT * FROM oidc_keys ORDER BY created_at, kid').all() as Array<Record<string, unknown>>
263
+ return rows.map(row => ({
264
+ kid: row.kid as string,
265
+ publicJwk: row.public_jwk as string,
266
+ status: row.status as OidcKeyRow['status'],
267
+ createdAt: row.created_at as string,
268
+ retiredAt: (row.retired_at as string | null) ?? null,
269
+ }))
270
+ }
271
+
272
+ export function upsertOidcKey(input: { kid: string; publicJwk: string }): void {
273
+ getDb().prepare(
274
+ 'INSERT OR IGNORE INTO oidc_keys (kid, public_jwk) VALUES (?, ?)',
275
+ ).run(input.kid, input.publicJwk)
276
+ }
277
+
278
+ export function retireOidcKey(kid: string): void {
279
+ getDb().prepare("UPDATE oidc_keys SET status = 'retired', retired_at = datetime('now') WHERE kid = ? AND status = 'active'").run(kid)
280
+ }