@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.
- package/README.md +92 -0
- package/migrations/0001_init.sql +69 -0
- package/migrations/0002_identity.sql +24 -0
- package/migrations/0003_federation_peers.sql +21 -0
- package/migrations/0003_users_rbac.sql +8 -0
- package/migrations/0004_oidc_op.sql +61 -0
- package/migrations/0005_upstream_providers.sql +34 -0
- package/migrations/0006_op_accounts.sql +28 -0
- package/migrations/0007_org_join_requests.sql +26 -0
- package/migrations/0008_op_client_roles.sql +19 -0
- package/migrations/0009_account_console.sql +32 -0
- package/migrations/0009_sso_states.sql +13 -0
- package/migrations/0010_notify_events.sql +23 -0
- package/migrations/0011_op_launch.sql +18 -0
- package/migrations/0011_org_memberships.sql +62 -0
- package/migrations/0012_notify_subscriptions.sql +57 -0
- package/migrations/0012_strong_auth.sql +109 -0
- package/migrations/0013_org_registry.sql +53 -0
- package/migrations/0014_notify_inbox.sql +34 -0
- package/migrations/0015_certificate_holder_attribution.sql +63 -0
- package/migrations/0016_instrument_registrations.sql +78 -0
- package/package.json +52 -0
- package/src/client-info.ts +25 -0
- package/src/context.ts +31 -0
- package/src/github.ts +284 -0
- package/src/mailer.ts +309 -0
- package/src/oidc.ts +369 -0
- package/src/profile/node.ts +83 -0
- package/src/profile.ts +582 -0
- package/src/rbac/node.ts +42 -0
- package/src/rbac.ts +53 -0
- package/src/session.ts +45 -0
- package/src/store/d1.ts +2850 -0
- package/src/store/sqlite/entities.ts +71 -0
- package/src/store/sqlite/events.ts +82 -0
- package/src/store/sqlite/factors-store.ts +348 -0
- package/src/store/sqlite/notify.ts +247 -0
- package/src/store/sqlite/op-accounts-store.ts +470 -0
- package/src/store/sqlite/op-store.ts +280 -0
- package/src/store/sqlite/schema.sql +745 -0
- package/src/store/sqlite/store.ts +1390 -0
- package/src/store/sqlite/upstream-store.ts +148 -0
- package/src/store/sqlite.ts +1027 -0
- package/src/store.ts +1826 -0
- package/src/vocab/index.ts +12 -0
- package/src/vocab/permissions.ts +398 -0
- package/src/vocab/rbac.ts +281 -0
- package/src/vocab/roles.ts +162 -0
package/src/store/d1.ts
ADDED
|
@@ -0,0 +1,2850 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2
|
+
// The D1 ServerStore (TODO.cs-e2e/14 — the Cloudflare deployment):
|
|
3
|
+
// the SAME surface as the SQLite store (server/db/store.ts +
|
|
4
|
+
// entities.ts) against a Cloudflare D1 binding — D1 IS SQLite, so the
|
|
5
|
+
// schema (schema.sql, and the canonical migration set under
|
|
6
|
+
// migrations/) and every statement port directly; only the driver
|
|
7
|
+
// calls change (prepare/bind/run, async).
|
|
8
|
+
//
|
|
9
|
+
// WORKER-SAFE: the only import is the D1 TYPE (erased at build) — no
|
|
10
|
+
// node built-ins, no better-sqlite3. The binding arrives from the
|
|
11
|
+
// Worker entry (server/cloudflare.ts), which installs this store when
|
|
12
|
+
// env.DB is present — the binding-presence profile switch, the same
|
|
13
|
+
// pattern ENTITY_BACKEND uses client-side.
|
|
14
|
+
//
|
|
15
|
+
// Local proof: the smart monorepo's browser/src/__tests__/
|
|
16
|
+
// d1-store.test.ts runs this class against a better-sqlite3-backed D1
|
|
17
|
+
// facade (the binding contract with real semantics) plus the real
|
|
18
|
+
// workerd binding through scripts/cloudflare-smoke.ts there.
|
|
19
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
20
|
+
|
|
21
|
+
import type { D1Database, D1PreparedStatement } from '@cloudflare/workers-types'
|
|
22
|
+
import {
|
|
23
|
+
DEMO_PASSWORD,
|
|
24
|
+
type AdvanceCounterResult,
|
|
25
|
+
type AuthUserPayload,
|
|
26
|
+
type CompleteEmailChangeResult,
|
|
27
|
+
type CompleteEnrollmentResult,
|
|
28
|
+
type EmailChangeToken,
|
|
29
|
+
type EnrollmentToken,
|
|
30
|
+
type EntityChange,
|
|
31
|
+
type EntityRow,
|
|
32
|
+
type EventKeyFilter,
|
|
33
|
+
type FederationPeer,
|
|
34
|
+
type IdentityApproval,
|
|
35
|
+
type IdentityLink,
|
|
36
|
+
type IdentityProvider,
|
|
37
|
+
type MfaPending,
|
|
38
|
+
type NotifyEntityMute,
|
|
39
|
+
type NotifyInboxState,
|
|
40
|
+
type NotifyPreferences,
|
|
41
|
+
type NotifyRule,
|
|
42
|
+
type OAuthInitialAssignment,
|
|
43
|
+
type OidcAccessToken,
|
|
44
|
+
type OidcAuthorization,
|
|
45
|
+
type OidcClient,
|
|
46
|
+
type OidcClientLaunch,
|
|
47
|
+
type OidcCode,
|
|
48
|
+
type OidcKeyRow,
|
|
49
|
+
type OpAccountErasure,
|
|
50
|
+
type OpClientRoleAssignment,
|
|
51
|
+
type OpLiveSession,
|
|
52
|
+
type OrgJoinRequest,
|
|
53
|
+
type OrgMembership,
|
|
54
|
+
type OrgMembershipState,
|
|
55
|
+
type CertificateHolderClaim,
|
|
56
|
+
type CertificateHolderOrg,
|
|
57
|
+
type OrgRegistryContact,
|
|
58
|
+
type OrgRegistryOrg,
|
|
59
|
+
type OrgRegistryState,
|
|
60
|
+
type InstrumentRegistration,
|
|
61
|
+
type InstrumentRegistrationLifecycle,
|
|
62
|
+
type InstrumentRegistrationScopeStatus,
|
|
63
|
+
type PlatformEvent,
|
|
64
|
+
resolveOrgContext,
|
|
65
|
+
type RecoveryCodeState,
|
|
66
|
+
type ServerStore,
|
|
67
|
+
type SessionView,
|
|
68
|
+
type SsoSignInState,
|
|
69
|
+
type TotpSecret,
|
|
70
|
+
type UserAdminRow,
|
|
71
|
+
type WebauthnChallenge,
|
|
72
|
+
type WebauthnCredential,
|
|
73
|
+
} from '../store'
|
|
74
|
+
// TODO.federation/01 — the account plan follows the deployment profile
|
|
75
|
+
// (the Worker's seed route installs it from the env binding first; the
|
|
76
|
+
// slot defaults to the hub profile, which is exactly DEMO_ACCOUNTS).
|
|
77
|
+
import { getInstanceProfile, seedAccountsForProfile } from '../profile'
|
|
78
|
+
|
|
79
|
+
interface UserRecord {
|
|
80
|
+
id: string
|
|
81
|
+
email: string
|
|
82
|
+
name: string
|
|
83
|
+
role: string
|
|
84
|
+
org_id: string | null
|
|
85
|
+
avatar_url: string | null
|
|
86
|
+
roles: string | null
|
|
87
|
+
active: number
|
|
88
|
+
provider: string
|
|
89
|
+
email_verified_at?: string | null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseRoles(raw: string | null): string[] | undefined {
|
|
93
|
+
if (!raw) return undefined
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(raw) as unknown
|
|
96
|
+
return Array.isArray(parsed) && parsed.length ? parsed.filter((v): v is string => typeof v === 'string') : undefined
|
|
97
|
+
} catch {
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function toPayload(user: UserRecord, avatarUrl?: string): AuthUserPayload {
|
|
103
|
+
const roles = parseRoles(user.roles)
|
|
104
|
+
return {
|
|
105
|
+
id: user.id,
|
|
106
|
+
email: user.email,
|
|
107
|
+
name: user.name,
|
|
108
|
+
role: user.role,
|
|
109
|
+
...(roles?.length ? { roles } : {}),
|
|
110
|
+
orgId: user.org_id ?? null,
|
|
111
|
+
avatarUrl: avatarUrl ?? user.avatar_url ?? undefined,
|
|
112
|
+
provider: user.provider,
|
|
113
|
+
// TODO.identity/06: the primary address's verification state.
|
|
114
|
+
emailVerifiedAt: user.email_verified_at ?? null,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The identity_approvals row → the seam's camelCase shape. */
|
|
119
|
+
function toIdentityApproval(row: Record<string, unknown>): IdentityApproval {
|
|
120
|
+
return {
|
|
121
|
+
id: row.id as string,
|
|
122
|
+
email: row.email as string,
|
|
123
|
+
name: row.name as string,
|
|
124
|
+
issuer: row.issuer as string,
|
|
125
|
+
sub: row.sub as string,
|
|
126
|
+
claimsJson: (row.claims_json as string | null) ?? null,
|
|
127
|
+
status: row.status as IdentityApproval['status'],
|
|
128
|
+
decidedRole: (row.decided_role as string | null) ?? null,
|
|
129
|
+
decidedOrg: (row.decided_org as string | null) ?? null,
|
|
130
|
+
decidedBy: (row.decided_by as string | null) ?? null,
|
|
131
|
+
createdAt: row.created_at as string,
|
|
132
|
+
lastSeen: (row.last_seen as string | null) ?? (row.created_at as string),
|
|
133
|
+
decidedAt: (row.decided_at as string | null) ?? null,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The federation_peers row → the seam's camelCase shape
|
|
138
|
+
* (TODO.federation/04). */
|
|
139
|
+
function toFederationPeer(row: Record<string, unknown>): FederationPeer {
|
|
140
|
+
return {
|
|
141
|
+
id: row.id as string,
|
|
142
|
+
name: row.name as string,
|
|
143
|
+
roles: row.roles as string,
|
|
144
|
+
descriptorUrl: (row.descriptor_url as string | null) ?? null,
|
|
145
|
+
descriptorJson: row.descriptor_json as string,
|
|
146
|
+
pinnedVia: row.pinned_via as FederationPeer['pinnedVia'],
|
|
147
|
+
connectivity: row.connectivity as FederationPeer['connectivity'],
|
|
148
|
+
status: row.status as FederationPeer['status'],
|
|
149
|
+
addedAt: row.added_at as string,
|
|
150
|
+
addedBy: (row.added_by as string | null) ?? null,
|
|
151
|
+
refreshedAt: (row.refreshed_at as string | null) ?? null,
|
|
152
|
+
revokedAt: (row.revoked_at as string | null) ?? null,
|
|
153
|
+
revokedBy: (row.revoked_by as string | null) ?? null,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function toAdminRow(user: UserRecord & { last_login?: string | null; provider?: string }): UserAdminRow {
|
|
158
|
+
return {
|
|
159
|
+
id: user.id,
|
|
160
|
+
email: user.email,
|
|
161
|
+
name: user.name,
|
|
162
|
+
role: user.role,
|
|
163
|
+
roles: parseRoles(user.roles) ?? [user.role],
|
|
164
|
+
orgId: user.org_id ?? null,
|
|
165
|
+
active: user.active !== 0,
|
|
166
|
+
provider: user.provider ?? 'demo',
|
|
167
|
+
lastLogin: user.last_login ?? null,
|
|
168
|
+
emailVerifiedAt: user.email_verified_at ?? null,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The workflow tables the reset wipe covers, in a fixed order. Ranged
|
|
173
|
+
* rounds address rows by rowid (stable under deletion, so a resumed
|
|
174
|
+
* round never re-scans); every table here is an ordinary rowid table
|
|
175
|
+
* (none WITHOUT ROWID — schema.sql). The TODO.notify/01 event store
|
|
176
|
+
* wipes with them — its rows reference the workflow entities a reset
|
|
177
|
+
* removes (the feed's read-time visibility gate would drop the orphans
|
|
178
|
+
* anyway; wiping keeps the demo honest). */
|
|
179
|
+
const WIPE_TABLES = ['entity_changes', 'evidence_records', 'entities', 'events', 'instrument_registrations'] as const
|
|
180
|
+
|
|
181
|
+
export class D1ServerStore implements ServerStore {
|
|
182
|
+
constructor(private readonly db: D1Database) {}
|
|
183
|
+
|
|
184
|
+
private stmt(sql: string, ...params: unknown[]): D1PreparedStatement {
|
|
185
|
+
return this.db.prepare(sql).bind(...params)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// TODO.federation/12: the roles/active columns arrive with migration
|
|
189
|
+
// 0002 — a dev D1 migrated from the pre-RBAC 0001 lacks them, so the
|
|
190
|
+
// user methods ensure them defensively (PRAGMA probe + ALTER),
|
|
191
|
+
// memoized per store.
|
|
192
|
+
private usersColumnsEnsured: Promise<void> | null = null
|
|
193
|
+
|
|
194
|
+
private ensureUserColumns(): Promise<void> {
|
|
195
|
+
if (!this.usersColumnsEnsured) {
|
|
196
|
+
this.usersColumnsEnsured = (async () => {
|
|
197
|
+
const cols = await this.db.prepare('PRAGMA table_info(users)').all<{ name: string }>()
|
|
198
|
+
const names = new Set(cols.results.map(c => c.name))
|
|
199
|
+
if (!names.has('roles')) await this.db.prepare('ALTER TABLE users ADD COLUMN roles TEXT').run()
|
|
200
|
+
if (!names.has('active')) await this.db.prepare('ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1').run()
|
|
201
|
+
// TODO.identity/06 (the account console): the address's
|
|
202
|
+
// verification state.
|
|
203
|
+
if (!names.has('email_verified_at')) await this.db.prepare('ALTER TABLE users ADD COLUMN email_verified_at TEXT').run()
|
|
204
|
+
})()
|
|
205
|
+
}
|
|
206
|
+
return this.usersColumnsEnsured
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// TODO.identity/06 (the account console's sessions section): the
|
|
210
|
+
// sign-in context columns arrive with migration 0009 — a dev D1
|
|
211
|
+
// migrated from before it lacks them, so the session methods ensure
|
|
212
|
+
// them defensively (the ensureUserColumns posture, memoized per store).
|
|
213
|
+
private sessionColumnsEnsured: Promise<void> | null = null
|
|
214
|
+
|
|
215
|
+
private ensureSessionColumns(): Promise<void> {
|
|
216
|
+
if (!this.sessionColumnsEnsured) {
|
|
217
|
+
this.sessionColumnsEnsured = (async () => {
|
|
218
|
+
const cols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
|
|
219
|
+
const names = new Set(cols.results.map(c => c.name))
|
|
220
|
+
if (!names.has('user_agent')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN user_agent TEXT').run()
|
|
221
|
+
if (!names.has('ip')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN ip TEXT').run()
|
|
222
|
+
if (!names.has('last_seen_at')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN last_seen_at TEXT').run()
|
|
223
|
+
// TODO.identity-sso/02+03: the sign-in provenance.
|
|
224
|
+
if (!names.has('amr')) await this.db.prepare('ALTER TABLE sessions ADD COLUMN amr TEXT').run()
|
|
225
|
+
})()
|
|
226
|
+
}
|
|
227
|
+
return this.sessionColumnsEnsured
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// TODO.identity/11 (the multi-org membership model): the
|
|
231
|
+
// org_memberships table, the session's active-org stamp and the
|
|
232
|
+
// token-flow context columns arrive with migration 0011 — a dev D1
|
|
233
|
+
// migrated from before it lacks them, so the membership/session/token
|
|
234
|
+
// methods ensure them defensively (the ensureUserColumns posture,
|
|
235
|
+
// memoized per store). The backfill (the migration's twin) is
|
|
236
|
+
// IDEMPOTENT and rides the ensure: every org-bound account's primary
|
|
237
|
+
// membership mirrors the legacy columns.
|
|
238
|
+
private membershipSupportEnsured: Promise<void> | null = null
|
|
239
|
+
|
|
240
|
+
private ensureMembershipSupport(): Promise<void> {
|
|
241
|
+
if (!this.membershipSupportEnsured) {
|
|
242
|
+
this.membershipSupportEnsured = (async () => {
|
|
243
|
+
await this.db.prepare(
|
|
244
|
+
`CREATE TABLE IF NOT EXISTS org_memberships (
|
|
245
|
+
id TEXT PRIMARY KEY,
|
|
246
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
247
|
+
org_id TEXT NOT NULL,
|
|
248
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
249
|
+
state TEXT NOT NULL DEFAULT 'active',
|
|
250
|
+
is_primary INTEGER NOT NULL DEFAULT 0,
|
|
251
|
+
invited_by TEXT,
|
|
252
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
253
|
+
activated_at TEXT,
|
|
254
|
+
disabled_at TEXT,
|
|
255
|
+
disabled_by TEXT,
|
|
256
|
+
UNIQUE (user_id, org_id)
|
|
257
|
+
)`,
|
|
258
|
+
).run()
|
|
259
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_org ON org_memberships (org_id, state)').run()
|
|
260
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_memberships_user ON org_memberships (user_id, state)').run()
|
|
261
|
+
const sessionCols = await this.db.prepare('PRAGMA table_info(sessions)').all<{ name: string }>()
|
|
262
|
+
if (!sessionCols.results.some(c => c.name === 'active_org')) {
|
|
263
|
+
await this.db.prepare('ALTER TABLE sessions ADD COLUMN active_org TEXT').run()
|
|
264
|
+
}
|
|
265
|
+
const codeCols = await this.db.prepare('PRAGMA table_info(oidc_codes)').all<{ name: string }>()
|
|
266
|
+
if (!codeCols.results.some(c => c.name === 'context_org')) {
|
|
267
|
+
await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN context_org TEXT').run()
|
|
268
|
+
}
|
|
269
|
+
const accessCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
|
|
270
|
+
if (!accessCols.results.some(c => c.name === 'context_org')) {
|
|
271
|
+
await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN context_org TEXT').run()
|
|
272
|
+
}
|
|
273
|
+
await this.db.prepare(
|
|
274
|
+
`INSERT OR IGNORE INTO org_memberships (id, user_id, org_id, roles, state, is_primary, activated_at)
|
|
275
|
+
SELECT 'mbr-' || id, id, org_id,
|
|
276
|
+
CASE WHEN roles IS NOT NULL AND roles != '' THEN roles ELSE json_array(role) END,
|
|
277
|
+
'active', 1, COALESCE(last_login, created_at)
|
|
278
|
+
FROM users WHERE org_id IS NOT NULL`,
|
|
279
|
+
).run()
|
|
280
|
+
})()
|
|
281
|
+
}
|
|
282
|
+
return this.membershipSupportEnsured
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// TODO.identity-features/05 (the organization registry): the
|
|
286
|
+
// org_registry table arrives with migration 0013 — a dev D1 migrated
|
|
287
|
+
// from before it lacks the table, so the registry methods ensure it
|
|
288
|
+
// defensively (the ensureMembershipSupport posture, memoized per
|
|
289
|
+
// store).
|
|
290
|
+
private orgRegistrySupportEnsured: Promise<void> | null = null
|
|
291
|
+
|
|
292
|
+
private ensureOrgRegistrySupport(): Promise<void> {
|
|
293
|
+
if (!this.orgRegistrySupportEnsured) {
|
|
294
|
+
this.orgRegistrySupportEnsured = (async () => {
|
|
295
|
+
await this.db.prepare(
|
|
296
|
+
`CREATE TABLE IF NOT EXISTS org_registry (
|
|
297
|
+
id TEXT PRIMARY KEY,
|
|
298
|
+
name TEXT NOT NULL,
|
|
299
|
+
short_name TEXT,
|
|
300
|
+
kind TEXT,
|
|
301
|
+
country TEXT,
|
|
302
|
+
contacts TEXT NOT NULL DEFAULT '[]',
|
|
303
|
+
participant_ref TEXT,
|
|
304
|
+
state TEXT NOT NULL DEFAULT 'active',
|
|
305
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
306
|
+
created_by TEXT,
|
|
307
|
+
updated_at TEXT,
|
|
308
|
+
updated_by TEXT,
|
|
309
|
+
disabled_at TEXT,
|
|
310
|
+
disabled_by TEXT
|
|
311
|
+
)`,
|
|
312
|
+
).run()
|
|
313
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_org_registry_state ON org_registry (state)').run()
|
|
314
|
+
})()
|
|
315
|
+
}
|
|
316
|
+
return this.orgRegistrySupportEnsured
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// TODO.register/02 (the register's holder-org attribution): the
|
|
320
|
+
// certificate_holder_orgs / certificate_holder_claims tables arrive with
|
|
321
|
+
// migration 0015 — a dev D1 migrated from before it lacks them, so the
|
|
322
|
+
// attribution/claim methods ensure them defensively (the
|
|
323
|
+
// ensureOrgRegistrySupport posture, memoized per store).
|
|
324
|
+
private holderAttributionSupportEnsured: Promise<void> | null = null
|
|
325
|
+
|
|
326
|
+
private ensureHolderAttributionSupport(): Promise<void> {
|
|
327
|
+
if (!this.holderAttributionSupportEnsured) {
|
|
328
|
+
this.holderAttributionSupportEnsured = (async () => {
|
|
329
|
+
await this.db.prepare(
|
|
330
|
+
`CREATE TABLE IF NOT EXISTS certificate_holder_orgs (
|
|
331
|
+
certificate_id TEXT PRIMARY KEY,
|
|
332
|
+
org_id TEXT NOT NULL,
|
|
333
|
+
org_name TEXT NOT NULL,
|
|
334
|
+
source TEXT NOT NULL,
|
|
335
|
+
attributed_at TEXT NOT NULL,
|
|
336
|
+
attributed_by TEXT,
|
|
337
|
+
claim_id TEXT
|
|
338
|
+
)`,
|
|
339
|
+
).run()
|
|
340
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_orgs_org ON certificate_holder_orgs (org_id)').run()
|
|
341
|
+
await this.db.prepare(
|
|
342
|
+
`CREATE TABLE IF NOT EXISTS certificate_holder_claims (
|
|
343
|
+
id TEXT PRIMARY KEY,
|
|
344
|
+
certificate_id TEXT NOT NULL,
|
|
345
|
+
claimant_org_id TEXT NOT NULL,
|
|
346
|
+
claimant_org_name TEXT NOT NULL,
|
|
347
|
+
matched_holder_name TEXT NOT NULL,
|
|
348
|
+
claimed_by TEXT NOT NULL,
|
|
349
|
+
state TEXT NOT NULL DEFAULT 'pending',
|
|
350
|
+
decided_by TEXT,
|
|
351
|
+
decided_at TEXT,
|
|
352
|
+
refusal_reason TEXT,
|
|
353
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
354
|
+
)`,
|
|
355
|
+
).run()
|
|
356
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_cert ON certificate_holder_claims (certificate_id)').run()
|
|
357
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_state ON certificate_holder_claims (state)').run()
|
|
358
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_certificate_holder_claims_org ON certificate_holder_claims (claimant_org_id)').run()
|
|
359
|
+
})()
|
|
360
|
+
}
|
|
361
|
+
return this.holderAttributionSupportEnsured
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// TODO.register/03 (the instrument register): the
|
|
365
|
+
// instrument_registrations table arrives with migration 0016 — a dev
|
|
366
|
+
// D1 migrated from before it lacks the table, so the register methods
|
|
367
|
+
// ensure it defensively (the ensureOrgRegistrySupport posture,
|
|
368
|
+
// memoized per store).
|
|
369
|
+
private instrumentRegistrationSupportEnsured: Promise<void> | null = null
|
|
370
|
+
|
|
371
|
+
private ensureInstrumentRegistrationSupport(): Promise<void> {
|
|
372
|
+
if (!this.instrumentRegistrationSupportEnsured) {
|
|
373
|
+
this.instrumentRegistrationSupportEnsured = (async () => {
|
|
374
|
+
await this.db.prepare(
|
|
375
|
+
`CREATE TABLE IF NOT EXISTS instrument_registrations (
|
|
376
|
+
id TEXT PRIMARY KEY,
|
|
377
|
+
certificate_id TEXT NOT NULL,
|
|
378
|
+
holder_org_id TEXT NOT NULL,
|
|
379
|
+
standard_id TEXT NOT NULL,
|
|
380
|
+
serial_number TEXT NOT NULL,
|
|
381
|
+
manufacture_date TEXT,
|
|
382
|
+
designations TEXT NOT NULL DEFAULT '{}',
|
|
383
|
+
scope_status TEXT NOT NULL,
|
|
384
|
+
scope_detail TEXT,
|
|
385
|
+
lifecycle TEXT NOT NULL DEFAULT 'registered',
|
|
386
|
+
registered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
387
|
+
registered_by TEXT,
|
|
388
|
+
updated_at TEXT,
|
|
389
|
+
updated_by TEXT,
|
|
390
|
+
UNIQUE (certificate_id, serial_number)
|
|
391
|
+
)`,
|
|
392
|
+
).run()
|
|
393
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_certificate ON instrument_registrations (certificate_id)').run()
|
|
394
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_holder ON instrument_registrations (holder_org_id)').run()
|
|
395
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_instrument_registrations_lifecycle ON instrument_registrations (lifecycle)').run()
|
|
396
|
+
})()
|
|
397
|
+
}
|
|
398
|
+
return this.instrumentRegistrationSupportEnsured
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// TODO.identity-sso/02+03: the amr provenance columns on the OIDC
|
|
402
|
+
// flow rows arrive with migration 0012 — the OIDC methods ensure them
|
|
403
|
+
// defensively (the same memoized posture as the session/user columns),
|
|
404
|
+
// so a dev D1 migrated from before the wave never 500s the core flow.
|
|
405
|
+
private oidcColumnsEnsured: Promise<void> | null = null
|
|
406
|
+
|
|
407
|
+
private ensureOidcColumns(): Promise<void> {
|
|
408
|
+
if (!this.oidcColumnsEnsured) {
|
|
409
|
+
this.oidcColumnsEnsured = (async () => {
|
|
410
|
+
const codeCols = await this.db.prepare('PRAGMA table_info(oidc_codes)').all<{ name: string }>()
|
|
411
|
+
if (!codeCols.results.some(c => c.name === 'amr')) {
|
|
412
|
+
await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN amr TEXT').run()
|
|
413
|
+
}
|
|
414
|
+
const tokenCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
|
|
415
|
+
if (!tokenCols.results.some(c => c.name === 'amr')) {
|
|
416
|
+
await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN amr TEXT').run()
|
|
417
|
+
}
|
|
418
|
+
})()
|
|
419
|
+
}
|
|
420
|
+
return this.oidcColumnsEnsured
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ── users / sessions ─────────────────────────────────────────────
|
|
424
|
+
|
|
425
|
+
async seedDemoAccounts(): Promise<void> {
|
|
426
|
+
const statements: D1PreparedStatement[] = []
|
|
427
|
+
for (const account of seedAccountsForProfile(getInstanceProfile())) {
|
|
428
|
+
// The roles column carries the account's declared FULL set (NULL =
|
|
429
|
+
// the primary role only); the align clears a stale set honestly
|
|
430
|
+
// (the SQLite seed's rule).
|
|
431
|
+
const roles = account.roles?.length ? JSON.stringify(account.roles) : null
|
|
432
|
+
statements.push(this.stmt(
|
|
433
|
+
`INSERT OR IGNORE INTO users (id, email, name, provider, provider_account_id, role, org_id, roles)
|
|
434
|
+
VALUES (?, ?, ?, 'demo', ?, ?, ?, ?)`,
|
|
435
|
+
crypto.randomUUID(), account.email, account.name, account.email, account.role, account.orgId, roles,
|
|
436
|
+
))
|
|
437
|
+
// Align existing demo rows with the current role/org assignments —
|
|
438
|
+
// INSERT OR IGNORE alone would leave them stale (same rule as the
|
|
439
|
+
// SQLite seed).
|
|
440
|
+
statements.push(this.stmt(
|
|
441
|
+
`UPDATE users SET name = ?, role = ?, org_id = ?, roles = ? WHERE email = ? AND provider = 'demo'`,
|
|
442
|
+
account.name, account.role, account.orgId, roles, account.email,
|
|
443
|
+
))
|
|
444
|
+
}
|
|
445
|
+
// A profile with no demo cast (the production posture) is an honest
|
|
446
|
+
// no-op, never an error — D1's batch rejects an empty statement
|
|
447
|
+
// list ("No SQL statements detected").
|
|
448
|
+
if (statements.length === 0) return
|
|
449
|
+
await this.db.batch(statements)
|
|
450
|
+
// TODO.identity/11: the org-bound seed accounts' primary memberships
|
|
451
|
+
// ride the mirror (idempotent — the seed runs at every boot).
|
|
452
|
+
for (const account of seedAccountsForProfile(getInstanceProfile())) {
|
|
453
|
+
if (!account.orgId) continue
|
|
454
|
+
const row = await this.stmt('SELECT id FROM users WHERE email = ?', account.email).first<{ id: string }>()
|
|
455
|
+
if (row) await this.syncPrimaryMembership(row.id)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async authenticateDemo(email: string, password: string): Promise<AuthUserPayload | null> {
|
|
460
|
+
await this.ensureUserColumns()
|
|
461
|
+
const user = await this.stmt("SELECT * FROM users WHERE email = ? AND provider = 'demo'", email)
|
|
462
|
+
.first<UserRecord>()
|
|
463
|
+
if (!user) return null
|
|
464
|
+
// A deactivated account refuses sign-in (TODO.federation/12).
|
|
465
|
+
if (user.active === 0) return null
|
|
466
|
+
if (password !== DEMO_PASSWORD) return null
|
|
467
|
+
await this.stmt("UPDATE users SET last_login = datetime('now') WHERE id = ?", user.id).run()
|
|
468
|
+
return toPayload(user)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async findOrCreateOAuthUser(
|
|
472
|
+
provider: string,
|
|
473
|
+
providerAccountId: string,
|
|
474
|
+
email: string,
|
|
475
|
+
name: string,
|
|
476
|
+
avatarUrl?: string,
|
|
477
|
+
// The INITIAL role/org — applied only on CREATE (an existing
|
|
478
|
+
// account keeps its local assignment).
|
|
479
|
+
initial?: OAuthInitialAssignment,
|
|
480
|
+
): Promise<AuthUserPayload> {
|
|
481
|
+
const existing = await this.stmt(
|
|
482
|
+
'SELECT * FROM users WHERE provider = ? AND provider_account_id = ?', provider, providerAccountId,
|
|
483
|
+
).first<UserRecord>()
|
|
484
|
+
|
|
485
|
+
if (existing) {
|
|
486
|
+
await this.stmt("UPDATE users SET last_login = datetime('now'), avatar_url = ? WHERE id = ?",
|
|
487
|
+
avatarUrl ?? null, existing.id).run()
|
|
488
|
+
return toPayload(existing, avatarUrl)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const id = crypto.randomUUID()
|
|
492
|
+
const role = initial?.role ?? 'user'
|
|
493
|
+
const orgId = initial?.orgId ?? null
|
|
494
|
+
await this.stmt(
|
|
495
|
+
"INSERT INTO users (id, email, name, avatar_url, provider, provider_account_id, role, org_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
496
|
+
id, email, name, avatarUrl ?? null, provider, providerAccountId, role, orgId,
|
|
497
|
+
).run()
|
|
498
|
+
if (orgId) await this.syncPrimaryMembership(id) // TODO.identity/11 — the mirror
|
|
499
|
+
return { id, email, name, role, orgId, avatarUrl }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async createSession(
|
|
503
|
+
userId: string,
|
|
504
|
+
opts?: { idTokenHint?: string | null; userAgent?: string | null; ip?: string | null; amr?: string[] | null },
|
|
505
|
+
): Promise<string> {
|
|
506
|
+
await this.ensureSessionColumns()
|
|
507
|
+
const token = crypto.randomUUID()
|
|
508
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
|
|
509
|
+
await this.stmt(
|
|
510
|
+
'INSERT INTO sessions (id, user_id, token, expires_at, id_token_hint, user_agent, ip, last_seen_at, amr) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
511
|
+
crypto.randomUUID(), userId, token, expiresAt, opts?.idTokenHint ?? null, opts?.userAgent ?? null, opts?.ip ?? null, null,
|
|
512
|
+
opts?.amr?.length ? JSON.stringify(opts.amr) : null,
|
|
513
|
+
).run()
|
|
514
|
+
return token
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async touchLastLogin(userId: string): Promise<void> {
|
|
518
|
+
await this.stmt("UPDATE users SET last_login = datetime('now') WHERE id = ?", userId).run()
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async getSessionIdTokenHint(token: string): Promise<string | null> {
|
|
522
|
+
const row = await this.stmt(
|
|
523
|
+
"SELECT id_token_hint FROM sessions WHERE token = ? AND expires_at > datetime('now')",
|
|
524
|
+
token,
|
|
525
|
+
).first<{ id_token_hint: string | null }>()
|
|
526
|
+
return row?.id_token_hint ?? null
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async getSessionUser(token: string): Promise<AuthUserPayload | null> {
|
|
530
|
+
await this.ensureUserColumns()
|
|
531
|
+
await this.ensureSessionColumns()
|
|
532
|
+
await this.ensureMembershipSupport()
|
|
533
|
+
// The session joins the LIVE user row (TODO.federation/12): a role
|
|
534
|
+
// reassignment takes effect on the next request; deactivation ends
|
|
535
|
+
// the session at once.
|
|
536
|
+
const session = await this.stmt(
|
|
537
|
+
`SELECT s.user_id, s.active_org, s.amr, u.email, u.name, u.role, u.roles, u.org_id, u.avatar_url, u.provider, u.email_verified_at
|
|
538
|
+
FROM sessions s JOIN users u ON s.user_id = u.id
|
|
539
|
+
WHERE s.token = ? AND s.expires_at > datetime('now') AND u.active = 1`,
|
|
540
|
+
token,
|
|
541
|
+
).first<{ user_id: string; active_org: string | null; amr: string | null; email: string; name: string; role: string; roles: string | null; org_id: string | null; avatar_url: string | null; provider: string; email_verified_at: string | null }>()
|
|
542
|
+
if (!session) return null
|
|
543
|
+
// TODO.identity/06: the last-active stamp, throttled to one write
|
|
544
|
+
// per minute per session (the sessions section shows it).
|
|
545
|
+
await this.stmt(
|
|
546
|
+
"UPDATE sessions SET last_seen_at = datetime('now') WHERE token = ? AND (last_seen_at IS NULL OR last_seen_at < datetime('now', '-60 seconds'))",
|
|
547
|
+
token,
|
|
548
|
+
).run()
|
|
549
|
+
const amr = parseRoles(session.amr)
|
|
550
|
+
const payload: AuthUserPayload = {
|
|
551
|
+
id: session.user_id,
|
|
552
|
+
email: session.email,
|
|
553
|
+
name: session.name,
|
|
554
|
+
role: session.role,
|
|
555
|
+
...(parseRoles(session.roles)?.length ? { roles: parseRoles(session.roles) } : {}),
|
|
556
|
+
orgId: session.org_id ?? null,
|
|
557
|
+
avatarUrl: session.avatar_url ?? undefined,
|
|
558
|
+
provider: session.provider,
|
|
559
|
+
emailVerifiedAt: session.email_verified_at ?? null,
|
|
560
|
+
...(amr?.length ? { amr } : {}),
|
|
561
|
+
}
|
|
562
|
+
// TODO.identity/11: the active-org context (the membership model) —
|
|
563
|
+
// the payload's org/roles follow the session's stamped context.
|
|
564
|
+
const activeOrg = session.active_org ?? null
|
|
565
|
+
const active = activeOrg ? await this.getOrgMembership(payload.id, activeOrg) : null
|
|
566
|
+
const primary = payload.orgId ? await this.getOrgMembership(payload.id, payload.orgId) : null
|
|
567
|
+
const resolved = resolveOrgContext(payload, { activeOrg, active, primary })
|
|
568
|
+
if (activeOrg && !(active && active.state === 'active')) {
|
|
569
|
+
// The stale stamp never lingers (the membership ended mid-session).
|
|
570
|
+
await this.stmt('UPDATE sessions SET active_org = NULL WHERE user_id = ? AND active_org = ?', payload.id, activeOrg).run()
|
|
571
|
+
}
|
|
572
|
+
return { ...payload, orgId: resolved.orgId, roles: resolved.roles }
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async deleteSession(token: string): Promise<void> {
|
|
576
|
+
await this.stmt('DELETE FROM sessions WHERE token = ?', token).run()
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async cleanExpiredSessions(): Promise<void> {
|
|
580
|
+
await this.stmt("DELETE FROM sessions WHERE expires_at <= datetime('now')").run()
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async listDemoAccounts(): Promise<Array<{ email: string; name: string; role: string }>> {
|
|
584
|
+
const res = await this.stmt(
|
|
585
|
+
"SELECT email, name, role FROM users WHERE provider = 'demo' ORDER BY role, name",
|
|
586
|
+
).all<{ email: string; name: string; role: string }>()
|
|
587
|
+
return res.results
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ── identity federation (TODO.federation/10) ───────────────────────
|
|
591
|
+
|
|
592
|
+
async findUserByEmail(email: string): Promise<AuthUserPayload | null> {
|
|
593
|
+
const user = await this.stmt('SELECT * FROM users WHERE email = ?', email).first<UserRecord>()
|
|
594
|
+
return user ? toPayload(user) : null
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** TODO.identity/01 — the OP's token endpoint resolves the code's
|
|
598
|
+
* user_id through this. */
|
|
599
|
+
async getUserById(id: string): Promise<AuthUserPayload | null> {
|
|
600
|
+
const user = await this.stmt('SELECT * FROM users WHERE id = ?', id).first<UserRecord>()
|
|
601
|
+
return user ? toPayload(user) : null
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async findUserByProvider(provider: string, providerAccountId: string): Promise<AuthUserPayload | null> {
|
|
605
|
+
const user = await this.stmt(
|
|
606
|
+
'SELECT * FROM users WHERE provider = ? AND provider_account_id = ?', provider, providerAccountId,
|
|
607
|
+
).first<UserRecord>()
|
|
608
|
+
return user ? toPayload(user) : null
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async provisionSsoUser(input: {
|
|
612
|
+
email: string
|
|
613
|
+
name: string
|
|
614
|
+
provider: string
|
|
615
|
+
providerAccountId: string
|
|
616
|
+
role: string
|
|
617
|
+
orgId: string | null
|
|
618
|
+
}): Promise<AuthUserPayload> {
|
|
619
|
+
const id = crypto.randomUUID()
|
|
620
|
+
await this.stmt(
|
|
621
|
+
"INSERT INTO users (id, email, name, provider, provider_account_id, role, org_id, last_login) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))",
|
|
622
|
+
id, input.email, input.name, input.provider, input.providerAccountId, input.role, input.orgId,
|
|
623
|
+
).run()
|
|
624
|
+
if (input.orgId) await this.syncPrimaryMembership(id) // TODO.identity/11 — the mirror
|
|
625
|
+
return { id, email: input.email, name: input.name, role: input.role, orgId: input.orgId }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async linkProviderIdentity(userId: string, provider: string, providerAccountId: string): Promise<void> {
|
|
629
|
+
await this.stmt(
|
|
630
|
+
"UPDATE users SET provider = ?, provider_account_id = ?, last_login = datetime('now') WHERE id = ?",
|
|
631
|
+
provider, providerAccountId, userId,
|
|
632
|
+
).run()
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async updateUserRoleOrg(userId: string, role: string, orgId: string | null): Promise<void> {
|
|
636
|
+
await this.stmt('UPDATE users SET role = ?, org_id = ? WHERE id = ?', role, orgId, userId).run()
|
|
637
|
+
if (orgId) await this.syncPrimaryMembership(userId) // TODO.identity/11 — the mirror
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// The approval queue rows port directly (D1 is SQLite).
|
|
641
|
+
|
|
642
|
+
private async approvalRow(where: string, ...params: unknown[]) {
|
|
643
|
+
const row = await this.stmt(`SELECT * FROM identity_approvals WHERE ${where}`, ...params)
|
|
644
|
+
.first<Record<string, unknown>>()
|
|
645
|
+
return row ? toIdentityApproval(row) : null
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async upsertIdentityApproval(input: {
|
|
649
|
+
email: string
|
|
650
|
+
name: string
|
|
651
|
+
issuer: string
|
|
652
|
+
sub: string
|
|
653
|
+
claimsJson: string | null
|
|
654
|
+
}): Promise<IdentityApproval> {
|
|
655
|
+
await this.stmt(
|
|
656
|
+
`INSERT INTO identity_approvals (id, email, name, issuer, sub, claims_json, last_seen)
|
|
657
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
|
658
|
+
ON CONFLICT (issuer, sub) DO UPDATE SET
|
|
659
|
+
email = excluded.email, name = excluded.name, claims_json = excluded.claims_json,
|
|
660
|
+
last_seen = datetime('now')`,
|
|
661
|
+
crypto.randomUUID(), input.email, input.name, input.issuer, input.sub, input.claimsJson,
|
|
662
|
+
).run()
|
|
663
|
+
return (await this.approvalRow('issuer = ? AND sub = ?', input.issuer, input.sub))!
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async getIdentityApproval(issuer: string, sub: string): Promise<IdentityApproval | null> {
|
|
667
|
+
return this.approvalRow('issuer = ? AND sub = ?', issuer, sub)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async listIdentityApprovals(status?: IdentityApproval['status']): Promise<IdentityApproval[]> {
|
|
671
|
+
const res = status
|
|
672
|
+
? await this.stmt('SELECT * FROM identity_approvals WHERE status = ? ORDER BY created_at', status).all<Record<string, unknown>>()
|
|
673
|
+
: await this.stmt('SELECT * FROM identity_approvals ORDER BY created_at').all<Record<string, unknown>>()
|
|
674
|
+
return res.results.map(toIdentityApproval)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async decideIdentityApproval(
|
|
678
|
+
id: string,
|
|
679
|
+
decision: { status: 'approved' | 'rejected'; role?: string; orgId?: string | null; decidedBy: string },
|
|
680
|
+
): Promise<IdentityApproval | null> {
|
|
681
|
+
await this.stmt(
|
|
682
|
+
`UPDATE identity_approvals SET status = ?, decided_role = ?, decided_org = ?, decided_by = ?, decided_at = datetime('now')
|
|
683
|
+
WHERE id = ?`,
|
|
684
|
+
decision.status, decision.role ?? null, decision.orgId ?? null, decision.decidedBy, id,
|
|
685
|
+
).run()
|
|
686
|
+
return this.approvalRow('id = ?', id)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ── the SSO sign-in state jar (TODO.identity/04) ───────────────────
|
|
690
|
+
// The rows port directly (D1 is SQLite); the consume's UPDATE flip is
|
|
691
|
+
// the atomic single-use guarantee across isolates.
|
|
692
|
+
|
|
693
|
+
async putSsoState(input: { state: string; nonce: string; verifier: string; ttlMs: number }): Promise<void> {
|
|
694
|
+
// The expiry sweep rides the write: stale rows are inert (consume
|
|
695
|
+
// checks the expiry) but never accumulate.
|
|
696
|
+
await this.stmt('DELETE FROM sso_states WHERE expires_at <= ?', new Date().toISOString()).run()
|
|
697
|
+
await this.stmt(
|
|
698
|
+
'INSERT INTO sso_states (state, nonce, verifier, expires_at) VALUES (?, ?, ?, ?)',
|
|
699
|
+
input.state, input.nonce, input.verifier, new Date(Date.now() + input.ttlMs).toISOString(),
|
|
700
|
+
).run()
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Atomically consume the state: the UPDATE flips consumed_at exactly
|
|
704
|
+
* once (a replay loses the race and answers null). An EXPIRED row is
|
|
705
|
+
* consumed too — never a second chance. */
|
|
706
|
+
async consumeSsoState(state: string): Promise<SsoSignInState | null> {
|
|
707
|
+
const res = await this.stmt(
|
|
708
|
+
"UPDATE sso_states SET consumed_at = datetime('now') WHERE state = ? AND consumed_at IS NULL", state,
|
|
709
|
+
).run()
|
|
710
|
+
if ((res.meta.changes ?? 0) === 0) return null
|
|
711
|
+
const row = await this.stmt('SELECT state, nonce, verifier, expires_at FROM sso_states WHERE state = ?', state)
|
|
712
|
+
.first<Record<string, unknown>>()
|
|
713
|
+
if (!row) return null
|
|
714
|
+
if (new Date(row.expires_at as string).getTime() <= Date.now()) return null
|
|
715
|
+
return {
|
|
716
|
+
state: row.state as string,
|
|
717
|
+
nonce: row.nonce as string,
|
|
718
|
+
verifier: row.verifier as string,
|
|
719
|
+
expiresAt: row.expires_at as string,
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ── federation peers (TODO.federation/04) ───────────────────────────
|
|
724
|
+
// The peer rows port directly (D1 is SQLite).
|
|
725
|
+
|
|
726
|
+
async listFederationPeers(status?: FederationPeer['status']): Promise<FederationPeer[]> {
|
|
727
|
+
const res = status
|
|
728
|
+
? await this.stmt('SELECT * FROM federation_peers WHERE status = ? ORDER BY added_at', status).all<Record<string, unknown>>()
|
|
729
|
+
: await this.stmt('SELECT * FROM federation_peers ORDER BY added_at').all<Record<string, unknown>>()
|
|
730
|
+
return res.results.map(toFederationPeer)
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async getFederationPeer(id: string): Promise<FederationPeer | null> {
|
|
734
|
+
const row = await this.stmt('SELECT * FROM federation_peers WHERE id = ?', id).first<Record<string, unknown>>()
|
|
735
|
+
return row ? toFederationPeer(row) : null
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async upsertFederationPeer(input: {
|
|
739
|
+
id: string
|
|
740
|
+
name: string
|
|
741
|
+
roles: string
|
|
742
|
+
descriptorUrl: string | null
|
|
743
|
+
descriptorJson: string
|
|
744
|
+
pinnedVia: FederationPeer['pinnedVia']
|
|
745
|
+
connectivity: FederationPeer['connectivity']
|
|
746
|
+
addedBy: string | null
|
|
747
|
+
}): Promise<FederationPeer> {
|
|
748
|
+
// The same upsert semantics as the SQLite store: an update stamps
|
|
749
|
+
// refreshed_at and never resurrects a revoked peer.
|
|
750
|
+
await this.stmt(
|
|
751
|
+
`INSERT INTO federation_peers (id, name, roles, descriptor_url, descriptor_json, pinned_via, connectivity, added_by)
|
|
752
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
753
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
754
|
+
name = excluded.name, roles = excluded.roles,
|
|
755
|
+
descriptor_url = excluded.descriptor_url, descriptor_json = excluded.descriptor_json,
|
|
756
|
+
pinned_via = excluded.pinned_via, connectivity = excluded.connectivity,
|
|
757
|
+
refreshed_at = datetime('now')`,
|
|
758
|
+
input.id, input.name, input.roles, input.descriptorUrl, input.descriptorJson, input.pinnedVia, input.connectivity, input.addedBy,
|
|
759
|
+
).run()
|
|
760
|
+
return (await this.getFederationPeer(input.id))!
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async revokeFederationPeer(id: string, revokedBy: string): Promise<FederationPeer | null> {
|
|
764
|
+
await this.stmt(
|
|
765
|
+
`UPDATE federation_peers SET status = 'revoked', revoked_at = datetime('now'), revoked_by = ? WHERE id = ?`,
|
|
766
|
+
revokedBy, id,
|
|
767
|
+
).run()
|
|
768
|
+
return this.getFederationPeer(id)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ── user administration (TODO.federation/12) ─────────────────────
|
|
772
|
+
|
|
773
|
+
async listUsers(): Promise<UserAdminRow[]> {
|
|
774
|
+
await this.ensureUserColumns()
|
|
775
|
+
const res = await this.stmt('SELECT * FROM users ORDER BY name')
|
|
776
|
+
.all<UserRecord & { last_login?: string | null; provider?: string }>()
|
|
777
|
+
return res.results.map(toAdminRow)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async createLocalUser(input: {
|
|
781
|
+
email: string
|
|
782
|
+
name: string
|
|
783
|
+
role: string
|
|
784
|
+
roles?: string[]
|
|
785
|
+
orgId?: string | null
|
|
786
|
+
}): Promise<UserAdminRow> {
|
|
787
|
+
await this.ensureUserColumns()
|
|
788
|
+
const id = crypto.randomUUID()
|
|
789
|
+
const roles = input.roles?.length ? input.roles : [input.role]
|
|
790
|
+
await this.stmt(
|
|
791
|
+
`INSERT INTO users (id, email, name, provider, provider_account_id, role, roles, org_id)
|
|
792
|
+
VALUES (?, ?, ?, 'demo', ?, ?, ?, ?)`,
|
|
793
|
+
id, input.email, input.name, input.email, input.role, JSON.stringify(roles), input.orgId ?? null,
|
|
794
|
+
).run()
|
|
795
|
+
if (input.orgId) await this.syncPrimaryMembership(id) // TODO.identity/11 — the mirror
|
|
796
|
+
const row = await this.stmt('SELECT * FROM users WHERE id = ?', id).first<UserRecord>()
|
|
797
|
+
return toAdminRow(row!)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async setUserRoles(id: string, role: string, roles: string[]): Promise<boolean> {
|
|
801
|
+
await this.ensureUserColumns()
|
|
802
|
+
const res = await this.stmt(
|
|
803
|
+
'UPDATE users SET role = ?, roles = ? WHERE id = ?',
|
|
804
|
+
role, JSON.stringify(roles.length ? roles : [role]), id,
|
|
805
|
+
).run()
|
|
806
|
+
// TODO.identity/11 — the mirror (a no-op for org-free accounts).
|
|
807
|
+
if ((res.meta.changes ?? 0) > 0) await this.syncPrimaryMembership(id)
|
|
808
|
+
return (res.meta.changes ?? 0) > 0
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async setUserActive(id: string, active: boolean): Promise<boolean> {
|
|
812
|
+
await this.ensureUserColumns()
|
|
813
|
+
const res = await this.stmt('UPDATE users SET active = ? WHERE id = ?', active ? 1 : 0, id).run()
|
|
814
|
+
return (res.meta.changes ?? 0) > 0
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ── the OIDC Provider (TODO.identity/01) ───────────────────────────
|
|
818
|
+
// The OP rows port directly (D1 is SQLite) — the same statements as
|
|
819
|
+
// op-store.ts's sync half.
|
|
820
|
+
|
|
821
|
+
private static toOidcClient(row: Record<string, unknown>): OidcClient {
|
|
822
|
+
return {
|
|
823
|
+
clientId: row.client_id as string,
|
|
824
|
+
name: row.name as string,
|
|
825
|
+
secretHash: (row.secret_hash as string | null) ?? null,
|
|
826
|
+
redirectUris: JSON.parse(row.redirect_uris as string) as string[],
|
|
827
|
+
claimsPolicy: row.claims_policy ? JSON.parse(row.claims_policy as string) as OidcClient['claimsPolicy'] : null,
|
|
828
|
+
// The SSO-home launch metadata (migration 0011): no launch_url =
|
|
829
|
+
// the client never appears on the launcher. A pre-0011 database
|
|
830
|
+
// reads the columns as absent — launch stays null, the honest
|
|
831
|
+
// default.
|
|
832
|
+
launch: row.launch_url
|
|
833
|
+
? {
|
|
834
|
+
url: row.launch_url as string,
|
|
835
|
+
icon: (row.launch_icon as string | null) ?? null,
|
|
836
|
+
description: (row.launch_description as string | null) ?? null,
|
|
837
|
+
visibility: ((row.launch_visibility as string | null) ?? 'roles') as OidcClientLaunch['visibility'],
|
|
838
|
+
}
|
|
839
|
+
: null,
|
|
840
|
+
status: row.status as OidcClient['status'],
|
|
841
|
+
createdAt: row.created_at as string,
|
|
842
|
+
createdBy: (row.created_by as string | null) ?? null,
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
private static toOidcAuthorization(row: Record<string, unknown>): OidcAuthorization {
|
|
847
|
+
return {
|
|
848
|
+
id: row.id as string,
|
|
849
|
+
clientId: row.client_id as string,
|
|
850
|
+
redirectUri: row.redirect_uri as string,
|
|
851
|
+
scope: row.scope as string,
|
|
852
|
+
state: row.state as string,
|
|
853
|
+
nonce: (row.nonce as string | null) ?? null,
|
|
854
|
+
codeChallenge: row.code_challenge as string,
|
|
855
|
+
userId: (row.user_id as string | null) ?? null,
|
|
856
|
+
decision: (row.decision as OidcAuthorization['decision']) ?? null,
|
|
857
|
+
createdAt: row.created_at as string,
|
|
858
|
+
expiresAt: row.expires_at as string,
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async getOidcClient(clientId: string): Promise<OidcClient | null> {
|
|
863
|
+
const row = await this.stmt('SELECT * FROM oidc_clients WHERE client_id = ?', clientId).first<Record<string, unknown>>()
|
|
864
|
+
return row ? D1ServerStore.toOidcClient(row) : null
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async listOidcClients(): Promise<OidcClient[]> {
|
|
868
|
+
const res = await this.stmt('SELECT * FROM oidc_clients ORDER BY created_at, client_id').all<Record<string, unknown>>()
|
|
869
|
+
return res.results.map(D1ServerStore.toOidcClient)
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async upsertOidcClient(input: {
|
|
873
|
+
clientId: string
|
|
874
|
+
name: string
|
|
875
|
+
secretHash: string | null
|
|
876
|
+
redirectUris: string[]
|
|
877
|
+
claimsPolicy: { claims: string[] } | null
|
|
878
|
+
createdBy?: string | null
|
|
879
|
+
}): Promise<OidcClient> {
|
|
880
|
+
await this.stmt(
|
|
881
|
+
`INSERT INTO oidc_clients (client_id, name, secret_hash, redirect_uris, claims_policy, created_by)
|
|
882
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
883
|
+
ON CONFLICT (client_id) DO UPDATE SET
|
|
884
|
+
name = excluded.name,
|
|
885
|
+
secret_hash = excluded.secret_hash,
|
|
886
|
+
redirect_uris = excluded.redirect_uris,
|
|
887
|
+
claims_policy = excluded.claims_policy`,
|
|
888
|
+
input.clientId, input.name, input.secretHash,
|
|
889
|
+
JSON.stringify(input.redirectUris),
|
|
890
|
+
input.claimsPolicy ? JSON.stringify(input.claimsPolicy) : null,
|
|
891
|
+
input.createdBy ?? null,
|
|
892
|
+
).run()
|
|
893
|
+
return (await this.getOidcClient(input.clientId))!
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async setOidcClientStatus(clientId: string, status: OidcClient['status']): Promise<OidcClient | null> {
|
|
897
|
+
const res = await this.stmt('UPDATE oidc_clients SET status = ? WHERE client_id = ?', status, clientId).run()
|
|
898
|
+
return (res.meta.changes ?? 0) > 0 ? this.getOidcClient(clientId) : null
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async setOidcClientLaunch(clientId: string, launch: OidcClientLaunch | null): Promise<OidcClient | null> {
|
|
902
|
+
const res = await this.stmt(
|
|
903
|
+
`UPDATE oidc_clients SET launch_url = ?, launch_icon = ?, launch_description = ?, launch_visibility = ?
|
|
904
|
+
WHERE client_id = ?`,
|
|
905
|
+
launch?.url ?? null,
|
|
906
|
+
launch?.icon ?? null,
|
|
907
|
+
launch?.description ?? null,
|
|
908
|
+
launch?.visibility ?? 'roles',
|
|
909
|
+
clientId,
|
|
910
|
+
).run()
|
|
911
|
+
return (res.meta.changes ?? 0) > 0 ? this.getOidcClient(clientId) : null
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async createOidcAuthorization(input: {
|
|
915
|
+
id: string
|
|
916
|
+
clientId: string
|
|
917
|
+
redirectUri: string
|
|
918
|
+
scope: string
|
|
919
|
+
state: string
|
|
920
|
+
nonce: string | null
|
|
921
|
+
codeChallenge: string
|
|
922
|
+
userId: string | null
|
|
923
|
+
ttlMs: number
|
|
924
|
+
}): Promise<OidcAuthorization> {
|
|
925
|
+
const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
|
|
926
|
+
await this.stmt(
|
|
927
|
+
`INSERT INTO oidc_authorizations
|
|
928
|
+
(id, client_id, redirect_uri, scope, state, nonce, code_challenge, user_id, expires_at)
|
|
929
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
930
|
+
input.id, input.clientId, input.redirectUri, input.scope, input.state,
|
|
931
|
+
input.nonce, input.codeChallenge, input.userId, expiresAt,
|
|
932
|
+
).run()
|
|
933
|
+
return (await this.getOidcAuthorization(input.id))!
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async getOidcAuthorization(id: string): Promise<OidcAuthorization | null> {
|
|
937
|
+
const row = await this.stmt('SELECT * FROM oidc_authorizations WHERE id = ?', id).first<Record<string, unknown>>()
|
|
938
|
+
return row ? D1ServerStore.toOidcAuthorization(row) : null
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async decideOidcAuthorization(
|
|
942
|
+
id: string,
|
|
943
|
+
decision: { userId: string; decision: 'allow' | 'deny' },
|
|
944
|
+
): Promise<OidcAuthorization | null> {
|
|
945
|
+
// The decision binds to the row's OWN account and flips atomically.
|
|
946
|
+
const res = await this.stmt(
|
|
947
|
+
'UPDATE oidc_authorizations SET decision = ? WHERE id = ? AND decision IS NULL AND user_id = ?',
|
|
948
|
+
decision.decision, id, decision.userId,
|
|
949
|
+
).run()
|
|
950
|
+
return (res.meta.changes ?? 0) > 0 ? this.getOidcAuthorization(id) : null
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async createOidcCode(input: {
|
|
954
|
+
code: string
|
|
955
|
+
clientId: string
|
|
956
|
+
redirectUri: string
|
|
957
|
+
scope: string
|
|
958
|
+
nonce: string | null
|
|
959
|
+
codeChallenge: string
|
|
960
|
+
userId: string
|
|
961
|
+
/** TODO.identity/11: the session's stamped active-org context at the
|
|
962
|
+
* consent decision (NULL = the primary context). */
|
|
963
|
+
contextOrg?: string | null
|
|
964
|
+
/** TODO.identity-sso/02+03: the consenting session's amr provenance
|
|
965
|
+
* (stored as JSON; the token endpoint emits it as the ID token's
|
|
966
|
+
* amr). Absent = no provenance recorded. */
|
|
967
|
+
amr?: string[] | null
|
|
968
|
+
ttlMs: number
|
|
969
|
+
}): Promise<void> {
|
|
970
|
+
await this.ensureMembershipSupport()
|
|
971
|
+
const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
|
|
972
|
+
await this.ensureOidcColumns()
|
|
973
|
+
await this.stmt(
|
|
974
|
+
`INSERT INTO oidc_codes (code, client_id, redirect_uri, scope, nonce, code_challenge, user_id, context_org, amr, expires_at)
|
|
975
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
976
|
+
input.code, input.clientId, input.redirectUri, input.scope, input.nonce, input.codeChallenge, input.userId, input.contextOrg ?? null,
|
|
977
|
+
input.amr?.length ? JSON.stringify(input.amr) : null, expiresAt,
|
|
978
|
+
).run()
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Atomically consume the code: the UPDATE flips consumed_at exactly
|
|
982
|
+
* once — a replay loses the race and answers null (→ invalid_grant).
|
|
983
|
+
* An expired code is consumed too (never a second chance). */
|
|
984
|
+
async consumeOidcCode(code: string): Promise<OidcCode | null> {
|
|
985
|
+
await this.ensureMembershipSupport()
|
|
986
|
+
const res = await this.stmt(
|
|
987
|
+
"UPDATE oidc_codes SET consumed_at = datetime('now') WHERE code = ? AND consumed_at IS NULL", code,
|
|
988
|
+
).run()
|
|
989
|
+
if ((res.meta.changes ?? 0) === 0) return null
|
|
990
|
+
const row = await this.stmt('SELECT * FROM oidc_codes WHERE code = ?', code).first<Record<string, unknown>>()
|
|
991
|
+
if (!row) return null
|
|
992
|
+
if (new Date(row.expires_at as string).getTime() <= Date.now()) return null
|
|
993
|
+
return {
|
|
994
|
+
code: row.code as string,
|
|
995
|
+
clientId: row.client_id as string,
|
|
996
|
+
redirectUri: row.redirect_uri as string,
|
|
997
|
+
scope: row.scope as string,
|
|
998
|
+
nonce: (row.nonce as string | null) ?? null,
|
|
999
|
+
codeChallenge: row.code_challenge as string,
|
|
1000
|
+
userId: row.user_id as string,
|
|
1001
|
+
contextOrg: (row.context_org as string | null) ?? null,
|
|
1002
|
+
amr: parseRoles((row.amr as string | null) ?? null) ?? null,
|
|
1003
|
+
expiresAt: row.expires_at as string,
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
async createOidcAccessToken(input: {
|
|
1008
|
+
token: string
|
|
1009
|
+
userId: string
|
|
1010
|
+
clientId: string
|
|
1011
|
+
scope: string
|
|
1012
|
+
/** The granting code's context (userinfo answers the ID token's
|
|
1013
|
+
* claims). */
|
|
1014
|
+
contextOrg?: string | null
|
|
1015
|
+
/** TODO.identity-sso/02+03: the authorizing authentication's amr —
|
|
1016
|
+
* userinfo answers the same truth the ID token carried. */
|
|
1017
|
+
amr?: string[] | null
|
|
1018
|
+
ttlMs: number
|
|
1019
|
+
}): Promise<void> {
|
|
1020
|
+
await this.ensureMembershipSupport()
|
|
1021
|
+
const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
|
|
1022
|
+
await this.ensureOidcColumns()
|
|
1023
|
+
await this.stmt(
|
|
1024
|
+
'INSERT INTO oidc_access_tokens (token, user_id, client_id, scope, context_org, amr, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
1025
|
+
input.token, input.userId, input.clientId, input.scope, input.contextOrg ?? null,
|
|
1026
|
+
input.amr?.length ? JSON.stringify(input.amr) : null, expiresAt,
|
|
1027
|
+
).run()
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async getOidcAccessToken(token: string): Promise<OidcAccessToken | null> {
|
|
1031
|
+
await this.ensureMembershipSupport()
|
|
1032
|
+
const row = await this.stmt(
|
|
1033
|
+
"SELECT * FROM oidc_access_tokens WHERE token = ? AND expires_at > datetime('now')", token,
|
|
1034
|
+
).first<Record<string, unknown>>()
|
|
1035
|
+
if (!row) return null
|
|
1036
|
+
return {
|
|
1037
|
+
token: row.token as string,
|
|
1038
|
+
userId: row.user_id as string,
|
|
1039
|
+
clientId: row.client_id as string,
|
|
1040
|
+
scope: row.scope as string,
|
|
1041
|
+
contextOrg: (row.context_org as string | null) ?? null,
|
|
1042
|
+
amr: parseRoles((row.amr as string | null) ?? null) ?? null,
|
|
1043
|
+
expiresAt: row.expires_at as string,
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
async listOidcKeys(): Promise<OidcKeyRow[]> {
|
|
1048
|
+
const res = await this.stmt('SELECT * FROM oidc_keys ORDER BY created_at, kid').all<Record<string, unknown>>()
|
|
1049
|
+
return res.results.map(row => ({
|
|
1050
|
+
kid: row.kid as string,
|
|
1051
|
+
publicJwk: row.public_jwk as string,
|
|
1052
|
+
status: row.status as OidcKeyRow['status'],
|
|
1053
|
+
createdAt: row.created_at as string,
|
|
1054
|
+
retiredAt: (row.retired_at as string | null) ?? null,
|
|
1055
|
+
}))
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
async upsertOidcKey(input: { kid: string; publicJwk: string }): Promise<void> {
|
|
1059
|
+
await this.stmt('INSERT OR IGNORE INTO oidc_keys (kid, public_jwk) VALUES (?, ?)', input.kid, input.publicJwk).run()
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async retireOidcKey(kid: string): Promise<void> {
|
|
1063
|
+
await this.stmt(
|
|
1064
|
+
"UPDATE oidc_keys SET status = 'retired', retired_at = datetime('now') WHERE kid = ? AND status = 'active'", kid,
|
|
1065
|
+
).run()
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// ── the upstream providers (TODO.identity/08) ─────────────────────
|
|
1069
|
+
// The provider + link rows port directly (D1 is SQLite).
|
|
1070
|
+
|
|
1071
|
+
private static toIdentityProvider(row: Record<string, unknown>): IdentityProvider {
|
|
1072
|
+
return {
|
|
1073
|
+
id: row.id as string,
|
|
1074
|
+
kind: row.kind as IdentityProvider['kind'],
|
|
1075
|
+
displayName: row.display_name as string,
|
|
1076
|
+
brandMark: (row.brand_mark as string | null) ?? null,
|
|
1077
|
+
issuer: (row.issuer as string | null) ?? null,
|
|
1078
|
+
clientId: row.client_id as string,
|
|
1079
|
+
clientSecretRef: (row.client_secret_ref as string | null) ?? null,
|
|
1080
|
+
scopes: (row.scopes as string | null) ?? null,
|
|
1081
|
+
enabled: row.enabled === 1,
|
|
1082
|
+
createdAt: row.created_at as string,
|
|
1083
|
+
createdBy: (row.created_by as string | null) ?? null,
|
|
1084
|
+
updatedAt: (row.updated_at as string | null) ?? null,
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
async listIdentityProviders(): Promise<IdentityProvider[]> {
|
|
1089
|
+
const res = await this.stmt('SELECT * FROM identity_providers ORDER BY created_at, id').all<Record<string, unknown>>()
|
|
1090
|
+
return res.results.map(D1ServerStore.toIdentityProvider)
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
async getIdentityProvider(id: string): Promise<IdentityProvider | null> {
|
|
1094
|
+
const row = await this.stmt('SELECT * FROM identity_providers WHERE id = ?', id).first<Record<string, unknown>>()
|
|
1095
|
+
return row ? D1ServerStore.toIdentityProvider(row) : null
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
async upsertIdentityProvider(input: {
|
|
1099
|
+
id: string
|
|
1100
|
+
kind: IdentityProvider['kind']
|
|
1101
|
+
displayName: string
|
|
1102
|
+
brandMark?: string | null
|
|
1103
|
+
issuer?: string | null
|
|
1104
|
+
clientId: string
|
|
1105
|
+
clientSecretRef?: string | null
|
|
1106
|
+
scopes?: string | null
|
|
1107
|
+
enabled?: boolean
|
|
1108
|
+
createdBy?: string | null
|
|
1109
|
+
}): Promise<IdentityProvider> {
|
|
1110
|
+
await this.stmt(
|
|
1111
|
+
`INSERT INTO identity_providers
|
|
1112
|
+
(id, kind, display_name, brand_mark, issuer, client_id, client_secret_ref, scopes, enabled, created_by)
|
|
1113
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1114
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
1115
|
+
kind = excluded.kind,
|
|
1116
|
+
display_name = excluded.display_name,
|
|
1117
|
+
brand_mark = excluded.brand_mark,
|
|
1118
|
+
issuer = excluded.issuer,
|
|
1119
|
+
client_id = excluded.client_id,
|
|
1120
|
+
client_secret_ref = excluded.client_secret_ref,
|
|
1121
|
+
scopes = excluded.scopes,
|
|
1122
|
+
enabled = excluded.enabled,
|
|
1123
|
+
updated_at = datetime('now')`,
|
|
1124
|
+
input.id, input.kind, input.displayName, input.brandMark ?? null, input.issuer ?? null,
|
|
1125
|
+
input.clientId, input.clientSecretRef ?? null, input.scopes ?? null,
|
|
1126
|
+
// Disabled by default (the schema's DEFAULT 0): a deliberate
|
|
1127
|
+
// enable makes the provider visible.
|
|
1128
|
+
input.enabled ? 1 : 0, input.createdBy ?? null,
|
|
1129
|
+
).run()
|
|
1130
|
+
return (await this.getIdentityProvider(input.id))!
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
async setIdentityProviderEnabled(id: string, enabled: boolean): Promise<IdentityProvider | null> {
|
|
1134
|
+
const res = await this.stmt(
|
|
1135
|
+
"UPDATE identity_providers SET enabled = ?, updated_at = datetime('now') WHERE id = ?",
|
|
1136
|
+
enabled ? 1 : 0, id,
|
|
1137
|
+
).run()
|
|
1138
|
+
return (res.meta.changes ?? 0) > 0 ? this.getIdentityProvider(id) : null
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
async deleteIdentityProvider(id: string): Promise<boolean> {
|
|
1142
|
+
const res = await this.stmt('DELETE FROM identity_providers WHERE id = ?', id).run()
|
|
1143
|
+
return (res.meta.changes ?? 0) > 0
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// ── the linked identities (TODO.identity/02's shape, 08's flows) ────
|
|
1147
|
+
// ── the OP's account model (TODO.identity/02) ──────────────────────
|
|
1148
|
+
// The account rows port directly (D1 is SQLite) — the same statements
|
|
1149
|
+
// as op-accounts-store.ts's sync half.
|
|
1150
|
+
|
|
1151
|
+
private static toIdentityLink(row: Record<string, unknown>): IdentityLink {
|
|
1152
|
+
return {
|
|
1153
|
+
id: row.id as string,
|
|
1154
|
+
userId: row.user_id as string,
|
|
1155
|
+
provider: row.provider as string,
|
|
1156
|
+
providerAccountId: row.provider_account_id as string,
|
|
1157
|
+
linkedAt: row.linked_at as string,
|
|
1158
|
+
linkedBy: (row.linked_by as string | null) ?? null,
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async listIdentityLinks(userId: string): Promise<IdentityLink[]> {
|
|
1163
|
+
const res = await this.stmt('SELECT * FROM identity_links WHERE user_id = ? ORDER BY linked_at, provider', userId)
|
|
1164
|
+
.all<Record<string, unknown>>()
|
|
1165
|
+
return res.results.map(D1ServerStore.toIdentityLink)
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async findIdentityLink(provider: string, providerAccountId: string): Promise<IdentityLink | null> {
|
|
1169
|
+
const row = await this.stmt('SELECT * FROM identity_links WHERE provider = ? AND provider_account_id = ?', provider, providerAccountId)
|
|
1170
|
+
.first<Record<string, unknown>>()
|
|
1171
|
+
return row ? D1ServerStore.toIdentityLink(row) : null
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** Create the link; NULL on the UNIQUE(provider, provider_account_id)
|
|
1175
|
+
* conflict — the pair is already linked (to any account). */
|
|
1176
|
+
async createIdentityLink(input: {
|
|
1177
|
+
userId: string
|
|
1178
|
+
provider: string
|
|
1179
|
+
providerAccountId: string
|
|
1180
|
+
linkedBy?: string | null
|
|
1181
|
+
}): Promise<IdentityLink | null> {
|
|
1182
|
+
const res = await this.stmt(
|
|
1183
|
+
'INSERT OR IGNORE INTO identity_links (id, user_id, provider, provider_account_id, linked_by) VALUES (?, ?, ?, ?, ?)',
|
|
1184
|
+
crypto.randomUUID(), input.userId, input.provider, input.providerAccountId, input.linkedBy ?? null,
|
|
1185
|
+
).run()
|
|
1186
|
+
if ((res.meta.changes ?? 0) === 0) return null
|
|
1187
|
+
return this.findIdentityLink(input.provider, input.providerAccountId)
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
async deleteIdentityLink(userId: string, provider: string): Promise<boolean> {
|
|
1191
|
+
const res = await this.stmt('DELETE FROM identity_links WHERE user_id = ? AND provider = ?', userId, provider).run()
|
|
1192
|
+
return (res.meta.changes ?? 0) > 0
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
private static toEnrollmentToken(row: Record<string, unknown>): EnrollmentToken {
|
|
1196
|
+
return {
|
|
1197
|
+
token: row.token as string,
|
|
1198
|
+
userId: row.user_id as string,
|
|
1199
|
+
createdBy: (row.created_by as string | null) ?? null,
|
|
1200
|
+
createdAt: row.created_at as string,
|
|
1201
|
+
expiresAt: row.expires_at as string,
|
|
1202
|
+
consumedAt: (row.consumed_at as string | null) ?? null,
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
async createOpAccount(input: {
|
|
1207
|
+
email: string
|
|
1208
|
+
name: string
|
|
1209
|
+
role: string
|
|
1210
|
+
createdBy?: string | null
|
|
1211
|
+
}): Promise<UserAdminRow | null> {
|
|
1212
|
+
const id = crypto.randomUUID()
|
|
1213
|
+
try {
|
|
1214
|
+
await this.stmt(
|
|
1215
|
+
"INSERT INTO users (id, email, name, provider, role) VALUES (?, ?, ?, 'password', ?)",
|
|
1216
|
+
id, input.email.trim().toLowerCase(), input.name.trim(), input.role,
|
|
1217
|
+
).run()
|
|
1218
|
+
} catch (e) {
|
|
1219
|
+
if (String((e as Error).message).includes('UNIQUE')) return null
|
|
1220
|
+
throw e
|
|
1221
|
+
}
|
|
1222
|
+
const row = await this.stmt('SELECT * FROM users WHERE id = ?', id).first<UserRecord & { last_login?: string | null; provider?: string }>()
|
|
1223
|
+
return toAdminRow(row!)
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/** The password sign-in's lookup: the credential + the active flag, by
|
|
1227
|
+
* (normalized) email. The credential's EXISTENCE is the qualifier. */
|
|
1228
|
+
async getPasswordLogin(email: string): Promise<{ userId: string; hash: string; active: boolean } | null> {
|
|
1229
|
+
const row = await this.stmt(
|
|
1230
|
+
`SELECT u.id AS user_id, u.active AS active, p.hash AS hash
|
|
1231
|
+
FROM users u JOIN passwords p ON p.user_id = u.id
|
|
1232
|
+
WHERE u.email = ?`,
|
|
1233
|
+
email.trim().toLowerCase(),
|
|
1234
|
+
).first<{ user_id: string; active: number; hash: string }>()
|
|
1235
|
+
if (!row) return null
|
|
1236
|
+
return { userId: row.user_id, hash: row.hash, active: row.active !== 0 }
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async setPasswordHash(userId: string, hash: string, setBy?: string | null): Promise<void> {
|
|
1240
|
+
await this.stmt(
|
|
1241
|
+
`INSERT INTO passwords (user_id, hash, set_by) VALUES (?, ?, ?)
|
|
1242
|
+
ON CONFLICT (user_id) DO UPDATE SET hash = excluded.hash, set_at = datetime('now'), set_by = excluded.set_by`,
|
|
1243
|
+
userId, hash, setBy ?? null,
|
|
1244
|
+
).run()
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** The sign-in methods the account holds (the account page's
|
|
1248
|
+
* password-set state + the admin list's posture). TODO.identity-sso/02:
|
|
1249
|
+
* the passkeys count — a passkey is a PRIMARY sign-in method. */
|
|
1250
|
+
async countSignInMethods(userId: string): Promise<{ password: boolean; links: number; passkeys: number }> {
|
|
1251
|
+
const pw = await this.stmt('SELECT COUNT(*) AS n FROM passwords WHERE user_id = ?', userId).first<{ n: number }>()
|
|
1252
|
+
const links = await this.stmt('SELECT COUNT(*) AS n FROM identity_links WHERE user_id = ?', userId).first<{ n: number }>()
|
|
1253
|
+
const passkeys = await this.stmt('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?', userId).first<{ n: number }>()
|
|
1254
|
+
return { password: (pw?.n ?? 0) > 0, links: links?.n ?? 0, passkeys: passkeys?.n ?? 0 }
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
async createEnrollmentToken(input: {
|
|
1258
|
+
token: string
|
|
1259
|
+
userId: string
|
|
1260
|
+
createdBy?: string | null
|
|
1261
|
+
ttlMs: number
|
|
1262
|
+
}): Promise<EnrollmentToken> {
|
|
1263
|
+
const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
|
|
1264
|
+
await this.stmt(
|
|
1265
|
+
'INSERT INTO enrollment_tokens (token, user_id, created_by, expires_at) VALUES (?, ?, ?, ?)',
|
|
1266
|
+
input.token, input.userId, input.createdBy ?? null, expiresAt,
|
|
1267
|
+
).run()
|
|
1268
|
+
return (await this.getEnrollmentToken(input.token))!
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
async getEnrollmentToken(token: string): Promise<EnrollmentToken | null> {
|
|
1272
|
+
const row = await this.stmt('SELECT * FROM enrollment_tokens WHERE token = ?', token).first<Record<string, unknown>>()
|
|
1273
|
+
return row ? D1ServerStore.toEnrollmentToken(row) : null
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** Complete the enrollment: consume the token ATOMICALLY (a concurrent
|
|
1277
|
+
* double-submit loses the consumed_at race), judge the expiry (an
|
|
1278
|
+
* expired link is burned, never redeemed), then set the password. */
|
|
1279
|
+
async completeEnrollment(token: string, passwordHash: string, setBy?: string | null): Promise<CompleteEnrollmentResult> {
|
|
1280
|
+
const res = await this.stmt(
|
|
1281
|
+
"UPDATE enrollment_tokens SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL", token,
|
|
1282
|
+
).run()
|
|
1283
|
+
if ((res.meta.changes ?? 0) === 0) return { kind: 'unknown' }
|
|
1284
|
+
const row = (await this.getEnrollmentToken(token))!
|
|
1285
|
+
if (new Date(row.expiresAt).getTime() <= Date.now()) return { kind: 'expired' }
|
|
1286
|
+
await this.setPasswordHash(row.userId, passwordHash, setBy)
|
|
1287
|
+
// TODO.identity/06: the invite ceremony doubles as the address's
|
|
1288
|
+
// verification (the administrator-delivered one-time link).
|
|
1289
|
+
await this.stmt("UPDATE users SET email_verified_at = datetime('now') WHERE id = ?", row.userId).run()
|
|
1290
|
+
return { kind: 'ok', userId: row.userId }
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** The account's live sessions, `current` computed in SQL against the
|
|
1294
|
+
* presenting token — the token value itself never leaves the store. */
|
|
1295
|
+
async listUserSessions(userId: string, currentToken?: string): Promise<SessionView[]> {
|
|
1296
|
+
await this.ensureSessionColumns()
|
|
1297
|
+
const res = await this.stmt(
|
|
1298
|
+
`SELECT id, created_at, expires_at, last_seen_at, user_agent, ip, (token = ?) AS is_current
|
|
1299
|
+
FROM sessions
|
|
1300
|
+
WHERE user_id = ? AND expires_at > datetime('now')
|
|
1301
|
+
ORDER BY created_at DESC`,
|
|
1302
|
+
currentToken ?? '', userId,
|
|
1303
|
+
).all<Record<string, unknown>>()
|
|
1304
|
+
return res.results.map(row => ({
|
|
1305
|
+
id: row.id as string,
|
|
1306
|
+
createdAt: row.created_at as string,
|
|
1307
|
+
expiresAt: row.expires_at as string,
|
|
1308
|
+
lastSeenAt: (row.last_seen_at as string | null) ?? null,
|
|
1309
|
+
userAgent: (row.user_agent as string | null) ?? null,
|
|
1310
|
+
ip: (row.ip as string | null) ?? null,
|
|
1311
|
+
current: Number(row.is_current) === 1,
|
|
1312
|
+
}))
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/** Revoke ONE of the account's own sessions (the user_id clause makes
|
|
1316
|
+
* another account's session id a no-op). */
|
|
1317
|
+
async deleteSessionById(userId: string, sessionId: string): Promise<boolean> {
|
|
1318
|
+
const res = await this.stmt('DELETE FROM sessions WHERE id = ? AND user_id = ?', sessionId, userId).run()
|
|
1319
|
+
return (res.meta.changes ?? 0) > 0
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** Every live session across accounts (expired excluded), `current`
|
|
1323
|
+
* computed in SQL against the presenting token — the aggregate admin
|
|
1324
|
+
* read (TODO.identity-sso/01); the token never leaves the store. */
|
|
1325
|
+
async listOpLiveSessions(currentToken?: string): Promise<OpLiveSession[]> {
|
|
1326
|
+
await this.ensureSessionColumns()
|
|
1327
|
+
const res = await this.stmt(
|
|
1328
|
+
`SELECT id, user_id, created_at, expires_at, last_seen_at, user_agent, ip, (token = ?) AS is_current
|
|
1329
|
+
FROM sessions
|
|
1330
|
+
WHERE expires_at > datetime('now')
|
|
1331
|
+
ORDER BY created_at DESC`,
|
|
1332
|
+
currentToken ?? '',
|
|
1333
|
+
).all<Record<string, unknown>>()
|
|
1334
|
+
return res.results.map(row => ({
|
|
1335
|
+
id: row.id as string,
|
|
1336
|
+
userId: row.user_id as string,
|
|
1337
|
+
createdAt: row.created_at as string,
|
|
1338
|
+
expiresAt: row.expires_at as string,
|
|
1339
|
+
lastSeenAt: (row.last_seen_at as string | null) ?? null,
|
|
1340
|
+
userAgent: (row.user_agent as string | null) ?? null,
|
|
1341
|
+
ip: (row.ip as string | null) ?? null,
|
|
1342
|
+
current: Number(row.is_current) === 1,
|
|
1343
|
+
}))
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/** The administrator's revoke-all (TODO.identity-sso/01's light act):
|
|
1347
|
+
* every session of the account, none kept; answers the count. */
|
|
1348
|
+
async deleteAllUserSessions(userId: string): Promise<number> {
|
|
1349
|
+
const res = await this.stmt('DELETE FROM sessions WHERE user_id = ?', userId).run()
|
|
1350
|
+
return res.meta.changes ?? 0
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// ── the central user registry (TODO.identity/03) ──────────────────
|
|
1354
|
+
|
|
1355
|
+
private static toClientRoleAssignment(row: Record<string, unknown>): OpClientRoleAssignment {
|
|
1356
|
+
return {
|
|
1357
|
+
userId: row.user_id as string,
|
|
1358
|
+
clientId: row.client_id as string,
|
|
1359
|
+
roles: JSON.parse(row.roles as string) as string[],
|
|
1360
|
+
assignedBy: (row.assigned_by as string | null) ?? null,
|
|
1361
|
+
createdAt: row.created_at as string,
|
|
1362
|
+
updatedAt: (row.updated_at as string | null) ?? null,
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
async listOpClientRoles(userId: string): Promise<OpClientRoleAssignment[]> {
|
|
1367
|
+
const res = await this.stmt(
|
|
1368
|
+
'SELECT * FROM op_client_roles WHERE user_id = ? ORDER BY client_id', userId,
|
|
1369
|
+
).all<Record<string, unknown>>()
|
|
1370
|
+
return res.results.map(D1ServerStore.toClientRoleAssignment)
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/** EVERY per-client assignment across accounts (TODO.identity-sso/01's
|
|
1374
|
+
* live access review). */
|
|
1375
|
+
async listAllOpClientRoles(): Promise<OpClientRoleAssignment[]> {
|
|
1376
|
+
const res = await this.stmt(
|
|
1377
|
+
'SELECT * FROM op_client_roles ORDER BY user_id, client_id',
|
|
1378
|
+
).all<Record<string, unknown>>()
|
|
1379
|
+
return res.results.map(D1ServerStore.toClientRoleAssignment)
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/** The assignment for ONE client: NULL = no row (the account default);
|
|
1383
|
+
* an EMPTY array = the explicit "no roles on this client". */
|
|
1384
|
+
async getOpClientRoles(userId: string, clientId: string): Promise<string[] | null> {
|
|
1385
|
+
const row = await this.stmt(
|
|
1386
|
+
'SELECT roles FROM op_client_roles WHERE user_id = ? AND client_id = ?', userId, clientId,
|
|
1387
|
+
).first<{ roles: string }>()
|
|
1388
|
+
return row ? (JSON.parse(row.roles) as string[]) : null
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
async setOpClientRoles(userId: string, clientId: string, roles: string[], assignedBy: string | null): Promise<void> {
|
|
1392
|
+
await this.stmt(
|
|
1393
|
+
`INSERT INTO op_client_roles (user_id, client_id, roles, assigned_by)
|
|
1394
|
+
VALUES (?, ?, ?, ?)
|
|
1395
|
+
ON CONFLICT (user_id, client_id) DO UPDATE SET
|
|
1396
|
+
roles = excluded.roles,
|
|
1397
|
+
assigned_by = excluded.assigned_by,
|
|
1398
|
+
updated_at = datetime('now')`,
|
|
1399
|
+
userId, clientId, JSON.stringify(roles), assignedBy,
|
|
1400
|
+
).run()
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
async deleteOpClientRoles(userId: string, clientId: string): Promise<boolean> {
|
|
1404
|
+
const res = await this.stmt('DELETE FROM op_client_roles WHERE user_id = ? AND client_id = ?', userId, clientId).run()
|
|
1405
|
+
return (res.meta.changes ?? 0) > 0
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/** The deactivation's revocation half: every live session, every issued
|
|
1409
|
+
* access token, every unconsumed code and pending authorization goes.
|
|
1410
|
+
* The user row STAYS (the history). */
|
|
1411
|
+
async revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; codes: number; authorizations: number }> {
|
|
1412
|
+
const sessions = await this.stmt('DELETE FROM sessions WHERE user_id = ?', userId).run()
|
|
1413
|
+
const accessTokens = await this.stmt('DELETE FROM oidc_access_tokens WHERE user_id = ?', userId).run()
|
|
1414
|
+
const codes = await this.stmt('DELETE FROM oidc_codes WHERE user_id = ? AND consumed_at IS NULL', userId).run()
|
|
1415
|
+
const authorizations = await this.stmt('DELETE FROM oidc_authorizations WHERE user_id = ? AND decision IS NULL', userId).run()
|
|
1416
|
+
return {
|
|
1417
|
+
sessions: sessions.meta.changes ?? 0,
|
|
1418
|
+
accessTokens: accessTokens.meta.changes ?? 0,
|
|
1419
|
+
codes: codes.meta.changes ?? 0,
|
|
1420
|
+
authorizations: authorizations.meta.changes ?? 0,
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/** The registry's ERASURE act (the offboarding runbook's delete path):
|
|
1425
|
+
* every credential, token, link and per-client assignment removed; the
|
|
1426
|
+
* user row anonymized in place (provider 'erased' — it drops out of
|
|
1427
|
+
* every account surface; the tombstone keeps the audit chain's
|
|
1428
|
+
* entity_id resolvable). Answers the counts, null when absent. */
|
|
1429
|
+
async eraseOpAccount(userId: string): Promise<OpAccountErasure | null> {
|
|
1430
|
+
await this.ensureMembershipSupport()
|
|
1431
|
+
const row = await this.stmt('SELECT 1 AS ok FROM users WHERE id = ?', userId).first<{ ok: number }>()
|
|
1432
|
+
if (!row) return null
|
|
1433
|
+
const revoked = await this.revokeOpUserCredentials(userId)
|
|
1434
|
+
const links = await this.stmt('DELETE FROM identity_links WHERE user_id = ?', userId).run()
|
|
1435
|
+
const clientRoles = await this.stmt('DELETE FROM op_client_roles WHERE user_id = ?', userId).run()
|
|
1436
|
+
// TODO.identity/11: the memberships go too (the tombstone acts for
|
|
1437
|
+
// no organization).
|
|
1438
|
+
const memberships = await this.stmt('DELETE FROM org_memberships WHERE user_id = ?', userId).run()
|
|
1439
|
+
const passwords = await this.stmt('DELETE FROM passwords WHERE user_id = ?', userId).run()
|
|
1440
|
+
const enrollments = await this.stmt('DELETE FROM enrollment_tokens WHERE user_id = ?', userId).run()
|
|
1441
|
+
const emailChanges = await this.stmt('DELETE FROM email_change_tokens WHERE user_id = ?', userId).run()
|
|
1442
|
+
// TODO.identity-sso/02+03: the factor registry follows the account
|
|
1443
|
+
// into erasure (passkeys, TOTP secrets, recovery codes, pending
|
|
1444
|
+
// ceremony state).
|
|
1445
|
+
const passkeys = await this.stmt('DELETE FROM webauthn_credentials WHERE user_id = ?', userId).run()
|
|
1446
|
+
const totp = await this.stmt('DELETE FROM totp_secrets WHERE user_id = ?', userId).run()
|
|
1447
|
+
const recovery = await this.stmt('DELETE FROM recovery_codes WHERE user_id = ?', userId).run()
|
|
1448
|
+
const challenges = await this.stmt('DELETE FROM webauthn_challenges WHERE user_id = ?', userId).run()
|
|
1449
|
+
const mfa = await this.stmt('DELETE FROM mfa_pending WHERE user_id = ?', userId).run()
|
|
1450
|
+
await this.stmt(
|
|
1451
|
+
`UPDATE users SET
|
|
1452
|
+
email = ?, name = 'Deleted account', provider = 'erased',
|
|
1453
|
+
role = 'viewer', roles = NULL, org_id = NULL,
|
|
1454
|
+
avatar_url = NULL, email_verified_at = NULL, active = 0
|
|
1455
|
+
WHERE id = ?`,
|
|
1456
|
+
`deleted-${userId}@erased.invalid`, userId,
|
|
1457
|
+
).run()
|
|
1458
|
+
return {
|
|
1459
|
+
...revoked,
|
|
1460
|
+
links: links.meta.changes ?? 0,
|
|
1461
|
+
clientRoles: clientRoles.meta.changes ?? 0,
|
|
1462
|
+
memberships: memberships.meta.changes ?? 0,
|
|
1463
|
+
tokens: (passwords.meta.changes ?? 0) + (enrollments.meta.changes ?? 0) + (emailChanges.meta.changes ?? 0),
|
|
1464
|
+
factors: (passkeys.meta.changes ?? 0) + (totp.meta.changes ?? 0) + (recovery.meta.changes ?? 0)
|
|
1465
|
+
+ (challenges.meta.changes ?? 0) + (mfa.meta.changes ?? 0),
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/** The registry's edit act (name/email). The email UNIQUE conflict
|
|
1470
|
+
* throws 'unique' (the route maps it to a 409, never a silent take). */
|
|
1471
|
+
async updateOpAccount(id: string, input: { name?: string; email?: string }): Promise<boolean> {
|
|
1472
|
+
if (input.email !== undefined) {
|
|
1473
|
+
try {
|
|
1474
|
+
// TODO.identity/06: an admin-set address never went through the
|
|
1475
|
+
// verify-new-email ceremony, so the verification state resets.
|
|
1476
|
+
await this.stmt('UPDATE users SET email = ?, email_verified_at = NULL WHERE id = ?', input.email.trim().toLowerCase(), id).run()
|
|
1477
|
+
} catch (e) {
|
|
1478
|
+
if (String((e as Error).message).includes('UNIQUE')) throw new Error(`unique: ${input.email}`)
|
|
1479
|
+
throw e
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
if (input.name !== undefined) {
|
|
1483
|
+
await this.stmt('UPDATE users SET name = ? WHERE id = ?', input.name.trim(), id).run()
|
|
1484
|
+
}
|
|
1485
|
+
const row = await this.stmt('SELECT 1 AS ok FROM users WHERE id = ?', id).first<{ ok: number }>()
|
|
1486
|
+
return !!row
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/** The last OP-side sign-in per account, FROM THE AUDIT CHAIN: the
|
|
1490
|
+
* newest auditEvents row whose action is a sign-in
|
|
1491
|
+
* ('account.sign_in' / 'upstream_sign_in') per entity_id. */
|
|
1492
|
+
async lastAccountSignIns(): Promise<Record<string, string>> {
|
|
1493
|
+
const res = await this.stmt(
|
|
1494
|
+
`SELECT data FROM entities
|
|
1495
|
+
WHERE store = 'auditEvents'
|
|
1496
|
+
AND (data LIKE '%"action":"account.sign_in"%' OR data LIKE '%"action":"upstream_sign_in"%')`,
|
|
1497
|
+
).all<{ data: string }>()
|
|
1498
|
+
const out: Record<string, string> = {}
|
|
1499
|
+
for (const { data } of res.results) {
|
|
1500
|
+
try {
|
|
1501
|
+
const event = JSON.parse(data) as { entity_id?: string; timestamp?: string }
|
|
1502
|
+
if (typeof event.entity_id !== 'string' || typeof event.timestamp !== 'string') continue
|
|
1503
|
+
if (!out[event.entity_id] || event.timestamp > out[event.entity_id]!) {
|
|
1504
|
+
out[event.entity_id] = event.timestamp
|
|
1505
|
+
}
|
|
1506
|
+
} catch { /* a malformed audit row is skipped, never trusted */ }
|
|
1507
|
+
}
|
|
1508
|
+
return out
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// ── the account console (TODO.identity/06) ─────────────────────────
|
|
1512
|
+
|
|
1513
|
+
/** The profile edit's write (the display name). */
|
|
1514
|
+
async updateUserName(userId: string, name: string): Promise<boolean> {
|
|
1515
|
+
const res = await this.stmt('UPDATE users SET name = ? WHERE id = ?', name.trim(), userId).run()
|
|
1516
|
+
return (res.meta.changes ?? 0) > 0
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/** The avatar write (the account console's upload/remove; NULL = the
|
|
1520
|
+
* initials). */
|
|
1521
|
+
async setUserAvatar(userId: string, avatarUrl: string | null): Promise<boolean> {
|
|
1522
|
+
const res = await this.stmt('UPDATE users SET avatar_url = ? WHERE id = ?', avatarUrl, userId).run()
|
|
1523
|
+
return (res.meta.changes ?? 0) > 0
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/** Remove the account's password credential (the route holds the
|
|
1527
|
+
* at-least-one-method guard). */
|
|
1528
|
+
async deletePasswordHash(userId: string): Promise<boolean> {
|
|
1529
|
+
const res = await this.stmt('DELETE FROM passwords WHERE user_id = ?', userId).run()
|
|
1530
|
+
return (res.meta.changes ?? 0) > 0
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/** Revoke every session of the account EXCEPT the presenting one. */
|
|
1534
|
+
async deleteOtherSessions(userId: string, keepToken: string): Promise<number> {
|
|
1535
|
+
const res = await this.stmt('DELETE FROM sessions WHERE user_id = ? AND token != ?', userId, keepToken).run()
|
|
1536
|
+
return res.meta.changes ?? 0
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
private static toEmailChangeToken(row: Record<string, unknown>): EmailChangeToken {
|
|
1540
|
+
return {
|
|
1541
|
+
token: row.token as string,
|
|
1542
|
+
userId: row.user_id as string,
|
|
1543
|
+
newEmail: row.new_email as string,
|
|
1544
|
+
deliveredBy: row.delivered_by === 'mailer' ? 'mailer' : 'shown',
|
|
1545
|
+
createdAt: row.created_at as string,
|
|
1546
|
+
expiresAt: row.expires_at as string,
|
|
1547
|
+
consumedAt: (row.consumed_at as string | null) ?? null,
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/** Mint the ceremony's token; the account's earlier pending rows are
|
|
1552
|
+
* VOIDED first (only the newest link works). */
|
|
1553
|
+
async createEmailChangeToken(input: {
|
|
1554
|
+
token: string
|
|
1555
|
+
userId: string
|
|
1556
|
+
newEmail: string
|
|
1557
|
+
deliveredBy: 'mailer' | 'shown'
|
|
1558
|
+
ttlMs: number
|
|
1559
|
+
}): Promise<EmailChangeToken> {
|
|
1560
|
+
await this.stmt(
|
|
1561
|
+
"UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND consumed_at IS NULL",
|
|
1562
|
+
input.userId,
|
|
1563
|
+
).run()
|
|
1564
|
+
const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
|
|
1565
|
+
await this.stmt(
|
|
1566
|
+
'INSERT INTO email_change_tokens (token, user_id, new_email, delivered_by, expires_at) VALUES (?, ?, ?, ?, ?)',
|
|
1567
|
+
input.token, input.userId, input.newEmail.trim().toLowerCase(), input.deliveredBy, expiresAt,
|
|
1568
|
+
).run()
|
|
1569
|
+
return (await this.getEmailChangeToken(input.token))!
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
async getEmailChangeToken(token: string): Promise<EmailChangeToken | null> {
|
|
1573
|
+
const row = await this.stmt('SELECT * FROM email_change_tokens WHERE token = ?', token).first<Record<string, unknown>>()
|
|
1574
|
+
return row ? D1ServerStore.toEmailChangeToken(row) : null
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/** The account's pending change (the newest live row), so the console
|
|
1578
|
+
* can show it. */
|
|
1579
|
+
async getPendingEmailChange(userId: string): Promise<EmailChangeToken | null> {
|
|
1580
|
+
const row = await this.stmt(
|
|
1581
|
+
`SELECT * FROM email_change_tokens
|
|
1582
|
+
WHERE user_id = ? AND consumed_at IS NULL AND expires_at > datetime('now')
|
|
1583
|
+
ORDER BY created_at DESC LIMIT 1`,
|
|
1584
|
+
userId,
|
|
1585
|
+
).first<Record<string, unknown>>()
|
|
1586
|
+
return row ? D1ServerStore.toEmailChangeToken(row) : null
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/** Complete the ceremony: consume ATOMICALLY (a presented link works
|
|
1590
|
+
* exactly once, expired or not), judge the expiry, re-check the
|
|
1591
|
+
* address's uniqueness (a conflict burns the token honestly), then
|
|
1592
|
+
* move the account's email. A 'mailer'-delivered token verifies the
|
|
1593
|
+
* address; a shown one never does. */
|
|
1594
|
+
async completeEmailChange(token: string): Promise<CompleteEmailChangeResult> {
|
|
1595
|
+
const res = await this.stmt(
|
|
1596
|
+
"UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL", token,
|
|
1597
|
+
).run()
|
|
1598
|
+
if ((res.meta.changes ?? 0) === 0) return { kind: 'unknown' }
|
|
1599
|
+
const row = (await this.getEmailChangeToken(token))!
|
|
1600
|
+
if (new Date(row.expiresAt).getTime() <= Date.now()) return { kind: 'expired' }
|
|
1601
|
+
const taken = await this.stmt('SELECT id FROM users WHERE email = ?', row.newEmail).first<{ id: string }>()
|
|
1602
|
+
if (taken) return { kind: 'conflict' }
|
|
1603
|
+
const verified = row.deliveredBy === 'mailer'
|
|
1604
|
+
await this.stmt(
|
|
1605
|
+
`UPDATE users SET email = ?, email_verified_at = ${verified ? "datetime('now')" : 'NULL'} WHERE id = ?`,
|
|
1606
|
+
row.newEmail, row.userId,
|
|
1607
|
+
).run()
|
|
1608
|
+
return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// ── strong authentication: the factor registry (TODO.identity-sso/02 + /03) ──
|
|
1612
|
+
// The same SQL as the SQLite half (store/sqlite/factors-store.ts): the
|
|
1613
|
+
// one-time consumes are guarded UPDATEs, the counter advance is the
|
|
1614
|
+
// clone-guarded UPDATE, the throttles ride the rows — the database is
|
|
1615
|
+
// the proof, never a per-isolate Map.
|
|
1616
|
+
|
|
1617
|
+
async createWebauthnChallenge(input: {
|
|
1618
|
+
challenge: string
|
|
1619
|
+
userId: string | null
|
|
1620
|
+
kind: WebauthnChallenge['kind']
|
|
1621
|
+
ttlMs: number
|
|
1622
|
+
}): Promise<void> {
|
|
1623
|
+
const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
|
|
1624
|
+
// The sweep rides the write (the putSsoState pattern).
|
|
1625
|
+
await this.stmt("DELETE FROM webauthn_challenges WHERE expires_at <= datetime('now')").run()
|
|
1626
|
+
await this.stmt(
|
|
1627
|
+
'INSERT INTO webauthn_challenges (challenge, user_id, kind, expires_at) VALUES (?, ?, ?, ?)',
|
|
1628
|
+
input.challenge, input.userId, input.kind, expiresAt,
|
|
1629
|
+
).run()
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
async consumeWebauthnChallenge(challenge: string): Promise<WebauthnChallenge | null> {
|
|
1633
|
+
const res = await this.stmt(
|
|
1634
|
+
"UPDATE webauthn_challenges SET consumed_at = datetime('now') WHERE challenge = ? AND consumed_at IS NULL", challenge,
|
|
1635
|
+
).run()
|
|
1636
|
+
if ((res.meta.changes ?? 0) === 0) return null
|
|
1637
|
+
const row = await this.stmt('SELECT * FROM webauthn_challenges WHERE challenge = ?', challenge).first<Record<string, unknown>>()
|
|
1638
|
+
if (!row) return null
|
|
1639
|
+
if (new Date(row.expires_at as string).getTime() <= Date.now()) return null
|
|
1640
|
+
return D1ServerStore.toWebauthnChallenge(row)
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
async createWebauthnCredential(input: {
|
|
1644
|
+
credentialId: string
|
|
1645
|
+
userId: string
|
|
1646
|
+
name: string
|
|
1647
|
+
publicKeyCose: string
|
|
1648
|
+
signCount: number
|
|
1649
|
+
aaguid: string | null
|
|
1650
|
+
transports: string[]
|
|
1651
|
+
ip?: string | null
|
|
1652
|
+
}): Promise<WebauthnCredential | null> {
|
|
1653
|
+
try {
|
|
1654
|
+
await this.stmt(
|
|
1655
|
+
`INSERT INTO webauthn_credentials
|
|
1656
|
+
(credential_id, user_id, name, public_key, sign_count, aaguid, transports, last_ip)
|
|
1657
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1658
|
+
input.credentialId, input.userId, input.name, input.publicKeyCose,
|
|
1659
|
+
Math.max(0, Math.floor(input.signCount)), input.aaguid, JSON.stringify(input.transports),
|
|
1660
|
+
input.ip ?? null,
|
|
1661
|
+
).run()
|
|
1662
|
+
} catch (e) {
|
|
1663
|
+
if (String((e as Error).message).includes('UNIQUE')) return null
|
|
1664
|
+
throw e
|
|
1665
|
+
}
|
|
1666
|
+
return this.getWebauthnCredential(input.credentialId)
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
async listWebauthnCredentials(userId: string): Promise<WebauthnCredential[]> {
|
|
1670
|
+
const res = await this.stmt(
|
|
1671
|
+
'SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at, credential_id', userId,
|
|
1672
|
+
).all<Record<string, unknown>>()
|
|
1673
|
+
return res.results.map(D1ServerStore.toWebauthnCredential)
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
async getWebauthnCredential(credentialId: string): Promise<WebauthnCredential | null> {
|
|
1677
|
+
const row = await this.stmt(
|
|
1678
|
+
'SELECT * FROM webauthn_credentials WHERE credential_id = ?', credentialId,
|
|
1679
|
+
).first<Record<string, unknown>>()
|
|
1680
|
+
return row ? D1ServerStore.toWebauthnCredential(row) : null
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
async deleteWebauthnCredential(userId: string, credentialId: string): Promise<boolean> {
|
|
1684
|
+
const res = await this.stmt(
|
|
1685
|
+
'DELETE FROM webauthn_credentials WHERE credential_id = ? AND user_id = ?', credentialId, userId,
|
|
1686
|
+
).run()
|
|
1687
|
+
return (res.meta.changes ?? 0) > 0
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
async advanceWebauthnCounter(credentialId: string, newCount: number, opts?: { ip?: string | null }): Promise<AdvanceCounterResult> {
|
|
1691
|
+
const count = Math.max(0, Math.floor(newCount))
|
|
1692
|
+
const res = await this.stmt(
|
|
1693
|
+
`UPDATE webauthn_credentials
|
|
1694
|
+
SET sign_count = ?, last_used_at = datetime('now'), last_ip = ?
|
|
1695
|
+
WHERE credential_id = ? AND ((sign_count = 0 AND ? = 0) OR sign_count < ?)`,
|
|
1696
|
+
count, opts?.ip ?? null, credentialId, count, count,
|
|
1697
|
+
).run()
|
|
1698
|
+
if ((res.meta.changes ?? 0) > 0) return 'ok'
|
|
1699
|
+
return (await this.getWebauthnCredential(credentialId)) ? 'regressed' : 'unknown'
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
async createTotpSecret(input: { id: string; userId: string; name: string; secret: string }): Promise<TotpSecret> {
|
|
1703
|
+
await this.stmt(
|
|
1704
|
+
'INSERT INTO totp_secrets (id, user_id, name, secret) VALUES (?, ?, ?, ?)',
|
|
1705
|
+
input.id, input.userId, input.name, input.secret,
|
|
1706
|
+
).run()
|
|
1707
|
+
return (await this.getTotpSecret(input.id))!
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
async listTotpSecrets(userId: string): Promise<TotpSecret[]> {
|
|
1711
|
+
const res = await this.stmt(
|
|
1712
|
+
'SELECT * FROM totp_secrets WHERE user_id = ? ORDER BY created_at, id', userId,
|
|
1713
|
+
).all<Record<string, unknown>>()
|
|
1714
|
+
return res.results.map(D1ServerStore.toTotpSecret)
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
async getTotpSecret(id: string): Promise<TotpSecret | null> {
|
|
1718
|
+
const row = await this.stmt('SELECT * FROM totp_secrets WHERE id = ?', id).first<Record<string, unknown>>()
|
|
1719
|
+
return row ? D1ServerStore.toTotpSecret(row) : null
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
async markTotpSecretVerified(id: string, userId: string, name: string): Promise<boolean> {
|
|
1723
|
+
const res = await this.stmt(
|
|
1724
|
+
"UPDATE totp_secrets SET verified_at = datetime('now'), name = ? WHERE id = ? AND user_id = ? AND verified_at IS NULL",
|
|
1725
|
+
name, id, userId,
|
|
1726
|
+
).run()
|
|
1727
|
+
return (res.meta.changes ?? 0) > 0
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
async recordTotpEnrollFailure(id: string, userId: string): Promise<number> {
|
|
1731
|
+
const res = await this.stmt(
|
|
1732
|
+
"UPDATE totp_secrets SET fail_count = fail_count + 1, last_failure_at = datetime('now') WHERE id = ? AND user_id = ? AND verified_at IS NULL",
|
|
1733
|
+
id, userId,
|
|
1734
|
+
).run()
|
|
1735
|
+
if ((res.meta.changes ?? 0) === 0) return 0
|
|
1736
|
+
const row = await this.stmt('SELECT fail_count AS n FROM totp_secrets WHERE id = ?', id).first<{ n: number }>()
|
|
1737
|
+
return row?.n ?? 0
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
async markTotpSecretUsed(id: string, opts?: { ip?: string | null }): Promise<void> {
|
|
1741
|
+
await this.stmt(
|
|
1742
|
+
"UPDATE totp_secrets SET last_used_at = datetime('now'), last_ip = ? WHERE id = ?",
|
|
1743
|
+
opts?.ip ?? null, id,
|
|
1744
|
+
).run()
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
async deleteTotpSecret(userId: string, id: string): Promise<boolean> {
|
|
1748
|
+
const res = await this.stmt('DELETE FROM totp_secrets WHERE id = ? AND user_id = ?', id, userId).run()
|
|
1749
|
+
return (res.meta.changes ?? 0) > 0
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/** The regenerate: the old batch goes, the new hashes land — D1's
|
|
1753
|
+
* batch is all-or-nothing (the sqlite half's transaction). */
|
|
1754
|
+
async replaceRecoveryCodes(userId: string, batch: string, codeHashes: string[]): Promise<void> {
|
|
1755
|
+
const statements: D1PreparedStatement[] = [
|
|
1756
|
+
this.stmt('DELETE FROM recovery_codes WHERE user_id = ?', userId),
|
|
1757
|
+
...codeHashes.map(hash => this.stmt(
|
|
1758
|
+
'INSERT INTO recovery_codes (id, user_id, batch, code_hash) VALUES (?, ?, ?, ?)',
|
|
1759
|
+
crypto.randomUUID(), userId, batch, hash,
|
|
1760
|
+
)),
|
|
1761
|
+
]
|
|
1762
|
+
await this.db.batch(statements)
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
async recoveryCodeState(userId: string): Promise<RecoveryCodeState> {
|
|
1766
|
+
const row = await this.stmt(
|
|
1767
|
+
`SELECT COUNT(*) AS total,
|
|
1768
|
+
SUM(CASE WHEN consumed_at IS NULL THEN 1 ELSE 0 END) AS remaining,
|
|
1769
|
+
MAX(created_at) AS created_at
|
|
1770
|
+
FROM recovery_codes WHERE user_id = ?`, userId,
|
|
1771
|
+
).first<{ total: number; remaining: number | null; created_at: string | null }>()
|
|
1772
|
+
return {
|
|
1773
|
+
total: row?.total ?? 0,
|
|
1774
|
+
remaining: row?.remaining ?? 0,
|
|
1775
|
+
createdAt: (row?.total ?? 0) > 0 ? (row?.created_at ?? null) : null,
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
async consumeRecoveryCode(userId: string, codeHash: string): Promise<boolean> {
|
|
1780
|
+
const res = await this.stmt(
|
|
1781
|
+
"UPDATE recovery_codes SET consumed_at = datetime('now') WHERE user_id = ? AND code_hash = ? AND consumed_at IS NULL",
|
|
1782
|
+
userId, codeHash,
|
|
1783
|
+
).run()
|
|
1784
|
+
return (res.meta.changes ?? 0) > 0
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
async createMfaPending(input: { token: string; userId: string; amr: string[]; ttlMs: number }): Promise<void> {
|
|
1788
|
+
const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
|
|
1789
|
+
await this.stmt("DELETE FROM mfa_pending WHERE expires_at <= datetime('now')").run()
|
|
1790
|
+
await this.stmt(
|
|
1791
|
+
'INSERT INTO mfa_pending (token, user_id, amr, expires_at) VALUES (?, ?, ?, ?)',
|
|
1792
|
+
input.token, input.userId, JSON.stringify(input.amr), expiresAt,
|
|
1793
|
+
).run()
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
async getMfaPending(token: string): Promise<MfaPending | null> {
|
|
1797
|
+
const row = await this.stmt('SELECT * FROM mfa_pending WHERE token = ?', token).first<Record<string, unknown>>()
|
|
1798
|
+
return row ? D1ServerStore.toMfaPending(row) : null
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
async consumeMfaPending(token: string): Promise<MfaPending | null> {
|
|
1802
|
+
const res = await this.stmt(
|
|
1803
|
+
"UPDATE mfa_pending SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL", token,
|
|
1804
|
+
).run()
|
|
1805
|
+
if ((res.meta.changes ?? 0) === 0) return null
|
|
1806
|
+
const row = await this.stmt('SELECT * FROM mfa_pending WHERE token = ?', token).first<Record<string, unknown>>()
|
|
1807
|
+
if (!row) return null
|
|
1808
|
+
if (new Date(row.expires_at as string).getTime() <= Date.now()) return null
|
|
1809
|
+
return D1ServerStore.toMfaPending(row)
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
async recordMfaPendingFailure(token: string): Promise<MfaPending | null> {
|
|
1813
|
+
const res = await this.stmt(
|
|
1814
|
+
"UPDATE mfa_pending SET fail_count = fail_count + 1, last_failure_at = datetime('now') WHERE token = ? AND consumed_at IS NULL",
|
|
1815
|
+
token,
|
|
1816
|
+
).run()
|
|
1817
|
+
if ((res.meta.changes ?? 0) === 0) return null
|
|
1818
|
+
const row = await this.stmt('SELECT * FROM mfa_pending WHERE token = ?', token).first<Record<string, unknown>>()
|
|
1819
|
+
return row ? D1ServerStore.toMfaPending(row) : null
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// ── organization administration (TODO.identity/10) ────────────────
|
|
1823
|
+
|
|
1824
|
+
/** The store's time columns arrive in two shapes (datetime('now')'s
|
|
1825
|
+
* naive UTC 'YYYY-MM-DD HH:MM:SS' from the DEFAULT writes, and the ISO
|
|
1826
|
+
* strings the code paths write); the API answers ISO always —
|
|
1827
|
+
* Date.parse would read the naive shape as LOCAL time and the routes'
|
|
1828
|
+
* age math would misfire off-UTC. */
|
|
1829
|
+
private static storeTimeToIso(value: string | null): string | null {
|
|
1830
|
+
if (value === null) return null
|
|
1831
|
+
if (value.includes('T')) return value
|
|
1832
|
+
return value.replace(' ', 'T') + 'Z'
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
private static toWebauthnCredential(row: Record<string, unknown>): WebauthnCredential {
|
|
1836
|
+
return {
|
|
1837
|
+
credentialId: row.credential_id as string,
|
|
1838
|
+
userId: row.user_id as string,
|
|
1839
|
+
name: row.name as string,
|
|
1840
|
+
publicKeyCose: row.public_key as string,
|
|
1841
|
+
signCount: Number(row.sign_count ?? 0),
|
|
1842
|
+
aaguid: (row.aaguid as string | null) ?? null,
|
|
1843
|
+
transports: parseRoles((row.transports as string | null) ?? null) ?? [],
|
|
1844
|
+
createdAt: D1ServerStore.storeTimeToIso(row.created_at as string)!,
|
|
1845
|
+
lastUsedAt: D1ServerStore.storeTimeToIso((row.last_used_at as string | null) ?? null),
|
|
1846
|
+
lastIp: (row.last_ip as string | null) ?? null,
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
private static toTotpSecret(row: Record<string, unknown>): TotpSecret {
|
|
1851
|
+
return {
|
|
1852
|
+
id: row.id as string,
|
|
1853
|
+
userId: row.user_id as string,
|
|
1854
|
+
name: row.name as string,
|
|
1855
|
+
secret: row.secret as string,
|
|
1856
|
+
failCount: Number(row.fail_count ?? 0),
|
|
1857
|
+
lastFailureAt: D1ServerStore.storeTimeToIso((row.last_failure_at as string | null) ?? null),
|
|
1858
|
+
createdAt: D1ServerStore.storeTimeToIso(row.created_at as string)!,
|
|
1859
|
+
verifiedAt: D1ServerStore.storeTimeToIso((row.verified_at as string | null) ?? null),
|
|
1860
|
+
lastUsedAt: D1ServerStore.storeTimeToIso((row.last_used_at as string | null) ?? null),
|
|
1861
|
+
lastIp: (row.last_ip as string | null) ?? null,
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
private static toWebauthnChallenge(row: Record<string, unknown>): WebauthnChallenge {
|
|
1866
|
+
return {
|
|
1867
|
+
challenge: row.challenge as string,
|
|
1868
|
+
userId: (row.user_id as string | null) ?? null,
|
|
1869
|
+
kind: row.kind as WebauthnChallenge['kind'],
|
|
1870
|
+
createdAt: D1ServerStore.storeTimeToIso(row.created_at as string)!,
|
|
1871
|
+
expiresAt: D1ServerStore.storeTimeToIso(row.expires_at as string)!,
|
|
1872
|
+
consumedAt: D1ServerStore.storeTimeToIso((row.consumed_at as string | null) ?? null),
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
private static toMfaPending(row: Record<string, unknown>): MfaPending {
|
|
1877
|
+
return {
|
|
1878
|
+
token: row.token as string,
|
|
1879
|
+
userId: row.user_id as string,
|
|
1880
|
+
amr: parseRoles((row.amr as string | null) ?? null) ?? [],
|
|
1881
|
+
failCount: Number(row.fail_count ?? 0),
|
|
1882
|
+
lastFailureAt: D1ServerStore.storeTimeToIso((row.last_failure_at as string | null) ?? null),
|
|
1883
|
+
createdAt: D1ServerStore.storeTimeToIso(row.created_at as string)!,
|
|
1884
|
+
expiresAt: D1ServerStore.storeTimeToIso(row.expires_at as string)!,
|
|
1885
|
+
consumedAt: D1ServerStore.storeTimeToIso((row.consumed_at as string | null) ?? null),
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
private static toOrgJoinRequest(row: Record<string, unknown>): OrgJoinRequest {
|
|
1890
|
+
return {
|
|
1891
|
+
id: row.id as string,
|
|
1892
|
+
name: row.name as string,
|
|
1893
|
+
email: row.email as string,
|
|
1894
|
+
orgId: (row.org_id as string | null) ?? null,
|
|
1895
|
+
orgNameText: (row.org_name_text as string | null) ?? null,
|
|
1896
|
+
requestedRole: row.requested_role as string,
|
|
1897
|
+
note: (row.note as string | null) ?? null,
|
|
1898
|
+
status: row.status as OrgJoinRequest['status'],
|
|
1899
|
+
decidedBy: (row.decided_by as string | null) ?? null,
|
|
1900
|
+
decidedAt: (row.decided_at as string | null) ?? null,
|
|
1901
|
+
refusalReason: (row.refusal_reason as string | null) ?? null,
|
|
1902
|
+
invitedUserId: (row.invited_user_id as string | null) ?? null,
|
|
1903
|
+
createdAt: row.created_at as string,
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
async createOrgJoinRequest(input: {
|
|
1908
|
+
name: string
|
|
1909
|
+
email: string
|
|
1910
|
+
orgId: string | null
|
|
1911
|
+
orgNameText: string | null
|
|
1912
|
+
requestedRole: string
|
|
1913
|
+
note?: string | null
|
|
1914
|
+
}): Promise<OrgJoinRequest> {
|
|
1915
|
+
const id = crypto.randomUUID()
|
|
1916
|
+
await this.stmt(
|
|
1917
|
+
`INSERT INTO org_join_requests (id, name, email, org_id, org_name_text, requested_role, note)
|
|
1918
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
1919
|
+
id, input.name, input.email, input.orgId, input.orgNameText, input.requestedRole, input.note ?? null,
|
|
1920
|
+
).run()
|
|
1921
|
+
return (await this.getOrgJoinRequest(id))!
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
async getOrgJoinRequest(id: string): Promise<OrgJoinRequest | null> {
|
|
1925
|
+
const row = await this.stmt('SELECT * FROM org_join_requests WHERE id = ?', id).first<Record<string, unknown>>()
|
|
1926
|
+
return row ? D1ServerStore.toOrgJoinRequest(row) : null
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
async listOrgJoinRequests(filter?: {
|
|
1930
|
+
scope?: 'org' | 'unregistered' | 'all'
|
|
1931
|
+
orgId?: string
|
|
1932
|
+
status?: OrgJoinRequest['status']
|
|
1933
|
+
}): Promise<OrgJoinRequest[]> {
|
|
1934
|
+
const scope = filter?.scope ?? 'all'
|
|
1935
|
+
const where: string[] = []
|
|
1936
|
+
const args: unknown[] = []
|
|
1937
|
+
if (scope === 'org') { where.push('org_id = ?'); args.push(filter?.orgId ?? '') }
|
|
1938
|
+
if (scope === 'unregistered') where.push('org_id IS NULL')
|
|
1939
|
+
if (filter?.status) { where.push('status = ?'); args.push(filter.status) }
|
|
1940
|
+
const sql = `SELECT * FROM org_join_requests${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at`
|
|
1941
|
+
const res = await this.stmt(sql, ...args).all<Record<string, unknown>>()
|
|
1942
|
+
return res.results.map(D1ServerStore.toOrgJoinRequest)
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
/** The decision — atomic on 'pending' (a double decide loses honestly). */
|
|
1946
|
+
async decideOrgJoinRequest(
|
|
1947
|
+
id: string,
|
|
1948
|
+
decision: {
|
|
1949
|
+
status: 'approved' | 'refused'
|
|
1950
|
+
decidedBy: string
|
|
1951
|
+
refusalReason?: string | null
|
|
1952
|
+
invitedUserId?: string | null
|
|
1953
|
+
},
|
|
1954
|
+
): Promise<OrgJoinRequest | null> {
|
|
1955
|
+
const res = await this.stmt(
|
|
1956
|
+
`UPDATE org_join_requests
|
|
1957
|
+
SET status = ?, decided_by = ?, decided_at = datetime('now'), refusal_reason = ?, invited_user_id = ?
|
|
1958
|
+
WHERE id = ? AND status = 'pending'`,
|
|
1959
|
+
decision.status, decision.decidedBy, decision.refusalReason ?? null, decision.invitedUserId ?? null, id,
|
|
1960
|
+
).run()
|
|
1961
|
+
if ((res.meta.changes ?? 0) === 0) return null
|
|
1962
|
+
return this.getOrgJoinRequest(id)
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
async findPendingOrgJoinRequestByEmail(email: string): Promise<OrgJoinRequest | null> {
|
|
1966
|
+
const row = await this.stmt(
|
|
1967
|
+
"SELECT * FROM org_join_requests WHERE email = ? AND status = 'pending' ORDER BY created_at", email,
|
|
1968
|
+
).first<Record<string, unknown>>()
|
|
1969
|
+
return row ? D1ServerStore.toOrgJoinRequest(row) : null
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
// ── organization memberships (TODO.identity/11 — the multi-org model) ──
|
|
1973
|
+
// The rows port directly (D1 is SQLite) — the same statements as the
|
|
1974
|
+
// SQLite store's membership section. THE DUAL-READ DOCTRINE: the users
|
|
1975
|
+
// row's org_id/roles columns stay the backward-compatible read (the
|
|
1976
|
+
// PRIMARY membership's mirror); the mirror rides every legacy writer.
|
|
1977
|
+
|
|
1978
|
+
private static toOrgMembership(row: Record<string, unknown>): OrgMembership {
|
|
1979
|
+
let roles: string[] = []
|
|
1980
|
+
try {
|
|
1981
|
+
const parsed = JSON.parse(row.roles as string) as unknown
|
|
1982
|
+
if (Array.isArray(parsed)) roles = parsed.filter((v): v is string => typeof v === 'string')
|
|
1983
|
+
} catch { /* a malformed roles cell reads as the empty set */ }
|
|
1984
|
+
return {
|
|
1985
|
+
id: row.id as string,
|
|
1986
|
+
userId: row.user_id as string,
|
|
1987
|
+
orgId: row.org_id as string,
|
|
1988
|
+
roles,
|
|
1989
|
+
state: row.state as OrgMembershipState,
|
|
1990
|
+
isPrimary: row.is_primary === 1,
|
|
1991
|
+
invitedBy: (row.invited_by as string | null) ?? null,
|
|
1992
|
+
createdAt: row.created_at as string,
|
|
1993
|
+
activatedAt: (row.activated_at as string | null) ?? null,
|
|
1994
|
+
disabledAt: (row.disabled_at as string | null) ?? null,
|
|
1995
|
+
disabledBy: (row.disabled_by as string | null) ?? null,
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
/** THE MIRROR (the dual-read doctrine's write half): re-project the
|
|
2000
|
+
* PRIMARY membership from the users row's legacy columns. A DISABLED
|
|
2001
|
+
* row keeps its state (only roles + the primary mark move). */
|
|
2002
|
+
private async syncPrimaryMembership(userId: string): Promise<void> {
|
|
2003
|
+
await this.ensureMembershipSupport()
|
|
2004
|
+
const user = await this.stmt('SELECT id, role, roles, org_id FROM users WHERE id = ?', userId)
|
|
2005
|
+
.first<{ id: string; role: string; roles: string | null; org_id: string | null }>()
|
|
2006
|
+
if (!user || !user.org_id) return
|
|
2007
|
+
const roles = parseRoles(user.roles) ?? [user.role]
|
|
2008
|
+
await this.db.batch([
|
|
2009
|
+
this.stmt('UPDATE org_memberships SET is_primary = 0 WHERE user_id = ? AND org_id != ? AND is_primary = 1', userId, user.org_id),
|
|
2010
|
+
this.stmt(
|
|
2011
|
+
`INSERT INTO org_memberships (id, user_id, org_id, roles, state, is_primary, activated_at)
|
|
2012
|
+
VALUES (?, ?, ?, ?, 'active', 1, datetime('now'))
|
|
2013
|
+
ON CONFLICT (user_id, org_id) DO UPDATE SET roles = excluded.roles, is_primary = 1`,
|
|
2014
|
+
crypto.randomUUID(), userId, user.org_id, JSON.stringify(roles),
|
|
2015
|
+
),
|
|
2016
|
+
])
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
async listOrgMemberships(userId: string): Promise<OrgMembership[]> {
|
|
2020
|
+
await this.ensureMembershipSupport()
|
|
2021
|
+
const res = await this.stmt(
|
|
2022
|
+
'SELECT * FROM org_memberships WHERE user_id = ? ORDER BY is_primary DESC, created_at', userId,
|
|
2023
|
+
).all<Record<string, unknown>>()
|
|
2024
|
+
return res.results.map(D1ServerStore.toOrgMembership)
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
async listOrgMembers(orgId: string): Promise<OrgMembership[]> {
|
|
2028
|
+
await this.ensureMembershipSupport()
|
|
2029
|
+
const res = await this.stmt(
|
|
2030
|
+
'SELECT * FROM org_memberships WHERE org_id = ? ORDER BY created_at', orgId,
|
|
2031
|
+
).all<Record<string, unknown>>()
|
|
2032
|
+
return res.results.map(D1ServerStore.toOrgMembership)
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
async getOrgMembership(userId: string, orgId: string): Promise<OrgMembership | null> {
|
|
2036
|
+
await this.ensureMembershipSupport()
|
|
2037
|
+
const row = await this.stmt('SELECT * FROM org_memberships WHERE user_id = ? AND org_id = ?', userId, orgId)
|
|
2038
|
+
.first<Record<string, unknown>>()
|
|
2039
|
+
return row ? D1ServerStore.toOrgMembership(row) : null
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
/** Create the membership; NULL on the (user, org) conflict — the
|
|
2043
|
+
* honest "already a member". */
|
|
2044
|
+
async createOrgMembership(input: {
|
|
2045
|
+
userId: string
|
|
2046
|
+
orgId: string
|
|
2047
|
+
roles: string[]
|
|
2048
|
+
state: OrgMembershipState
|
|
2049
|
+
invitedBy?: string | null
|
|
2050
|
+
}): Promise<OrgMembership | null> {
|
|
2051
|
+
await this.ensureMembershipSupport()
|
|
2052
|
+
const res = await this.stmt(
|
|
2053
|
+
`INSERT OR IGNORE INTO org_memberships (id, user_id, org_id, roles, state, invited_by, activated_at)
|
|
2054
|
+
VALUES (?, ?, ?, ?, ?, ?, CASE WHEN ? = 'active' THEN datetime('now') ELSE NULL END)`,
|
|
2055
|
+
crypto.randomUUID(), input.userId, input.orgId, JSON.stringify(input.roles), input.state,
|
|
2056
|
+
input.invitedBy ?? null, input.state,
|
|
2057
|
+
).run()
|
|
2058
|
+
if ((res.meta.changes ?? 0) === 0) return null
|
|
2059
|
+
return this.getOrgMembership(input.userId, input.orgId)
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
/** Replace the per-org role set; the PRIMARY membership's write mirrors
|
|
2063
|
+
* into the users row (the dual-write — the legacy read stays
|
|
2064
|
+
* identical). */
|
|
2065
|
+
async setOrgMembershipRoles(userId: string, orgId: string, roles: string[]): Promise<boolean> {
|
|
2066
|
+
await this.ensureMembershipSupport()
|
|
2067
|
+
const res = await this.stmt('UPDATE org_memberships SET roles = ? WHERE user_id = ? AND org_id = ?',
|
|
2068
|
+
JSON.stringify(roles), userId, orgId).run()
|
|
2069
|
+
if ((res.meta.changes ?? 0) === 0) return false
|
|
2070
|
+
const membership = await this.getOrgMembership(userId, orgId)
|
|
2071
|
+
if (membership?.isPrimary) {
|
|
2072
|
+
const user = await this.stmt('SELECT role FROM users WHERE id = ?', userId).first<{ role: string }>()
|
|
2073
|
+
if (user) {
|
|
2074
|
+
const primaryRole = roles.includes(user.role) ? user.role : (roles[0] ?? user.role)
|
|
2075
|
+
await this.stmt('UPDATE users SET role = ?, roles = ? WHERE id = ?',
|
|
2076
|
+
primaryRole, JSON.stringify(roles.length ? roles : [primaryRole]), userId).run()
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
return true
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
/** The lifecycle act (stamps; disabling also clears the account's
|
|
2083
|
+
* sessions' active-org stamps pointing at the org). */
|
|
2084
|
+
async setOrgMembershipState(
|
|
2085
|
+
userId: string,
|
|
2086
|
+
orgId: string,
|
|
2087
|
+
state: OrgMembershipState,
|
|
2088
|
+
actor?: string | null,
|
|
2089
|
+
): Promise<OrgMembership | null> {
|
|
2090
|
+
await this.ensureMembershipSupport()
|
|
2091
|
+
const existing = await this.getOrgMembership(userId, orgId)
|
|
2092
|
+
if (!existing) return null
|
|
2093
|
+
if (state === 'active') {
|
|
2094
|
+
await this.stmt(
|
|
2095
|
+
"UPDATE org_memberships SET state = 'active', activated_at = datetime('now'), disabled_at = NULL, disabled_by = NULL WHERE user_id = ? AND org_id = ?",
|
|
2096
|
+
userId, orgId,
|
|
2097
|
+
).run()
|
|
2098
|
+
} else if (state === 'disabled') {
|
|
2099
|
+
await this.db.batch([
|
|
2100
|
+
this.stmt(
|
|
2101
|
+
"UPDATE org_memberships SET state = 'disabled', disabled_at = datetime('now'), disabled_by = ? WHERE user_id = ? AND org_id = ?",
|
|
2102
|
+
actor ?? null, userId, orgId,
|
|
2103
|
+
),
|
|
2104
|
+
this.stmt('UPDATE sessions SET active_org = NULL WHERE user_id = ? AND active_org = ?', userId, orgId),
|
|
2105
|
+
])
|
|
2106
|
+
} else {
|
|
2107
|
+
await this.stmt("UPDATE org_memberships SET state = 'invited' WHERE user_id = ? AND org_id = ?", userId, orgId).run()
|
|
2108
|
+
}
|
|
2109
|
+
return this.getOrgMembership(userId, orgId)
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/** Remove the row (the declined invitation; the erasure's cleanup). */
|
|
2113
|
+
async deleteOrgMembership(userId: string, orgId: string): Promise<boolean> {
|
|
2114
|
+
await this.ensureMembershipSupport()
|
|
2115
|
+
const res = await this.stmt('DELETE FROM org_memberships WHERE user_id = ? AND org_id = ?', userId, orgId).run()
|
|
2116
|
+
if ((res.meta.changes ?? 0) > 0) {
|
|
2117
|
+
await this.stmt('UPDATE sessions SET active_org = NULL WHERE user_id = ? AND active_org = ?', userId, orgId).run()
|
|
2118
|
+
}
|
|
2119
|
+
return (res.meta.changes ?? 0) > 0
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
/** The session's stamped active-org context (NULL = the primary
|
|
2123
|
+
* context; also NULL for an unknown/expired token). */
|
|
2124
|
+
async getSessionActiveOrg(token: string): Promise<string | null> {
|
|
2125
|
+
await this.ensureMembershipSupport()
|
|
2126
|
+
const row = await this.stmt("SELECT active_org FROM sessions WHERE token = ? AND expires_at > datetime('now')", token)
|
|
2127
|
+
.first<{ active_org: string | null }>()
|
|
2128
|
+
return row?.active_org ?? null
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
/** Stamp the session's active-org context; NULL clears to the primary
|
|
2132
|
+
* context. */
|
|
2133
|
+
async setSessionActiveOrg(token: string, orgId: string | null): Promise<boolean> {
|
|
2134
|
+
await this.ensureMembershipSupport()
|
|
2135
|
+
const res = await this.stmt("UPDATE sessions SET active_org = ? WHERE token = ? AND expires_at > datetime('now')",
|
|
2136
|
+
orgId, token).run()
|
|
2137
|
+
return (res.meta.changes ?? 0) > 0
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
// ── the organization registry (TODO.identity-features/05) ──────────
|
|
2141
|
+
// The identity service's OWN org registry — the rows the membership
|
|
2142
|
+
// graph references by id. The lifecycle acts are the routes'; these
|
|
2143
|
+
// mirror the SQLite store's registry section one-for-one.
|
|
2144
|
+
|
|
2145
|
+
private static toOrgRegistryOrg(row: Record<string, unknown>): OrgRegistryOrg {
|
|
2146
|
+
let contacts: OrgRegistryContact[] = []
|
|
2147
|
+
try {
|
|
2148
|
+
const parsed = JSON.parse((row.contacts as string) ?? '[]') as unknown
|
|
2149
|
+
if (Array.isArray(parsed)) {
|
|
2150
|
+
contacts = parsed
|
|
2151
|
+
.filter((e): e is Record<string, unknown> => !!e && typeof e === 'object')
|
|
2152
|
+
.map(e => ({ name: typeof e.name === 'string' && e.name.trim() ? e.name.trim() : null, email: typeof e.email === 'string' ? e.email.trim() : '' }))
|
|
2153
|
+
.filter(e => e.email.includes('@'))
|
|
2154
|
+
}
|
|
2155
|
+
} catch { /* a malformed contacts cell reads as the empty list */ }
|
|
2156
|
+
return {
|
|
2157
|
+
id: row.id as string,
|
|
2158
|
+
name: row.name as string,
|
|
2159
|
+
shortName: (row.short_name as string | null) ?? null,
|
|
2160
|
+
kind: (row.kind as string | null) ?? null,
|
|
2161
|
+
country: (row.country as string | null) ?? null,
|
|
2162
|
+
contacts,
|
|
2163
|
+
participantRef: (row.participant_ref as string | null) ?? null,
|
|
2164
|
+
state: row.state as OrgRegistryState,
|
|
2165
|
+
createdAt: row.created_at as string,
|
|
2166
|
+
createdBy: (row.created_by as string | null) ?? null,
|
|
2167
|
+
updatedAt: (row.updated_at as string | null) ?? null,
|
|
2168
|
+
updatedBy: (row.updated_by as string | null) ?? null,
|
|
2169
|
+
disabledAt: (row.disabled_at as string | null) ?? null,
|
|
2170
|
+
disabledBy: (row.disabled_by as string | null) ?? null,
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
async listOrgRegistryOrgs(): Promise<OrgRegistryOrg[]> {
|
|
2175
|
+
await this.ensureOrgRegistrySupport()
|
|
2176
|
+
const res = await this.stmt('SELECT * FROM org_registry').all<Record<string, unknown>>()
|
|
2177
|
+
return res.results.map(D1ServerStore.toOrgRegistryOrg).sort((a, b) => a.name.localeCompare(b.name))
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
async getOrgRegistryOrg(id: string): Promise<OrgRegistryOrg | null> {
|
|
2181
|
+
await this.ensureOrgRegistrySupport()
|
|
2182
|
+
const row = await this.stmt('SELECT * FROM org_registry WHERE id = ?', id).first<Record<string, unknown>>()
|
|
2183
|
+
return row ? D1ServerStore.toOrgRegistryOrg(row) : null
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/** Add the organization; NULL on the id conflict (the slug is taken). */
|
|
2187
|
+
async createOrgRegistryOrg(input: {
|
|
2188
|
+
id: string
|
|
2189
|
+
name: string
|
|
2190
|
+
shortName?: string | null
|
|
2191
|
+
kind?: string | null
|
|
2192
|
+
country?: string | null
|
|
2193
|
+
contacts?: OrgRegistryContact[]
|
|
2194
|
+
participantRef?: string | null
|
|
2195
|
+
createdBy?: string | null
|
|
2196
|
+
}): Promise<OrgRegistryOrg | null> {
|
|
2197
|
+
await this.ensureOrgRegistrySupport()
|
|
2198
|
+
const res = await this.stmt(
|
|
2199
|
+
`INSERT OR IGNORE INTO org_registry (id, name, short_name, kind, country, contacts, participant_ref, created_by)
|
|
2200
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
2201
|
+
input.id, input.name, input.shortName ?? null, input.kind ?? null, input.country ?? null,
|
|
2202
|
+
JSON.stringify(input.contacts ?? []), input.participantRef ?? null, input.createdBy ?? null,
|
|
2203
|
+
).run()
|
|
2204
|
+
if ((res.meta.changes ?? 0) === 0) return null
|
|
2205
|
+
return this.getOrgRegistryOrg(input.id)
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/** Edit the display data (the id never moves); stamps updated_at/by. */
|
|
2209
|
+
async updateOrgRegistryOrg(
|
|
2210
|
+
id: string,
|
|
2211
|
+
patch: {
|
|
2212
|
+
name?: string
|
|
2213
|
+
shortName?: string | null
|
|
2214
|
+
kind?: string | null
|
|
2215
|
+
country?: string | null
|
|
2216
|
+
contacts?: OrgRegistryContact[]
|
|
2217
|
+
participantRef?: string | null
|
|
2218
|
+
},
|
|
2219
|
+
actor?: string | null,
|
|
2220
|
+
): Promise<OrgRegistryOrg | null> {
|
|
2221
|
+
await this.ensureOrgRegistrySupport()
|
|
2222
|
+
const sets: string[] = []
|
|
2223
|
+
const params: unknown[] = []
|
|
2224
|
+
if (patch.name !== undefined) { sets.push('name = ?'); params.push(patch.name) }
|
|
2225
|
+
if (patch.shortName !== undefined) { sets.push('short_name = ?'); params.push(patch.shortName) }
|
|
2226
|
+
if (patch.kind !== undefined) { sets.push('kind = ?'); params.push(patch.kind) }
|
|
2227
|
+
if (patch.country !== undefined) { sets.push('country = ?'); params.push(patch.country) }
|
|
2228
|
+
if (patch.contacts !== undefined) { sets.push('contacts = ?'); params.push(JSON.stringify(patch.contacts)) }
|
|
2229
|
+
if (patch.participantRef !== undefined) { sets.push('participant_ref = ?'); params.push(patch.participantRef) }
|
|
2230
|
+
sets.push("updated_at = datetime('now')", 'updated_by = ?')
|
|
2231
|
+
params.push(actor ?? null)
|
|
2232
|
+
const res = await this.stmt(`UPDATE org_registry SET ${sets.join(', ')} WHERE id = ?`, ...params, id).run()
|
|
2233
|
+
if ((res.meta.changes ?? 0) === 0) return null
|
|
2234
|
+
return this.getOrgRegistryOrg(id)
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
/** The lifecycle act (stamps; re-enable clears the disable stamps —
|
|
2238
|
+
* the memberships stay as they are). */
|
|
2239
|
+
async setOrgRegistryOrgState(id: string, state: OrgRegistryState, actor?: string | null): Promise<OrgRegistryOrg | null> {
|
|
2240
|
+
await this.ensureOrgRegistrySupport()
|
|
2241
|
+
const existing = await this.getOrgRegistryOrg(id)
|
|
2242
|
+
if (!existing) return null
|
|
2243
|
+
if (state === 'disabled') {
|
|
2244
|
+
await this.stmt("UPDATE org_registry SET state = 'disabled', disabled_at = datetime('now'), disabled_by = ? WHERE id = ?",
|
|
2245
|
+
actor ?? null, id).run()
|
|
2246
|
+
} else {
|
|
2247
|
+
await this.stmt("UPDATE org_registry SET state = 'active', disabled_at = NULL, disabled_by = NULL WHERE id = ?",
|
|
2248
|
+
id).run()
|
|
2249
|
+
}
|
|
2250
|
+
return this.getOrgRegistryOrg(id)
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
/** The erasure-adjacent hard delete (the route guards it). */
|
|
2254
|
+
async deleteOrgRegistryOrg(id: string): Promise<boolean> {
|
|
2255
|
+
await this.ensureOrgRegistrySupport()
|
|
2256
|
+
const res = await this.stmt('DELETE FROM org_registry WHERE id = ?', id).run()
|
|
2257
|
+
return (res.meta.changes ?? 0) > 0
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
// ── the register's holder-org attribution (TODO.register/02) ────────
|
|
2261
|
+
|
|
2262
|
+
private static toCertificateHolderOrg(row: Record<string, unknown>): CertificateHolderOrg {
|
|
2263
|
+
return {
|
|
2264
|
+
certificateId: row.certificate_id as string,
|
|
2265
|
+
orgId: row.org_id as string,
|
|
2266
|
+
orgName: row.org_name as string,
|
|
2267
|
+
source: row.source as CertificateHolderOrg['source'],
|
|
2268
|
+
attributedAt: row.attributed_at as string,
|
|
2269
|
+
attributedBy: (row.attributed_by as string | null) ?? null,
|
|
2270
|
+
claimId: (row.claim_id as string | null) ?? null,
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
/** INSERT-IF-ABSENT — the first attribution wins (NULL on an existing
|
|
2275
|
+
* row, never a silent overwrite). */
|
|
2276
|
+
async attributeCertificateHolderOrg(input: {
|
|
2277
|
+
certificateId: string
|
|
2278
|
+
orgId: string
|
|
2279
|
+
orgName: string
|
|
2280
|
+
source: CertificateHolderOrg['source']
|
|
2281
|
+
attributedAt: string
|
|
2282
|
+
attributedBy?: string | null
|
|
2283
|
+
claimId?: string | null
|
|
2284
|
+
}): Promise<CertificateHolderOrg | null> {
|
|
2285
|
+
await this.ensureHolderAttributionSupport()
|
|
2286
|
+
const res = await this.stmt(
|
|
2287
|
+
`INSERT OR IGNORE INTO certificate_holder_orgs
|
|
2288
|
+
(certificate_id, org_id, org_name, source, attributed_at, attributed_by, claim_id)
|
|
2289
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
2290
|
+
input.certificateId, input.orgId, input.orgName, input.source,
|
|
2291
|
+
input.attributedAt, input.attributedBy ?? null, input.claimId ?? null,
|
|
2292
|
+
).run()
|
|
2293
|
+
if ((res.meta.changes ?? 0) === 0) return null
|
|
2294
|
+
return this.getCertificateHolderOrg(input.certificateId)
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
async getCertificateHolderOrg(certificateId: string): Promise<CertificateHolderOrg | null> {
|
|
2298
|
+
await this.ensureHolderAttributionSupport()
|
|
2299
|
+
const row = await this.stmt('SELECT * FROM certificate_holder_orgs WHERE certificate_id = ?', certificateId)
|
|
2300
|
+
.first<Record<string, unknown>>()
|
|
2301
|
+
return row ? D1ServerStore.toCertificateHolderOrg(row) : null
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
async listCertificateHolderOrgs(filter?: { orgId?: string }): Promise<CertificateHolderOrg[]> {
|
|
2305
|
+
await this.ensureHolderAttributionSupport()
|
|
2306
|
+
const res = filter?.orgId
|
|
2307
|
+
? await this.stmt('SELECT * FROM certificate_holder_orgs WHERE org_id = ?', filter.orgId).all<Record<string, unknown>>()
|
|
2308
|
+
: await this.stmt('SELECT * FROM certificate_holder_orgs').all<Record<string, unknown>>()
|
|
2309
|
+
return res.results.map(D1ServerStore.toCertificateHolderOrg)
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
private static toCertificateHolderClaim(row: Record<string, unknown>): CertificateHolderClaim {
|
|
2313
|
+
return {
|
|
2314
|
+
id: row.id as string,
|
|
2315
|
+
certificateId: row.certificate_id as string,
|
|
2316
|
+
claimantOrgId: row.claimant_org_id as string,
|
|
2317
|
+
claimantOrgName: row.claimant_org_name as string,
|
|
2318
|
+
matchedHolderName: row.matched_holder_name as string,
|
|
2319
|
+
claimedBy: row.claimed_by as string,
|
|
2320
|
+
state: row.state as CertificateHolderClaim['state'],
|
|
2321
|
+
decidedBy: (row.decided_by as string | null) ?? null,
|
|
2322
|
+
decidedAt: (row.decided_at as string | null) ?? null,
|
|
2323
|
+
refusalReason: (row.refusal_reason as string | null) ?? null,
|
|
2324
|
+
createdAt: row.created_at as string,
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
async createCertificateHolderClaim(input: {
|
|
2329
|
+
certificateId: string
|
|
2330
|
+
claimantOrgId: string
|
|
2331
|
+
claimantOrgName: string
|
|
2332
|
+
matchedHolderName: string
|
|
2333
|
+
claimedBy: string
|
|
2334
|
+
}): Promise<CertificateHolderClaim> {
|
|
2335
|
+
await this.ensureHolderAttributionSupport()
|
|
2336
|
+
const id = crypto.randomUUID()
|
|
2337
|
+
await this.stmt(
|
|
2338
|
+
`INSERT INTO certificate_holder_claims
|
|
2339
|
+
(id, certificate_id, claimant_org_id, claimant_org_name, matched_holder_name, claimed_by)
|
|
2340
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
2341
|
+
id, input.certificateId, input.claimantOrgId, input.claimantOrgName, input.matchedHolderName, input.claimedBy,
|
|
2342
|
+
).run()
|
|
2343
|
+
return (await this.getCertificateHolderClaim(id))!
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
async getCertificateHolderClaim(id: string): Promise<CertificateHolderClaim | null> {
|
|
2347
|
+
await this.ensureHolderAttributionSupport()
|
|
2348
|
+
const row = await this.stmt('SELECT * FROM certificate_holder_claims WHERE id = ?', id)
|
|
2349
|
+
.first<Record<string, unknown>>()
|
|
2350
|
+
return row ? D1ServerStore.toCertificateHolderClaim(row) : null
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
async listCertificateHolderClaims(filter?: {
|
|
2354
|
+
state?: CertificateHolderClaim['state']
|
|
2355
|
+
claimantOrgId?: string
|
|
2356
|
+
certificateId?: string
|
|
2357
|
+
}): Promise<CertificateHolderClaim[]> {
|
|
2358
|
+
await this.ensureHolderAttributionSupport()
|
|
2359
|
+
const where: string[] = []
|
|
2360
|
+
const params: unknown[] = []
|
|
2361
|
+
if (filter?.state) { where.push('state = ?'); params.push(filter.state) }
|
|
2362
|
+
if (filter?.claimantOrgId) { where.push('claimant_org_id = ?'); params.push(filter.claimantOrgId) }
|
|
2363
|
+
if (filter?.certificateId) { where.push('certificate_id = ?'); params.push(filter.certificateId) }
|
|
2364
|
+
const res = await this.stmt(
|
|
2365
|
+
`SELECT * FROM certificate_holder_claims${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at`,
|
|
2366
|
+
...params,
|
|
2367
|
+
).all<Record<string, unknown>>()
|
|
2368
|
+
return res.results.map(D1ServerStore.toCertificateHolderClaim)
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
/** The estate admin's decision — ATOMIC on 'pending' (a double
|
|
2372
|
+
* decision loses the race, answering null). */
|
|
2373
|
+
async decideCertificateHolderClaim(
|
|
2374
|
+
id: string,
|
|
2375
|
+
decision: { status: 'confirmed' | 'refused'; decidedBy: string; refusalReason?: string | null },
|
|
2376
|
+
): Promise<CertificateHolderClaim | null> {
|
|
2377
|
+
await this.ensureHolderAttributionSupport()
|
|
2378
|
+
const res = await this.stmt(
|
|
2379
|
+
`UPDATE certificate_holder_claims
|
|
2380
|
+
SET state = ?, decided_by = ?, decided_at = datetime('now'), refusal_reason = ?
|
|
2381
|
+
WHERE id = ? AND state = 'pending'`,
|
|
2382
|
+
decision.status, decision.decidedBy, decision.refusalReason ?? null, id,
|
|
2383
|
+
).run()
|
|
2384
|
+
if ((res.meta.changes ?? 0) === 0) return null
|
|
2385
|
+
return this.getCertificateHolderClaim(id)
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
async findPendingCertificateHolderClaim(certificateId: string): Promise<CertificateHolderClaim | null> {
|
|
2389
|
+
await this.ensureHolderAttributionSupport()
|
|
2390
|
+
const row = await this.stmt(
|
|
2391
|
+
"SELECT * FROM certificate_holder_claims WHERE certificate_id = ? AND state = 'pending'",
|
|
2392
|
+
certificateId,
|
|
2393
|
+
).first<Record<string, unknown>>()
|
|
2394
|
+
return row ? D1ServerStore.toCertificateHolderClaim(row) : null
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// ── the instrument register (TODO.register/03) ─────────────────────
|
|
2398
|
+
// The platform-side serial register — the rows the registration
|
|
2399
|
+
// interface's cones read (the route's; browser/server/routes/
|
|
2400
|
+
// registrations.ts). These mirror the SQLite store's register section
|
|
2401
|
+
// one-for-one.
|
|
2402
|
+
|
|
2403
|
+
private static toInstrumentRegistration(row: Record<string, unknown>): InstrumentRegistration {
|
|
2404
|
+
let designations: Record<string, unknown> = {}
|
|
2405
|
+
try {
|
|
2406
|
+
const parsed = JSON.parse((row.designations as string) ?? '{}') as unknown
|
|
2407
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) designations = parsed as Record<string, unknown>
|
|
2408
|
+
} catch { /* a malformed designations cell reads as the empty object, never trusted */ }
|
|
2409
|
+
return {
|
|
2410
|
+
id: row.id as string,
|
|
2411
|
+
certificateId: row.certificate_id as string,
|
|
2412
|
+
holderOrgId: row.holder_org_id as string,
|
|
2413
|
+
standardId: row.standard_id as string,
|
|
2414
|
+
serialNumber: row.serial_number as string,
|
|
2415
|
+
manufactureDate: (row.manufacture_date as string | null) ?? null,
|
|
2416
|
+
designations,
|
|
2417
|
+
scopeStatus: row.scope_status as InstrumentRegistrationScopeStatus,
|
|
2418
|
+
scopeDetail: (row.scope_detail as string | null) ?? null,
|
|
2419
|
+
lifecycle: row.lifecycle as InstrumentRegistrationLifecycle,
|
|
2420
|
+
registeredAt: row.registered_at as string,
|
|
2421
|
+
registeredBy: (row.registered_by as string | null) ?? null,
|
|
2422
|
+
updatedAt: (row.updated_at as string | null) ?? null,
|
|
2423
|
+
updatedBy: (row.updated_by as string | null) ?? null,
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
async listInstrumentRegistrations(): Promise<InstrumentRegistration[]> {
|
|
2428
|
+
await this.ensureInstrumentRegistrationSupport()
|
|
2429
|
+
const res = await this.stmt('SELECT * FROM instrument_registrations').all<Record<string, unknown>>()
|
|
2430
|
+
return res.results
|
|
2431
|
+
.map(D1ServerStore.toInstrumentRegistration)
|
|
2432
|
+
.sort((a, b) => a.certificateId.localeCompare(b.certificateId) || a.serialNumber.localeCompare(b.serialNumber))
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
async listInstrumentRegistrationsForCertificate(certificateId: string): Promise<InstrumentRegistration[]> {
|
|
2436
|
+
await this.ensureInstrumentRegistrationSupport()
|
|
2437
|
+
const res = await this.stmt('SELECT * FROM instrument_registrations WHERE certificate_id = ?', certificateId).all<Record<string, unknown>>()
|
|
2438
|
+
return res.results.map(D1ServerStore.toInstrumentRegistration).sort((a, b) => a.serialNumber.localeCompare(b.serialNumber))
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
async listInstrumentRegistrationsForHolder(holderOrgId: string): Promise<InstrumentRegistration[]> {
|
|
2442
|
+
await this.ensureInstrumentRegistrationSupport()
|
|
2443
|
+
const res = await this.stmt('SELECT * FROM instrument_registrations WHERE holder_org_id = ?', holderOrgId).all<Record<string, unknown>>()
|
|
2444
|
+
return res.results.map(D1ServerStore.toInstrumentRegistration).sort((a, b) => a.serialNumber.localeCompare(b.serialNumber))
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
async getInstrumentRegistration(id: string): Promise<InstrumentRegistration | null> {
|
|
2448
|
+
await this.ensureInstrumentRegistrationSupport()
|
|
2449
|
+
const row = await this.stmt('SELECT * FROM instrument_registrations WHERE id = ?', id).first<Record<string, unknown>>()
|
|
2450
|
+
return row ? D1ServerStore.toInstrumentRegistration(row) : null
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
/** Register the instrument; NULL on the (certificate, serial)
|
|
2454
|
+
* conflict (the route's honest 409). */
|
|
2455
|
+
async createInstrumentRegistration(input: {
|
|
2456
|
+
id: string
|
|
2457
|
+
certificateId: string
|
|
2458
|
+
holderOrgId: string
|
|
2459
|
+
standardId: string
|
|
2460
|
+
serialNumber: string
|
|
2461
|
+
manufactureDate?: string | null
|
|
2462
|
+
designations?: Record<string, unknown>
|
|
2463
|
+
scopeStatus: InstrumentRegistrationScopeStatus
|
|
2464
|
+
scopeDetail?: string | null
|
|
2465
|
+
registeredBy?: string | null
|
|
2466
|
+
}): Promise<InstrumentRegistration | null> {
|
|
2467
|
+
await this.ensureInstrumentRegistrationSupport()
|
|
2468
|
+
const res = await this.stmt(
|
|
2469
|
+
`INSERT OR IGNORE INTO instrument_registrations
|
|
2470
|
+
(id, certificate_id, holder_org_id, standard_id, serial_number, manufacture_date, designations, scope_status, scope_detail, registered_by)
|
|
2471
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
2472
|
+
input.id, input.certificateId, input.holderOrgId, input.standardId, input.serialNumber,
|
|
2473
|
+
input.manufactureDate ?? null, JSON.stringify(input.designations ?? {}),
|
|
2474
|
+
input.scopeStatus, input.scopeDetail ?? null, input.registeredBy ?? null,
|
|
2475
|
+
).run()
|
|
2476
|
+
if ((res.meta.changes ?? 0) === 0) return null
|
|
2477
|
+
return this.getInstrumentRegistration(input.id)
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
/** The lifecycle act (the transition rule is the route's); stamps
|
|
2481
|
+
* updated_at/by. NULL when the register does not carry the id. */
|
|
2482
|
+
async setInstrumentRegistrationLifecycle(
|
|
2483
|
+
id: string,
|
|
2484
|
+
lifecycle: InstrumentRegistrationLifecycle,
|
|
2485
|
+
actor?: string | null,
|
|
2486
|
+
): Promise<InstrumentRegistration | null> {
|
|
2487
|
+
await this.ensureInstrumentRegistrationSupport()
|
|
2488
|
+
const res = await this.stmt(
|
|
2489
|
+
"UPDATE instrument_registrations SET lifecycle = ?, updated_at = datetime('now'), updated_by = ? WHERE id = ?",
|
|
2490
|
+
lifecycle, actor ?? null, id,
|
|
2491
|
+
).run()
|
|
2492
|
+
if ((res.meta.changes ?? 0) === 0) return null
|
|
2493
|
+
return this.getInstrumentRegistration(id)
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// ── the workflow entity store + change journal ───────────────────
|
|
2497
|
+
|
|
2498
|
+
async listEntities(store: string): Promise<EntityRow[]> {
|
|
2499
|
+
const res = await this.stmt(
|
|
2500
|
+
'SELECT store, id, org_id, data, updated_at FROM entities WHERE store = ?', store,
|
|
2501
|
+
).all<EntityRow>()
|
|
2502
|
+
return res.results
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
async getEntity(store: string, id: string): Promise<EntityRow | undefined> {
|
|
2506
|
+
const row = await this.stmt(
|
|
2507
|
+
'SELECT store, id, org_id, data, updated_at FROM entities WHERE store = ? AND id = ?', store, id,
|
|
2508
|
+
).first<EntityRow>()
|
|
2509
|
+
return row ?? undefined
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
async putEntity(store: string, id: string, orgId: string | null, data: string): Promise<void> {
|
|
2513
|
+
// The upsert + its journal entry ride ONE batch — D1 batches are
|
|
2514
|
+
// all-or-nothing, the same atomicity the SQLite path gets from its
|
|
2515
|
+
// transaction.
|
|
2516
|
+
await this.db.batch([
|
|
2517
|
+
this.stmt(
|
|
2518
|
+
`INSERT INTO entities (store, id, org_id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))
|
|
2519
|
+
ON CONFLICT (store, id) DO UPDATE SET org_id = excluded.org_id, data = excluded.data, updated_at = datetime('now')`,
|
|
2520
|
+
store, id, orgId, data,
|
|
2521
|
+
),
|
|
2522
|
+
this.stmt('INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)', store, 'persist', id),
|
|
2523
|
+
])
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
async deleteEntity(store: string, id: string): Promise<boolean> {
|
|
2527
|
+
const res = await this.stmt('DELETE FROM entities WHERE store = ? AND id = ?', store, id).run()
|
|
2528
|
+
const gone = (res.meta.changes ?? 0) > 0
|
|
2529
|
+
if (gone) {
|
|
2530
|
+
await this.stmt('INSERT INTO entity_changes (store, type, id) VALUES (?, ?, ?)', store, 'remove', id).run()
|
|
2531
|
+
}
|
|
2532
|
+
return gone
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
async changesAfter(seq: number, limit = 500): Promise<EntityChange[]> {
|
|
2536
|
+
const res = await this.stmt(
|
|
2537
|
+
'SELECT seq, store, type, id, at FROM entity_changes WHERE seq > ? ORDER BY seq LIMIT ?', seq, limit,
|
|
2538
|
+
).all<EntityChange>()
|
|
2539
|
+
return res.results
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
async latestChangeSeq(): Promise<number> {
|
|
2543
|
+
const row = await this.stmt('SELECT MAX(seq) AS seq FROM entity_changes').first<{ seq: number | null }>()
|
|
2544
|
+
return row?.seq ?? 0
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// ── the platform event store (TODO.notify/01) ─────────────────────
|
|
2548
|
+
// The event rows port directly (D1 is SQLite) — the same statements
|
|
2549
|
+
// as sqlite/events.ts's sync half.
|
|
2550
|
+
|
|
2551
|
+
private static toPlatformEvent(row: Record<string, unknown>): PlatformEvent {
|
|
2552
|
+
return {
|
|
2553
|
+
seq: row.seq as number,
|
|
2554
|
+
id: row.id as string,
|
|
2555
|
+
domain: row.domain as string,
|
|
2556
|
+
entityId: row.entity_id as string,
|
|
2557
|
+
action: row.action as string,
|
|
2558
|
+
payload: row.payload as string,
|
|
2559
|
+
at: row.at as string,
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
async appendEvent(input: {
|
|
2564
|
+
id: string
|
|
2565
|
+
domain: string
|
|
2566
|
+
entityId: string
|
|
2567
|
+
action: string
|
|
2568
|
+
payload: string
|
|
2569
|
+
}): Promise<PlatformEvent> {
|
|
2570
|
+
await this.stmt(
|
|
2571
|
+
'INSERT INTO events (id, domain, entity_id, action, payload) VALUES (?, ?, ?, ?, ?)',
|
|
2572
|
+
input.id, input.domain, input.entityId, input.action, input.payload,
|
|
2573
|
+
).run()
|
|
2574
|
+
const row = await this.stmt('SELECT * FROM events WHERE id = ?', input.id).first<Record<string, unknown>>()
|
|
2575
|
+
return D1ServerStore.toPlatformEvent(row!)
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
async eventsAfter(seq: number, limit = 500): Promise<PlatformEvent[]> {
|
|
2579
|
+
const res = await this.stmt(
|
|
2580
|
+
'SELECT * FROM events WHERE seq > ? ORDER BY seq LIMIT ?', seq, limit,
|
|
2581
|
+
).all<Record<string, unknown>>()
|
|
2582
|
+
return res.results.map(D1ServerStore.toPlatformEvent)
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
async latestEventSeq(): Promise<number> {
|
|
2586
|
+
const row = await this.stmt('SELECT MAX(seq) AS seq FROM events').first<{ seq: number | null }>()
|
|
2587
|
+
return row?.seq ?? 0
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
async getEvent(id: string): Promise<PlatformEvent | null> {
|
|
2591
|
+
const row = await this.stmt('SELECT * FROM events WHERE id = ?', id).first<Record<string, unknown>>()
|
|
2592
|
+
return row ? D1ServerStore.toPlatformEvent(row) : null
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
/** The subscription grammar's SQL resolution: the pinned columns match
|
|
2596
|
+
* by equality; the free legs stay out of the WHERE. */
|
|
2597
|
+
async eventsMatching(filter: EventKeyFilter, limit = 500): Promise<PlatformEvent[]> {
|
|
2598
|
+
const where: string[] = []
|
|
2599
|
+
const args: unknown[] = []
|
|
2600
|
+
if (filter.domain !== undefined) { where.push('domain = ?'); args.push(filter.domain) }
|
|
2601
|
+
if (filter.entityId !== undefined) { where.push('entity_id = ?'); args.push(filter.entityId) }
|
|
2602
|
+
if (filter.action !== undefined) { where.push('action = ?'); args.push(filter.action) }
|
|
2603
|
+
const sql = `SELECT * FROM events${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY seq LIMIT ?`
|
|
2604
|
+
const res = await this.stmt(sql, ...args, limit).all<Record<string, unknown>>()
|
|
2605
|
+
return res.results.map(D1ServerStore.toPlatformEvent)
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
// ── the notification subscriptions store (TODO.notify/02) ─────────
|
|
2609
|
+
// The SAME statements as sqlite/notify.ts's sync half (D1 is SQLite).
|
|
2610
|
+
|
|
2611
|
+
private static toNotifyRule(row: Record<string, unknown>): NotifyRule {
|
|
2612
|
+
return {
|
|
2613
|
+
id: row.id as string,
|
|
2614
|
+
userId: row.user_id as string,
|
|
2615
|
+
pattern: row.pattern as string,
|
|
2616
|
+
domain: row.domain as string,
|
|
2617
|
+
entityId: (row.entity_id as string | null) ?? null,
|
|
2618
|
+
action: (row.action as string | null) ?? null,
|
|
2619
|
+
mode: row.mode as NotifyRule['mode'],
|
|
2620
|
+
channelOverrides: (row.channel_overrides as string | null) ?? null,
|
|
2621
|
+
createdAt: row.created_at as string,
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
async listNotifyRules(userId: string): Promise<NotifyRule[]> {
|
|
2626
|
+
const res = await this.stmt(
|
|
2627
|
+
'SELECT * FROM notify_rules WHERE user_id = ? ORDER BY created_at, id', userId,
|
|
2628
|
+
).all<Record<string, unknown>>()
|
|
2629
|
+
return res.results.map(D1ServerStore.toNotifyRule)
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
async putNotifyRule(input: {
|
|
2633
|
+
id: string
|
|
2634
|
+
userId: string
|
|
2635
|
+
pattern: string
|
|
2636
|
+
domain: string
|
|
2637
|
+
entityId: string | null
|
|
2638
|
+
action: string | null
|
|
2639
|
+
mode: NotifyRule['mode']
|
|
2640
|
+
channelOverrides: string | null
|
|
2641
|
+
}): Promise<NotifyRule> {
|
|
2642
|
+
await this.stmt(
|
|
2643
|
+
`INSERT INTO notify_rules (id, user_id, pattern, domain, entity_id, action, mode, channel_overrides)
|
|
2644
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
2645
|
+
ON CONFLICT (user_id, pattern) DO UPDATE SET
|
|
2646
|
+
domain = excluded.domain,
|
|
2647
|
+
entity_id = excluded.entity_id,
|
|
2648
|
+
action = excluded.action,
|
|
2649
|
+
mode = excluded.mode,
|
|
2650
|
+
channel_overrides = excluded.channel_overrides`,
|
|
2651
|
+
input.id, input.userId, input.pattern, input.domain, input.entityId, input.action, input.mode, input.channelOverrides,
|
|
2652
|
+
).run()
|
|
2653
|
+
const row = await this.stmt(
|
|
2654
|
+
'SELECT * FROM notify_rules WHERE user_id = ? AND pattern = ?', input.userId, input.pattern,
|
|
2655
|
+
).first<Record<string, unknown>>()
|
|
2656
|
+
return D1ServerStore.toNotifyRule(row!)
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
async deleteNotifyRule(userId: string, pattern: string): Promise<boolean> {
|
|
2660
|
+
const res = await this.stmt(
|
|
2661
|
+
'DELETE FROM notify_rules WHERE user_id = ? AND pattern = ?', userId, pattern,
|
|
2662
|
+
).run()
|
|
2663
|
+
return (res.meta.changes ?? 0) > 0
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
/** The resolution's reverse match: a rule covers the event when its
|
|
2667
|
+
* pinned legs equal the event's columns (NULL = the wild leg). */
|
|
2668
|
+
async notifyRulesForEvent(filter: { domain: string; entityId: string; action: string }): Promise<NotifyRule[]> {
|
|
2669
|
+
const res = await this.stmt(
|
|
2670
|
+
`SELECT * FROM notify_rules
|
|
2671
|
+
WHERE domain = ? AND (entity_id IS NULL OR entity_id = ?) AND (action IS NULL OR action = ?)
|
|
2672
|
+
ORDER BY created_at, id`,
|
|
2673
|
+
filter.domain, filter.entityId, filter.action,
|
|
2674
|
+
).all<Record<string, unknown>>()
|
|
2675
|
+
return res.results.map(D1ServerStore.toNotifyRule)
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
private static toNotifyEntityMute(row: Record<string, unknown>): NotifyEntityMute {
|
|
2679
|
+
return {
|
|
2680
|
+
id: row.id as string,
|
|
2681
|
+
userId: row.user_id as string,
|
|
2682
|
+
domain: row.domain as string,
|
|
2683
|
+
entityId: row.entity_id as string,
|
|
2684
|
+
createdAt: row.created_at as string,
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
async listNotifyEntityMutes(userId: string): Promise<NotifyEntityMute[]> {
|
|
2689
|
+
const res = await this.stmt(
|
|
2690
|
+
'SELECT * FROM notify_entity_mutes WHERE user_id = ? ORDER BY created_at, id', userId,
|
|
2691
|
+
).all<Record<string, unknown>>()
|
|
2692
|
+
return res.results.map(D1ServerStore.toNotifyEntityMute)
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
async putNotifyEntityMute(input: { id: string; userId: string; domain: string; entityId: string }): Promise<NotifyEntityMute> {
|
|
2696
|
+
await this.stmt(
|
|
2697
|
+
`INSERT INTO notify_entity_mutes (id, user_id, domain, entity_id) VALUES (?, ?, ?, ?)
|
|
2698
|
+
ON CONFLICT (user_id, domain, entity_id) DO NOTHING`,
|
|
2699
|
+
input.id, input.userId, input.domain, input.entityId,
|
|
2700
|
+
).run()
|
|
2701
|
+
const row = await this.stmt(
|
|
2702
|
+
'SELECT * FROM notify_entity_mutes WHERE user_id = ? AND domain = ? AND entity_id = ?',
|
|
2703
|
+
input.userId, input.domain, input.entityId,
|
|
2704
|
+
).first<Record<string, unknown>>()
|
|
2705
|
+
return D1ServerStore.toNotifyEntityMute(row!)
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
async deleteNotifyEntityMute(userId: string, domain: string, entityId: string): Promise<boolean> {
|
|
2709
|
+
const res = await this.stmt(
|
|
2710
|
+
'DELETE FROM notify_entity_mutes WHERE user_id = ? AND domain = ? AND entity_id = ?',
|
|
2711
|
+
userId, domain, entityId,
|
|
2712
|
+
).run()
|
|
2713
|
+
return (res.meta.changes ?? 0) > 0
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
async notifyEntityMutesForEvent(domain: string, entityId: string): Promise<NotifyEntityMute[]> {
|
|
2717
|
+
const res = await this.stmt(
|
|
2718
|
+
'SELECT * FROM notify_entity_mutes WHERE domain = ? AND entity_id = ? ORDER BY created_at, id',
|
|
2719
|
+
domain, entityId,
|
|
2720
|
+
).all<Record<string, unknown>>()
|
|
2721
|
+
return res.results.map(D1ServerStore.toNotifyEntityMute)
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
private static toNotifyPreferences(row: Record<string, unknown>): NotifyPreferences {
|
|
2725
|
+
return {
|
|
2726
|
+
userId: row.user_id as string,
|
|
2727
|
+
channels: row.channels as string,
|
|
2728
|
+
updatedAt: row.updated_at as string,
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
async getNotifyPreferences(userId: string): Promise<NotifyPreferences | null> {
|
|
2733
|
+
const row = await this.stmt(
|
|
2734
|
+
'SELECT * FROM notify_preferences WHERE user_id = ?', userId,
|
|
2735
|
+
).first<Record<string, unknown>>()
|
|
2736
|
+
return row ? D1ServerStore.toNotifyPreferences(row) : null
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
async putNotifyPreferences(userId: string, channels: string): Promise<NotifyPreferences> {
|
|
2740
|
+
await this.stmt(
|
|
2741
|
+
`INSERT INTO notify_preferences (user_id, channels) VALUES (?, ?)
|
|
2742
|
+
ON CONFLICT (user_id) DO UPDATE SET channels = excluded.channels, updated_at = datetime('now')`,
|
|
2743
|
+
userId, channels,
|
|
2744
|
+
).run()
|
|
2745
|
+
const row = await this.stmt(
|
|
2746
|
+
'SELECT * FROM notify_preferences WHERE user_id = ?', userId,
|
|
2747
|
+
).first<Record<string, unknown>>()
|
|
2748
|
+
return D1ServerStore.toNotifyPreferences(row!)
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
// ── the inbox state (TODO.notify/03) ──────────────────────────────
|
|
2752
|
+
// The SAME statements as sqlite/notify.ts's sync half (D1 is SQLite).
|
|
2753
|
+
|
|
2754
|
+
private static toNotifyInboxState(row: Record<string, unknown>): NotifyInboxState {
|
|
2755
|
+
return {
|
|
2756
|
+
userId: row.user_id as string,
|
|
2757
|
+
eventId: row.event_id as string,
|
|
2758
|
+
readAt: (row.read_at as string | null) ?? null,
|
|
2759
|
+
doneAt: (row.done_at as string | null) ?? null,
|
|
2760
|
+
createdAt: row.created_at as string,
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
async listNotifyInboxStates(userId: string): Promise<NotifyInboxState[]> {
|
|
2765
|
+
const res = await this.stmt(
|
|
2766
|
+
'SELECT * FROM notify_inbox_state WHERE user_id = ? ORDER BY created_at, event_id', userId,
|
|
2767
|
+
).all<Record<string, unknown>>()
|
|
2768
|
+
return res.results.map(D1ServerStore.toNotifyInboxState)
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
/** The marker write: each PRESENT flag stamps (datetime('now')) or
|
|
2772
|
+
* clears (NULL) its column; absent flags keep. */
|
|
2773
|
+
async putNotifyInboxState(input: {
|
|
2774
|
+
userId: string
|
|
2775
|
+
eventId: string
|
|
2776
|
+
read?: boolean
|
|
2777
|
+
done?: boolean
|
|
2778
|
+
}): Promise<NotifyInboxState> {
|
|
2779
|
+
await this.stmt(
|
|
2780
|
+
'INSERT OR IGNORE INTO notify_inbox_state (user_id, event_id) VALUES (?, ?)',
|
|
2781
|
+
input.userId, input.eventId,
|
|
2782
|
+
).run()
|
|
2783
|
+
if (input.read !== undefined) {
|
|
2784
|
+
await this.stmt(
|
|
2785
|
+
"UPDATE notify_inbox_state SET read_at = CASE WHEN ? THEN datetime('now') ELSE NULL END WHERE user_id = ? AND event_id = ?",
|
|
2786
|
+
input.read ? 1 : 0, input.userId, input.eventId,
|
|
2787
|
+
).run()
|
|
2788
|
+
}
|
|
2789
|
+
if (input.done !== undefined) {
|
|
2790
|
+
await this.stmt(
|
|
2791
|
+
"UPDATE notify_inbox_state SET done_at = CASE WHEN ? THEN datetime('now') ELSE NULL END WHERE user_id = ? AND event_id = ?",
|
|
2792
|
+
input.done ? 1 : 0, input.userId, input.eventId,
|
|
2793
|
+
).run()
|
|
2794
|
+
}
|
|
2795
|
+
const row = await this.stmt(
|
|
2796
|
+
'SELECT * FROM notify_inbox_state WHERE user_id = ? AND event_id = ?',
|
|
2797
|
+
input.userId, input.eventId,
|
|
2798
|
+
).first<Record<string, unknown>>()
|
|
2799
|
+
return D1ServerStore.toNotifyInboxState(row!)
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
// ── provisioning / dev support ───────────────────────────────────
|
|
2803
|
+
|
|
2804
|
+
async wipeWorkflowStores(range?: { after: number; through: number }): Promise<number> {
|
|
2805
|
+
// The register table joins the wipe defensively (a dev D1 migrated
|
|
2806
|
+
// from before migration 0016 lacks it — the ensure posture).
|
|
2807
|
+
await this.ensureInstrumentRegistrationSupport()
|
|
2808
|
+
// The wipe's tables in one batch (all-or-nothing, the putEntity
|
|
2809
|
+
// pattern). A ranged round charges one bounded statement per table;
|
|
2810
|
+
// the range-less form is the direct-call default (small stores,
|
|
2811
|
+
// tests) — the budgeted reset phase always passes a range.
|
|
2812
|
+
const statements = range
|
|
2813
|
+
? WIPE_TABLES.map(t => this.db.prepare(`DELETE FROM ${t} WHERE rowid > ? AND rowid <= ?`).bind(range.after, range.through))
|
|
2814
|
+
: WIPE_TABLES.map(t => this.db.prepare(`DELETE FROM ${t}`))
|
|
2815
|
+
const results = await this.db.batch(statements)
|
|
2816
|
+
return results.reduce((rows, r) => rows + (r.meta.changes ?? 0), 0)
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
async workflowStoreRowCeiling(): Promise<number> {
|
|
2820
|
+
await this.ensureInstrumentRegistrationSupport()
|
|
2821
|
+
const row = await this.stmt(
|
|
2822
|
+
`SELECT MAX(ceiling) AS ceiling FROM (
|
|
2823
|
+
SELECT MAX(rowid) AS ceiling FROM entity_changes
|
|
2824
|
+
UNION ALL SELECT MAX(rowid) FROM evidence_records
|
|
2825
|
+
UNION ALL SELECT MAX(rowid) FROM entities
|
|
2826
|
+
UNION ALL SELECT MAX(rowid) FROM events
|
|
2827
|
+
UNION ALL SELECT MAX(rowid) FROM instrument_registrations)`,
|
|
2828
|
+
).first<{ ceiling: number | null }>()
|
|
2829
|
+
return row?.ceiling ?? 0
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
async countEntities(): Promise<number> {
|
|
2833
|
+
const row = await this.stmt('SELECT COUNT(*) AS n FROM entities').first<{ n: number }>()
|
|
2834
|
+
return row?.n ?? 0
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
/** The worker entry's install: one store per binding, memoized (the
|
|
2839
|
+
* store is a stateless facade over the binding — safe to share across
|
|
2840
|
+
* the isolate's concurrent requests). */
|
|
2841
|
+
const byBinding = new WeakMap<D1Database, D1ServerStore>()
|
|
2842
|
+
|
|
2843
|
+
export function d1StoreFor(binding: D1Database): D1ServerStore {
|
|
2844
|
+
let store = byBinding.get(binding)
|
|
2845
|
+
if (!store) {
|
|
2846
|
+
store = new D1ServerStore(binding)
|
|
2847
|
+
byBinding.set(binding, store)
|
|
2848
|
+
}
|
|
2849
|
+
return store
|
|
2850
|
+
}
|