@substrat-run/adapter-sqlite 0.5.0 → 0.7.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/dist/checker.d.ts.map +1 -1
- package/dist/checker.js +15 -5
- package/dist/checker.js.map +1 -1
- package/dist/index.d.ts +74 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +922 -61
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import Database from 'better-sqlite3';
|
|
4
|
-
import { adminLogEntry, createTenantInput, domainEvent, domainEventInput, eventId, identityLink, instant, moduleManifest, objectRef, principalId, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, } from '@substrat-run/contracts';
|
|
4
|
+
import { accessLogEntry, adminLogEntry, createTenantInput, domainEvent, domainEventInput, eventId, identityLink, identityPool, instant, moduleManifest, createOrgInput, promotionAcknowledgement, bindHostnameInput, hostnameBinding, publishVersionInput, routeTarget, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, org as orgSchema, orgMembership, principalId, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, } from '@substrat-run/contracts';
|
|
5
5
|
import { resolveScopeRecord, ulid, } from '@substrat-run/kernel';
|
|
6
6
|
import { ScopeActor } from './actor.js';
|
|
7
7
|
import { createTupleChecker } from './checker.js';
|
|
@@ -32,6 +32,10 @@ const KERNEL_DDL = `
|
|
|
32
32
|
relation TEXT NOT NULL,
|
|
33
33
|
object TEXT NOT NULL,
|
|
34
34
|
expires_at TEXT,
|
|
35
|
+
-- K-21: revocation TOMBSTONES. The row stays and the walk skips it, because a
|
|
36
|
+
-- tuple that once granted access is evidence of why an access was allowed
|
|
37
|
+
-- (K-4) and D-32's compliance product has to produce that evidence.
|
|
38
|
+
revoked_at TEXT,
|
|
35
39
|
PRIMARY KEY (subject, relation, object)
|
|
36
40
|
);
|
|
37
41
|
CREATE TABLE IF NOT EXISTS _substrat_deliveries (
|
|
@@ -42,6 +46,36 @@ const KERNEL_DDL = `
|
|
|
42
46
|
PRIMARY KEY (event_id, consumer_module)
|
|
43
47
|
);
|
|
44
48
|
`;
|
|
49
|
+
/**
|
|
50
|
+
* The four migration-health columns → the contract's nullable `migrationFailure`.
|
|
51
|
+
* All-null is the healthy case (never attempted, or the last attempt succeeded and
|
|
52
|
+
* cleared them); a version present means the scope failed closed and did not serve.
|
|
53
|
+
*
|
|
54
|
+
* Returns the *unparsed* shape — `scopeSchema.parse` brands `lastAttemptAt` into an
|
|
55
|
+
* `Instant`, the same way every other row value in `mapScope` is branded on read.
|
|
56
|
+
*/
|
|
57
|
+
function mapMigrationFailure(r) {
|
|
58
|
+
if (!r.migration_failed_version || !r.migration_last_attempt_at)
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
version: r.migration_failed_version,
|
|
62
|
+
error: r.migration_error ?? '',
|
|
63
|
+
attempts: r.migration_attempts,
|
|
64
|
+
lastAttemptAt: r.migration_last_attempt_at,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const mapHostname = (r) => hostnameBinding.parse({
|
|
68
|
+
hostname: r.hostname,
|
|
69
|
+
tenantId: r.tenant_id,
|
|
70
|
+
scopeId: r.scope_id,
|
|
71
|
+
verticalSlug: r.vertical_slug,
|
|
72
|
+
surface: r.surface,
|
|
73
|
+
region: r.region,
|
|
74
|
+
status: r.status,
|
|
75
|
+
statusNote: r.status_note,
|
|
76
|
+
canonical: r.canonical === 1,
|
|
77
|
+
createdAt: r.created_at,
|
|
78
|
+
});
|
|
45
79
|
export class SqliteScopeHost {
|
|
46
80
|
admin;
|
|
47
81
|
dir;
|
|
@@ -61,6 +95,15 @@ export class SqliteScopeHost {
|
|
|
61
95
|
/** operation name → its owning module's entitlementKey (§4.3 gate). */
|
|
62
96
|
operationEntitlement = new Map();
|
|
63
97
|
roles = new Map(); // 'tenantId/roleKey'
|
|
98
|
+
/** Executor id → {eventType, handler} (K-22 §4.2). Host code, not module code. */
|
|
99
|
+
executors = new Map();
|
|
100
|
+
/**
|
|
101
|
+
* The event currently being effected by an executor, stamped onto any admin rows
|
|
102
|
+
* it writes. Ambient rather than threaded through every HostAdmin signature: it is
|
|
103
|
+
* set and cleared immediately around one `await`, and executors run sequentially,
|
|
104
|
+
* so there is no window where it belongs to a different event.
|
|
105
|
+
*/
|
|
106
|
+
causedBy = null;
|
|
64
107
|
systemPrincipal = principalId.parse(ulid());
|
|
65
108
|
constructor(options) {
|
|
66
109
|
this.dir = options.dir;
|
|
@@ -96,6 +139,80 @@ export class SqliteScopeHost {
|
|
|
96
139
|
jurisdiction TEXT,
|
|
97
140
|
status TEXT NOT NULL DEFAULT 'active',
|
|
98
141
|
schema_version TEXT NOT NULL DEFAULT '0',
|
|
142
|
+
vertical_version_id TEXT,
|
|
143
|
+
-- Last FAILED migration attempt (§5.3). All null / 0 = healthy. Written on
|
|
144
|
+
-- the failure path so a scope that fails closed stops rendering as active;
|
|
145
|
+
-- cleared on the next success. See applyPendingMigrations.
|
|
146
|
+
migration_failed_version TEXT,
|
|
147
|
+
migration_error TEXT,
|
|
148
|
+
migration_attempts INTEGER NOT NULL DEFAULT 0,
|
|
149
|
+
migration_last_attempt_at TEXT,
|
|
150
|
+
created_at TEXT NOT NULL
|
|
151
|
+
);
|
|
152
|
+
-- The hostname map (K-26). A single environment-wide router resolves against
|
|
153
|
+
-- this before dispatching to the vertical's worker.
|
|
154
|
+
--
|
|
155
|
+
-- The surface column is why one hostname per scope was not enough: the shop
|
|
156
|
+
-- fronts a storefront AND a back office from one scope. The region column is
|
|
157
|
+
-- Regional Services, which Cloudflare configures per hostname — the reason
|
|
158
|
+
-- residency lives here rather than in a router deployed per jurisdiction.
|
|
159
|
+
CREATE TABLE IF NOT EXISTS hostnames (
|
|
160
|
+
hostname TEXT PRIMARY KEY,
|
|
161
|
+
tenant_id TEXT NOT NULL,
|
|
162
|
+
scope_id TEXT NOT NULL,
|
|
163
|
+
vertical_slug TEXT,
|
|
164
|
+
surface TEXT NOT NULL,
|
|
165
|
+
region TEXT,
|
|
166
|
+
status TEXT NOT NULL,
|
|
167
|
+
status_note TEXT,
|
|
168
|
+
canonical INTEGER NOT NULL DEFAULT 0,
|
|
169
|
+
created_at TEXT NOT NULL
|
|
170
|
+
);
|
|
171
|
+
CREATE INDEX IF NOT EXISTS hostnames_scope ON hostnames (scope_id, surface);
|
|
172
|
+
-- The vertical + version registry (#31). A scope binds to a VERSION, so
|
|
173
|
+
-- dev/staging/prod are the same vertical pinned differently, and a preview
|
|
174
|
+
-- deployment is a version nothing has been promoted to yet.
|
|
175
|
+
CREATE TABLE IF NOT EXISTS verticals (
|
|
176
|
+
slug TEXT PRIMARY KEY,
|
|
177
|
+
name TEXT NOT NULL,
|
|
178
|
+
source TEXT NOT NULL,
|
|
179
|
+
created_at TEXT NOT NULL
|
|
180
|
+
);
|
|
181
|
+
-- admission: 'pending' until the gates pass. A push is not a deploy, and
|
|
182
|
+
-- bind_scope_version refuses anything not admitted -- which is what makes
|
|
183
|
+
-- that sentence structural rather than a convention.
|
|
184
|
+
CREATE TABLE IF NOT EXISTS vertical_versions (
|
|
185
|
+
id TEXT PRIMARY KEY,
|
|
186
|
+
vertical_slug TEXT NOT NULL,
|
|
187
|
+
version TEXT NOT NULL,
|
|
188
|
+
manifest_digest TEXT NOT NULL,
|
|
189
|
+
permission_digest TEXT NOT NULL,
|
|
190
|
+
migration_digest TEXT NOT NULL,
|
|
191
|
+
deployment_ref TEXT,
|
|
192
|
+
admission TEXT NOT NULL,
|
|
193
|
+
admission_note TEXT,
|
|
194
|
+
created_at TEXT NOT NULL,
|
|
195
|
+
UNIQUE (vertical_slug, version)
|
|
196
|
+
);
|
|
197
|
+
-- Channels (#31 step 2): a named pointer per vertical. Promotion moves it,
|
|
198
|
+
-- and promotion is where the migration and permission diffs fire — the
|
|
199
|
+
-- moment a change reaches anyone, rather than the moment it was typed.
|
|
200
|
+
CREATE TABLE IF NOT EXISTS vertical_channels (
|
|
201
|
+
vertical_slug TEXT NOT NULL,
|
|
202
|
+
channel TEXT NOT NULL,
|
|
203
|
+
version_id TEXT NOT NULL,
|
|
204
|
+
updated_at TEXT NOT NULL,
|
|
205
|
+
PRIMARY KEY (vertical_slug, channel)
|
|
206
|
+
);
|
|
207
|
+
-- Organizations inside a tenant (K-22). Membership tuples point at these and
|
|
208
|
+
-- grantToOrg targets them. Before this the id was a free-form string with no
|
|
209
|
+
-- record, so a typo addressed a phantom org. The tenant_id column is also
|
|
210
|
+
-- kernel-design §4.3's required orgId <-> tenantId join.
|
|
211
|
+
CREATE TABLE IF NOT EXISTS orgs (
|
|
212
|
+
org_id TEXT PRIMARY KEY,
|
|
213
|
+
tenant_id TEXT NOT NULL,
|
|
214
|
+
slug TEXT NOT NULL,
|
|
215
|
+
name TEXT NOT NULL,
|
|
99
216
|
created_at TEXT NOT NULL
|
|
100
217
|
);
|
|
101
218
|
CREATE TABLE IF NOT EXISTS _substrat_tenant_tuples (
|
|
@@ -104,6 +221,9 @@ export class SqliteScopeHost {
|
|
|
104
221
|
relation TEXT NOT NULL,
|
|
105
222
|
object TEXT NOT NULL,
|
|
106
223
|
expires_at TEXT,
|
|
224
|
+
-- K-21: see the note on _substrat_tuples. Membership lives here, so this
|
|
225
|
+
-- is the column removeMember writes.
|
|
226
|
+
revoked_at TEXT,
|
|
107
227
|
PRIMARY KEY (tenant_id, subject, relation, object)
|
|
108
228
|
);
|
|
109
229
|
CREATE TABLE IF NOT EXISTS _substrat_roles (
|
|
@@ -125,6 +245,16 @@ export class SqliteScopeHost {
|
|
|
125
245
|
-- principal + home node. Provider-keyed so Better Auth, an OIDC issuer, or
|
|
126
246
|
-- several at once coexist. Authentication input only — authorization stays
|
|
127
247
|
-- in the tuples above.
|
|
248
|
+
-- Registered identity pools (K-23). A provider declares its topology before it
|
|
249
|
+
-- may link, so the directory knows whether the same externalId in two tenants is
|
|
250
|
+
-- one human (central) or two (tenant-bound). tenant_id is non-null exactly when
|
|
251
|
+
-- tenant-bound.
|
|
252
|
+
CREATE TABLE IF NOT EXISTS _substrat_identity_pools (
|
|
253
|
+
provider TEXT PRIMARY KEY,
|
|
254
|
+
topology TEXT NOT NULL,
|
|
255
|
+
tenant_id TEXT,
|
|
256
|
+
created_at TEXT NOT NULL
|
|
257
|
+
);
|
|
128
258
|
CREATE TABLE IF NOT EXISTS _substrat_identities (
|
|
129
259
|
provider TEXT NOT NULL,
|
|
130
260
|
external_id TEXT NOT NULL,
|
|
@@ -132,20 +262,50 @@ export class SqliteScopeHost {
|
|
|
132
262
|
tenant_id TEXT NOT NULL,
|
|
133
263
|
scope_id TEXT,
|
|
134
264
|
created_at TEXT NOT NULL,
|
|
135
|
-
PRIMARY KEY (provider, external_id)
|
|
265
|
+
PRIMARY KEY (tenant_id, provider, external_id)
|
|
136
266
|
);
|
|
137
267
|
-- Append-only control-plane audit trail (control-plane.md §4.4). Lives in
|
|
138
268
|
-- the directory, not a scope DB: it records cross-tenant staff actions and
|
|
139
269
|
-- is stamped host-side. Never UPDATEd, never DELETEd.
|
|
270
|
+
-- Staff READS (K-24). Separate from the admin log because they are two
|
|
271
|
+
-- things: a mutation is permanent evidence, a read is operational history.
|
|
272
|
+
-- One table would force one retention policy on both, the stricter would
|
|
273
|
+
-- win, and read noise would bury the mutation rows an auditor came for.
|
|
274
|
+
--
|
|
275
|
+
-- drained_at marks a row shipped to Tier 2. ONLY drained rows may be
|
|
276
|
+
-- pruned: expiring on age alone would destroy evidence while calling
|
|
277
|
+
-- itself retention. Until that sink exists nothing drains, so nothing
|
|
278
|
+
-- prunes and the window is unbounded — a stated limitation, not a policy.
|
|
279
|
+
CREATE TABLE IF NOT EXISTS _substrat_access_log (
|
|
280
|
+
id TEXT PRIMARY KEY,
|
|
281
|
+
actor TEXT NOT NULL,
|
|
282
|
+
method TEXT NOT NULL,
|
|
283
|
+
tenant_id TEXT,
|
|
284
|
+
scope_id TEXT,
|
|
285
|
+
params TEXT,
|
|
286
|
+
-- What separates navigation from an incident: "called listScopes" against
|
|
287
|
+
-- "enumerated 4,000 tenants". A log that cannot tell them apart is a log
|
|
288
|
+
-- nobody reads.
|
|
289
|
+
result_count INTEGER NOT NULL,
|
|
290
|
+
drained_at TEXT,
|
|
291
|
+
at TEXT NOT NULL
|
|
292
|
+
);
|
|
293
|
+
CREATE INDEX IF NOT EXISTS _substrat_access_log_actor ON _substrat_access_log (actor, id);
|
|
294
|
+
CREATE INDEX IF NOT EXISTS _substrat_access_log_tenant ON _substrat_access_log (tenant_id, id);
|
|
140
295
|
CREATE TABLE IF NOT EXISTS _substrat_admin_log (
|
|
141
296
|
id TEXT PRIMARY KEY,
|
|
142
297
|
actor TEXT NOT NULL,
|
|
143
298
|
action TEXT NOT NULL,
|
|
144
|
-
|
|
299
|
+
-- Nullable: a platform-level action targets no tenant (K-23).
|
|
300
|
+
tenant_id TEXT,
|
|
145
301
|
scope_id TEXT,
|
|
146
302
|
vertical TEXT,
|
|
147
303
|
before TEXT,
|
|
148
304
|
after TEXT,
|
|
305
|
+
-- The event that caused this action, when one did (K-22 §4.2). This is
|
|
306
|
+
-- what joins the connector seam's two halves: the module's emit and the
|
|
307
|
+
-- executor's effect. Null for a staff member acting directly.
|
|
308
|
+
caused_by TEXT,
|
|
149
309
|
at TEXT NOT NULL
|
|
150
310
|
);
|
|
151
311
|
-- Read-path indexes for the console (control-plane.md §4.5). The admin log
|
|
@@ -169,6 +329,11 @@ export class SqliteScopeHost {
|
|
|
169
329
|
});
|
|
170
330
|
this.admin = this.buildAdmin();
|
|
171
331
|
}
|
|
332
|
+
registerExecutor(id, eventType, handler) {
|
|
333
|
+
if (this.executors.has(id))
|
|
334
|
+
throw new Error(`executor '${id}' is already registered`);
|
|
335
|
+
this.executors.set(id, { eventType, handler });
|
|
336
|
+
}
|
|
172
337
|
registerModule(registration) {
|
|
173
338
|
const manifest = moduleManifest.parse(registration.manifest);
|
|
174
339
|
if (this.modules.has(manifest.id)) {
|
|
@@ -353,8 +518,12 @@ export class SqliteScopeHost {
|
|
|
353
518
|
rt.db.exec('ROLLBACK');
|
|
354
519
|
throw err;
|
|
355
520
|
}
|
|
356
|
-
// Post-commit, still inside the actor task: drain outbox → consumers
|
|
521
|
+
// Post-commit, still inside the actor task: drain outbox → consumers,
|
|
522
|
+
// then → executors. Prompt dispatch (K-22 §4.2): the common case
|
|
523
|
+
// completes inside this request, with the outbox as the retry backstop
|
|
524
|
+
// if it does not.
|
|
357
525
|
await this.dispatch(rt);
|
|
526
|
+
await this.dispatchExecutors(rt);
|
|
358
527
|
return structuredClone(result);
|
|
359
528
|
});
|
|
360
529
|
},
|
|
@@ -393,6 +562,47 @@ export class SqliteScopeHost {
|
|
|
393
562
|
// Event dispatch (testrun spec §9.2.3): at-least-once, kernel-journaled,
|
|
394
563
|
// consumers run as system-actor operations in their own transactions.
|
|
395
564
|
// -------------------------------------------------------------------------
|
|
565
|
+
/**
|
|
566
|
+
* Run executors over this scope's outbox (K-22 §4.2) — the connector half of the
|
|
567
|
+
* seam. Same at-least-once journal as consumers, keyed on a distinct delivery id
|
|
568
|
+
* so an executor and a module consumer on the same event do not shadow each other.
|
|
569
|
+
*
|
|
570
|
+
* Runs OUTSIDE the scope's transaction, and deliberately so: the executor acts on
|
|
571
|
+
* the directory, which is not part of the scope's serialization domain. The
|
|
572
|
+
* atomicity that matters already happened — the event only exists because the
|
|
573
|
+
* emitting transaction committed.
|
|
574
|
+
*
|
|
575
|
+
* A throwing executor leaves the delivery unjournaled, so it is retried on the
|
|
576
|
+
* next dispatch. That is the backstop; it is not a substitute for idempotent
|
|
577
|
+
* handlers, which at-least-once still requires.
|
|
578
|
+
*/
|
|
579
|
+
async dispatchExecutors(rt) {
|
|
580
|
+
for (const [id, executor] of this.executors) {
|
|
581
|
+
const deliveryId = `executor:${id}`;
|
|
582
|
+
const rows = rt.db
|
|
583
|
+
.prepare(`SELECT * FROM _substrat_outbox o
|
|
584
|
+
WHERE o.type = ?
|
|
585
|
+
AND NOT EXISTS (
|
|
586
|
+
SELECT 1 FROM _substrat_deliveries d
|
|
587
|
+
WHERE d.event_id = o.id AND d.consumer_module = ?
|
|
588
|
+
)
|
|
589
|
+
ORDER BY o.id`)
|
|
590
|
+
.all(executor.eventType, deliveryId);
|
|
591
|
+
for (const row of rows) {
|
|
592
|
+
const event = this.parseOutboxRow(row);
|
|
593
|
+
this.causedBy = event.id;
|
|
594
|
+
try {
|
|
595
|
+
await executor.handler(this.admin, event);
|
|
596
|
+
}
|
|
597
|
+
finally {
|
|
598
|
+
this.causedBy = null;
|
|
599
|
+
}
|
|
600
|
+
rt.db
|
|
601
|
+
.prepare('INSERT OR IGNORE INTO _substrat_deliveries (event_id, consumer_module, delivered_at) VALUES (?, ?, ?)')
|
|
602
|
+
.run(row.id, deliveryId, new Date().toISOString());
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
396
606
|
async dispatch(rt) {
|
|
397
607
|
for (let round = 0; round < 50; round++) {
|
|
398
608
|
let deliveredAny = false;
|
|
@@ -463,12 +673,27 @@ export class SqliteScopeHost {
|
|
|
463
673
|
* remembering a call per method. `before` is captured only where cheaply
|
|
464
674
|
* readable; idempotent upserts with no cheap prior state pass `before: null`.
|
|
465
675
|
*/
|
|
676
|
+
/**
|
|
677
|
+
* Record a staff read (K-24). Called by every read on `HostAdmin`, which is why
|
|
678
|
+
* they all take an actor: a read the log cannot attribute is unrepresentable.
|
|
679
|
+
*
|
|
680
|
+
* `params` is a bounded summary, not the raw filter — enough to know what was
|
|
681
|
+
* asked, capped so one query cannot write an unbounded row.
|
|
682
|
+
*/
|
|
683
|
+
recordAccess(actor, method, target, params, resultCount) {
|
|
684
|
+
const summary = params == null ? null : JSON.stringify(params).slice(0, 500);
|
|
685
|
+
this.directory
|
|
686
|
+
.prepare(`INSERT INTO _substrat_access_log
|
|
687
|
+
(id, actor, method, tenant_id, scope_id, params, result_count, drained_at, at)
|
|
688
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)`)
|
|
689
|
+
.run(ulid(), actor, method, target.tenantId ?? null, target.scopeId ?? null, summary, resultCount, new Date().toISOString());
|
|
690
|
+
}
|
|
466
691
|
recordAdmin(actor, action, target, before, after) {
|
|
467
692
|
this.directory
|
|
468
693
|
.prepare(`INSERT INTO _substrat_admin_log
|
|
469
|
-
(id, actor, action, tenant_id, scope_id, vertical, before, after, at)
|
|
470
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
471
|
-
.run(ulid(), actor, action, target.tenantId, target.scopeId ?? null, target.vertical ?? null, before == null ? null : JSON.stringify(before), after == null ? null : JSON.stringify(after), new Date().toISOString());
|
|
694
|
+
(id, actor, action, tenant_id, scope_id, vertical, before, after, caused_by, at)
|
|
695
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
696
|
+
.run(ulid(), actor, action, target.tenantId ?? null, target.scopeId ?? null, target.vertical ?? null, before == null ? null : JSON.stringify(before), after == null ? null : JSON.stringify(after), this.causedBy, new Date().toISOString());
|
|
472
697
|
}
|
|
473
698
|
tenantHoldsEntitlement(tenantId, key) {
|
|
474
699
|
return (this.directory
|
|
@@ -487,6 +712,88 @@ export class SqliteScopeHost {
|
|
|
487
712
|
const r = this.directory.prepare('SELECT * FROM tenants WHERE tenant_id = ?').get(id);
|
|
488
713
|
return r ? mapTenant(r) : undefined;
|
|
489
714
|
};
|
|
715
|
+
const readPool = (provider) => {
|
|
716
|
+
const r = this.directory
|
|
717
|
+
.prepare('SELECT provider, topology, tenant_id FROM _substrat_identity_pools WHERE provider = ?')
|
|
718
|
+
.get(provider);
|
|
719
|
+
return r
|
|
720
|
+
? identityPool.parse({ provider: r.provider, topology: r.topology, tenantId: r.tenant_id })
|
|
721
|
+
: undefined;
|
|
722
|
+
};
|
|
723
|
+
/**
|
|
724
|
+
* A pool must be registered before it may link, and a tenant-bound pool may only
|
|
725
|
+
* link into its own tenant. Resolution needs no equivalent check — K-22's
|
|
726
|
+
* (tenantId, provider, externalId) key already scopes reads — so this is the one
|
|
727
|
+
* place the topology is established rather than merely assumed.
|
|
728
|
+
*/
|
|
729
|
+
const requirePoolServes = (provider, tenant) => {
|
|
730
|
+
const pool = readPool(provider);
|
|
731
|
+
if (!pool) {
|
|
732
|
+
throw new Error(`identity pool '${provider}' is not registered — a pool must declare its ` +
|
|
733
|
+
`topology before it may link (central vs tenant-bound decides whether the ` +
|
|
734
|
+
`same externalId in two tenants is one person or two)`);
|
|
735
|
+
}
|
|
736
|
+
if (pool.topology === 'tenant-bound' && pool.tenantId !== tenant) {
|
|
737
|
+
throw new Error(`identity pool '${provider}' is bound to tenant ${pool.tenantId} and cannot link into ${tenant}`);
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
const mapVertical = (r) => verticalSchema.parse({
|
|
741
|
+
slug: r.slug,
|
|
742
|
+
name: r.name,
|
|
743
|
+
source: r.source,
|
|
744
|
+
createdAt: r.created_at,
|
|
745
|
+
});
|
|
746
|
+
const readVertical = (slugValue) => {
|
|
747
|
+
const r = this.directory
|
|
748
|
+
.prepare('SELECT * FROM verticals WHERE slug = ?')
|
|
749
|
+
.get(slugValue);
|
|
750
|
+
return r ? mapVertical(r) : undefined;
|
|
751
|
+
};
|
|
752
|
+
const mapVersion = (r) => verticalVersion.parse({
|
|
753
|
+
id: r.id,
|
|
754
|
+
verticalSlug: r.vertical_slug,
|
|
755
|
+
version: r.version,
|
|
756
|
+
manifestDigest: r.manifest_digest,
|
|
757
|
+
permissionDigest: r.permission_digest,
|
|
758
|
+
migrationDigest: r.migration_digest,
|
|
759
|
+
deploymentRef: r.deployment_ref,
|
|
760
|
+
admission: r.admission,
|
|
761
|
+
admissionNote: r.admission_note,
|
|
762
|
+
createdAt: r.created_at,
|
|
763
|
+
});
|
|
764
|
+
const readVersion = (id) => {
|
|
765
|
+
const r = this.directory
|
|
766
|
+
.prepare('SELECT * FROM vertical_versions WHERE id = ?')
|
|
767
|
+
.get(id);
|
|
768
|
+
return r ? mapVersion(r) : undefined;
|
|
769
|
+
};
|
|
770
|
+
const mapOrg = (r) => orgSchema.parse({
|
|
771
|
+
id: r.org_id,
|
|
772
|
+
tenantId: r.tenant_id,
|
|
773
|
+
slug: r.slug,
|
|
774
|
+
name: r.name,
|
|
775
|
+
createdAt: r.created_at,
|
|
776
|
+
});
|
|
777
|
+
// Scoped by tenant, not just by id: an org id from another tenant must read as
|
|
778
|
+
// absent here, or `grantToOrg` would reach across the boundary the record exists
|
|
779
|
+
// to make explicit.
|
|
780
|
+
const readOrg = (tenant, id) => {
|
|
781
|
+
const r = this.directory
|
|
782
|
+
.prepare('SELECT * FROM orgs WHERE tenant_id = ? AND org_id = ?')
|
|
783
|
+
.get(tenant, id);
|
|
784
|
+
return r ? mapOrg(r) : undefined;
|
|
785
|
+
};
|
|
786
|
+
/**
|
|
787
|
+
* Fail closed on an org that does not exist in this tenant. This is what the
|
|
788
|
+
* record buys: before it, membership and grants accepted any string, so a typo
|
|
789
|
+
* produced a tuple pointing at a phantom that silently granted nothing and
|
|
790
|
+
* appeared in no listing.
|
|
791
|
+
*/
|
|
792
|
+
const requireOrg = (tenant, id) => {
|
|
793
|
+
if (!readOrg(tenant, id)) {
|
|
794
|
+
throw new Error(`unknown org ${id} in tenant ${tenant}`);
|
|
795
|
+
}
|
|
796
|
+
};
|
|
490
797
|
// The directory row → the `scope` contract. Parsed, not cast: the columns are
|
|
491
798
|
// nullable in SQLite (ALTER TABLE cannot add NOT NULL to a populated table)
|
|
492
799
|
// while the contract requires them, so this parse is where that gap is held
|
|
@@ -504,6 +811,8 @@ export class SqliteScopeHost {
|
|
|
504
811
|
jurisdiction: r.jurisdiction,
|
|
505
812
|
vertical: r.vertical,
|
|
506
813
|
schemaVersion: r.schema_version,
|
|
814
|
+
verticalVersionId: r.vertical_version_id,
|
|
815
|
+
migrationFailure: mapMigrationFailure(r),
|
|
507
816
|
createdAt: r.created_at,
|
|
508
817
|
});
|
|
509
818
|
// Scope lifecycle transition (control-plane.md §4.2): validate ownership,
|
|
@@ -564,7 +873,7 @@ export class SqliteScopeHost {
|
|
|
564
873
|
this.roles.set(`${tenantId}/${parsed.key}`, parsed);
|
|
565
874
|
this.recordAdmin(actor, 'defineRole', { tenantId }, before, parsed);
|
|
566
875
|
},
|
|
567
|
-
listRoles: async (filter) => {
|
|
876
|
+
listRoles: async (actor, filter) => {
|
|
568
877
|
const where = [];
|
|
569
878
|
const params = [];
|
|
570
879
|
if (filter?.tenantId) {
|
|
@@ -582,6 +891,7 @@ export class SqliteScopeHost {
|
|
|
582
891
|
// Parsed, not cast: `permissions` is a JSON blob in a TEXT column, so the
|
|
583
892
|
// contract is the only thing standing between a corrupted row and the
|
|
584
893
|
// console rendering a role with permissions nobody declared.
|
|
894
|
+
this.recordAccess(actor, 'listRoles', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
|
|
585
895
|
return rows.map((r) => tenantRole.parse({
|
|
586
896
|
tenantId: r.tenant_id,
|
|
587
897
|
key: r.role_key,
|
|
@@ -604,13 +914,316 @@ export class SqliteScopeHost {
|
|
|
604
914
|
this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
|
|
605
915
|
},
|
|
606
916
|
grantToOrg: async (actor, orgId, permission, node, entity) => {
|
|
917
|
+
// The org must exist in the node's tenant. A grant to a phantom org is
|
|
918
|
+
// worse than an error: it looks applied, resolves for nobody, and shows up
|
|
919
|
+
// in the permission diff as though access were conferred.
|
|
920
|
+
requireOrg(node.tenantId, orgId);
|
|
607
921
|
writeGrant(`org:${orgId}`, permission, node, entity);
|
|
608
922
|
this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
|
|
609
923
|
},
|
|
924
|
+
// -- vertical + version registry (#31) ---------------------------------
|
|
925
|
+
// -- the hostname map (K-26) -------------------------------------------
|
|
926
|
+
bindHostname: async (actor, input) => {
|
|
927
|
+
const parsed = bindHostnameInput.parse(input);
|
|
928
|
+
const scope = this.directory
|
|
929
|
+
.prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
|
|
930
|
+
.get(parsed.scopeId);
|
|
931
|
+
if (!scope || scope.tenant_id !== parsed.tenantId) {
|
|
932
|
+
throw new Error(`unknown scope ${parsed.scopeId} in tenant ${parsed.tenantId}`);
|
|
933
|
+
}
|
|
934
|
+
const existing = this.directory
|
|
935
|
+
.prepare('SELECT scope_id FROM hostnames WHERE hostname = ?')
|
|
936
|
+
.get(parsed.hostname);
|
|
937
|
+
if (existing && existing.scope_id !== parsed.scopeId) {
|
|
938
|
+
// A hostname is globally unique and routes to exactly one place. Silently
|
|
939
|
+
// rebinding it would move another tenant's traffic.
|
|
940
|
+
throw new Error(`hostname '${parsed.hostname}' is already bound to another scope`);
|
|
941
|
+
}
|
|
942
|
+
// Exactly one canonical per (scope, surface): "which one do certs and
|
|
943
|
+
// redirects use" has to have one answer, so a new canonical demotes the old.
|
|
944
|
+
if (parsed.canonical) {
|
|
945
|
+
this.directory
|
|
946
|
+
.prepare('UPDATE hostnames SET canonical = 0 WHERE scope_id = ? AND surface = ?')
|
|
947
|
+
.run(parsed.scopeId, parsed.surface);
|
|
948
|
+
}
|
|
949
|
+
this.directory
|
|
950
|
+
.prepare(`INSERT OR REPLACE INTO hostnames
|
|
951
|
+
(hostname, tenant_id, scope_id, vertical_slug, surface, region,
|
|
952
|
+
status, status_note, canonical, created_at)
|
|
953
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?)`)
|
|
954
|
+
.run(parsed.hostname, parsed.tenantId, parsed.scopeId, scope.vertical, parsed.surface, parsed.region, parsed.canonical ? 1 : 0, new Date().toISOString());
|
|
955
|
+
this.recordAdmin(actor, 'bindHostname', { tenantId: parsed.tenantId, scopeId: parsed.scopeId, vertical: scope.vertical }, null, parsed);
|
|
956
|
+
},
|
|
957
|
+
setHostnameStatus: async (actor, raw, status, note) => {
|
|
958
|
+
const hostname = raw.toLowerCase(); // DNS is case-insensitive; the map is normalized
|
|
959
|
+
const row = this.directory
|
|
960
|
+
.prepare('SELECT tenant_id, scope_id, status FROM hostnames WHERE hostname = ?')
|
|
961
|
+
.get(hostname);
|
|
962
|
+
if (!row)
|
|
963
|
+
throw new Error(`unknown hostname '${hostname}'`);
|
|
964
|
+
if (row.status === status)
|
|
965
|
+
return; // idempotent, and a no-op is not audited
|
|
966
|
+
this.directory
|
|
967
|
+
.prepare('UPDATE hostnames SET status = ?, status_note = ? WHERE hostname = ?')
|
|
968
|
+
.run(status, note ?? null, hostname);
|
|
969
|
+
this.recordAdmin(actor, 'setHostnameStatus', { tenantId: row.tenant_id, scopeId: row.scope_id }, { status: row.status }, { status, note: note ?? null });
|
|
970
|
+
},
|
|
971
|
+
listHostnames: async (actor, filter) => {
|
|
972
|
+
const where = [];
|
|
973
|
+
const params = [];
|
|
974
|
+
if (filter?.tenantId) {
|
|
975
|
+
where.push('tenant_id = ?');
|
|
976
|
+
params.push(filter.tenantId);
|
|
977
|
+
}
|
|
978
|
+
if (filter?.scopeId) {
|
|
979
|
+
where.push('scope_id = ?');
|
|
980
|
+
params.push(filter.scopeId);
|
|
981
|
+
}
|
|
982
|
+
let sql = 'SELECT * FROM hostnames';
|
|
983
|
+
if (where.length)
|
|
984
|
+
sql += ` WHERE ${where.join(' AND ')}`;
|
|
985
|
+
sql += ' ORDER BY hostname';
|
|
986
|
+
const rows = this.directory.prepare(sql).all(...params);
|
|
987
|
+
this.recordAccess(actor, 'listHostnames', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
|
|
988
|
+
return rows.map(mapHostname);
|
|
989
|
+
},
|
|
990
|
+
resolveHostname: async (raw) => {
|
|
991
|
+
// The router's per-request read. No actor, not logged — same carve-out as
|
|
992
|
+
// resolveIdentity (K-24): this is a machine path, not a staff read.
|
|
993
|
+
const hostname = raw.toLowerCase();
|
|
994
|
+
const r = this.directory
|
|
995
|
+
.prepare(`SELECT tenant_id, scope_id, vertical_slug, surface, region
|
|
996
|
+
FROM hostnames WHERE hostname = ? AND status = 'active'`)
|
|
997
|
+
.get(hostname);
|
|
998
|
+
if (!r)
|
|
999
|
+
return undefined;
|
|
1000
|
+
return routeTarget.parse({
|
|
1001
|
+
tenantId: r.tenant_id,
|
|
1002
|
+
scopeId: r.scope_id,
|
|
1003
|
+
verticalSlug: r.vertical_slug,
|
|
1004
|
+
surface: r.surface,
|
|
1005
|
+
region: r.region,
|
|
1006
|
+
});
|
|
1007
|
+
},
|
|
1008
|
+
registerVertical: async (actor, input) => {
|
|
1009
|
+
const parsed = registerVerticalInput.parse(input);
|
|
1010
|
+
const existing = readVertical(parsed.slug);
|
|
1011
|
+
if (existing) {
|
|
1012
|
+
// Idempotent on an identical registration. A conflicting one throws:
|
|
1013
|
+
// changing a vertical's source silently rebinds what every scope on it
|
|
1014
|
+
// is understood to be running.
|
|
1015
|
+
if (existing.source === parsed.source && existing.name === parsed.name)
|
|
1016
|
+
return;
|
|
1017
|
+
throw new Error(`vertical '${parsed.slug}' is already registered as ${existing.source}`);
|
|
1018
|
+
}
|
|
1019
|
+
this.directory
|
|
1020
|
+
.prepare('INSERT INTO verticals (slug, name, source, created_at) VALUES (?, ?, ?, ?)')
|
|
1021
|
+
.run(parsed.slug, parsed.name, parsed.source, new Date().toISOString());
|
|
1022
|
+
this.recordAdmin(actor, 'registerVertical', { tenantId: null }, null, parsed);
|
|
1023
|
+
},
|
|
1024
|
+
listVerticals: async (actor) => {
|
|
1025
|
+
const rows = this.directory
|
|
1026
|
+
.prepare('SELECT * FROM verticals ORDER BY slug')
|
|
1027
|
+
.all();
|
|
1028
|
+
this.recordAccess(actor, 'listVerticals', {}, null, rows.length);
|
|
1029
|
+
return rows.map(mapVertical);
|
|
1030
|
+
},
|
|
1031
|
+
publishVersion: async (actor, input) => {
|
|
1032
|
+
const parsed = publishVersionInput.parse(input);
|
|
1033
|
+
if (!readVertical(parsed.verticalSlug)) {
|
|
1034
|
+
throw new Error(`unknown vertical '${parsed.verticalSlug}'`);
|
|
1035
|
+
}
|
|
1036
|
+
// Lands PENDING. A push is not a deploy — the gates decide, and binding a
|
|
1037
|
+
// scope is a separate, reviewable step.
|
|
1038
|
+
this.directory
|
|
1039
|
+
.prepare(`INSERT INTO vertical_versions
|
|
1040
|
+
(id, vertical_slug, version, manifest_digest, permission_digest,
|
|
1041
|
+
migration_digest, deployment_ref, admission, admission_note, created_at)
|
|
1042
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', NULL, ?)`)
|
|
1043
|
+
.run(parsed.id, parsed.verticalSlug, parsed.version, parsed.manifestDigest, parsed.permissionDigest, parsed.migrationDigest, parsed.deploymentRef, new Date().toISOString());
|
|
1044
|
+
this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, parsed);
|
|
1045
|
+
},
|
|
1046
|
+
listVersions: async (actor, verticalSlug) => {
|
|
1047
|
+
const rows = this.directory
|
|
1048
|
+
.prepare('SELECT * FROM vertical_versions WHERE vertical_slug = ? ORDER BY id')
|
|
1049
|
+
.all(verticalSlug);
|
|
1050
|
+
this.recordAccess(actor, 'listVersions', {}, { verticalSlug }, rows.length);
|
|
1051
|
+
return rows.map(mapVersion);
|
|
1052
|
+
},
|
|
1053
|
+
admitVersion: async (actor, versionId) => {
|
|
1054
|
+
const v = readVersion(versionId);
|
|
1055
|
+
if (!v)
|
|
1056
|
+
throw new Error(`unknown version ${versionId}`);
|
|
1057
|
+
if (v.admission === 'admitted')
|
|
1058
|
+
return; // idempotent
|
|
1059
|
+
if (v.admission === 'rejected') {
|
|
1060
|
+
throw new Error(`version ${versionId} was rejected — publish a new one`);
|
|
1061
|
+
}
|
|
1062
|
+
this.directory
|
|
1063
|
+
.prepare("UPDATE vertical_versions SET admission = 'admitted' WHERE id = ?")
|
|
1064
|
+
.run(versionId);
|
|
1065
|
+
this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, {
|
|
1066
|
+
admission: 'admitted',
|
|
1067
|
+
});
|
|
1068
|
+
},
|
|
1069
|
+
rejectVersion: async (actor, versionId, note) => {
|
|
1070
|
+
const v = readVersion(versionId);
|
|
1071
|
+
if (!v)
|
|
1072
|
+
throw new Error(`unknown version ${versionId}`);
|
|
1073
|
+
if (v.admission === 'admitted') {
|
|
1074
|
+
throw new Error(`version ${versionId} is already admitted — it may be bound`);
|
|
1075
|
+
}
|
|
1076
|
+
if (v.admission === 'rejected')
|
|
1077
|
+
return; // idempotent
|
|
1078
|
+
this.directory
|
|
1079
|
+
.prepare("UPDATE vertical_versions SET admission = 'rejected', admission_note = ? WHERE id = ?")
|
|
1080
|
+
.run(note, versionId);
|
|
1081
|
+
this.recordAdmin(actor, 'rejectVersion', { tenantId: null }, { admission: v.admission }, {
|
|
1082
|
+
admission: 'rejected',
|
|
1083
|
+
note,
|
|
1084
|
+
});
|
|
1085
|
+
},
|
|
1086
|
+
promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
|
|
1087
|
+
const incoming = readVersion(versionId);
|
|
1088
|
+
if (!incoming)
|
|
1089
|
+
throw new Error(`unknown version ${versionId}`);
|
|
1090
|
+
if (incoming.verticalSlug !== verticalSlug) {
|
|
1091
|
+
throw new Error(`version ${versionId} belongs to '${incoming.verticalSlug}'`);
|
|
1092
|
+
}
|
|
1093
|
+
if (incoming.admission !== 'admitted') {
|
|
1094
|
+
throw new Error(`version ${versionId} is ${incoming.admission}, not admitted — it cannot be promoted`);
|
|
1095
|
+
}
|
|
1096
|
+
const current = this.directory
|
|
1097
|
+
.prepare('SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = ?')
|
|
1098
|
+
.get(verticalSlug, channel);
|
|
1099
|
+
const outgoing = current ? readVersion(current.version_id) : undefined;
|
|
1100
|
+
const ack = promotionAcknowledgement.parse(acknowledge ?? {});
|
|
1101
|
+
// §4's two checkpoints, fired where the blast radius is. A FIRST promotion
|
|
1102
|
+
// has nothing to diff against, so there is nothing to acknowledge — the
|
|
1103
|
+
// gate is about change, not about existence.
|
|
1104
|
+
if (outgoing) {
|
|
1105
|
+
if (outgoing.permissionDigest !== incoming.permissionDigest && !ack.permissionChange) {
|
|
1106
|
+
throw new Error(`promotion changes the permission surface (${outgoing.permissionDigest} → ` +
|
|
1107
|
+
`${incoming.permissionDigest}) — acknowledge it explicitly to promote`);
|
|
1108
|
+
}
|
|
1109
|
+
if (outgoing.migrationDigest !== incoming.migrationDigest && !ack.migrationChange) {
|
|
1110
|
+
throw new Error(`promotion changes migrations (${outgoing.migrationDigest} → ` +
|
|
1111
|
+
`${incoming.migrationDigest}) — acknowledge it explicitly to promote`);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
this.directory
|
|
1115
|
+
.prepare(`INSERT INTO vertical_channels (vertical_slug, channel, version_id, updated_at)
|
|
1116
|
+
VALUES (?, ?, ?, ?)
|
|
1117
|
+
ON CONFLICT (vertical_slug, channel) DO UPDATE SET version_id = ?, updated_at = ?`)
|
|
1118
|
+
.run(verticalSlug, channel, versionId, new Date().toISOString(), versionId, new Date().toISOString());
|
|
1119
|
+
// The acknowledgement is recorded, not just enforced: that is what turns
|
|
1120
|
+
// "someone reviewed the permission change" into evidence.
|
|
1121
|
+
this.recordAdmin(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack });
|
|
1122
|
+
},
|
|
1123
|
+
listChannels: async (actor, verticalSlug) => {
|
|
1124
|
+
const rows = this.directory
|
|
1125
|
+
.prepare('SELECT * FROM vertical_channels WHERE vertical_slug = ? ORDER BY channel')
|
|
1126
|
+
.all(verticalSlug);
|
|
1127
|
+
this.recordAccess(actor, 'listChannels', {}, { verticalSlug }, rows.length);
|
|
1128
|
+
return rows.map((r) => verticalChannel.parse({
|
|
1129
|
+
verticalSlug: r.vertical_slug,
|
|
1130
|
+
channel: r.channel,
|
|
1131
|
+
versionId: r.version_id,
|
|
1132
|
+
updatedAt: r.updated_at,
|
|
1133
|
+
}));
|
|
1134
|
+
},
|
|
1135
|
+
bindScopeVersion: async (actor, tenantId, scopeId, versionId) => {
|
|
1136
|
+
const v = readVersion(versionId);
|
|
1137
|
+
if (!v)
|
|
1138
|
+
throw new Error(`unknown version ${versionId}`);
|
|
1139
|
+
// The refusal this registry exists for. Without it, "a push lands pending"
|
|
1140
|
+
// is a convention, and D-30's argument is that we cannot afford conventions
|
|
1141
|
+
// where lockstep upgrades are the failure mode.
|
|
1142
|
+
if (v.admission !== 'admitted') {
|
|
1143
|
+
throw new Error(`version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
|
|
1144
|
+
}
|
|
1145
|
+
const scope = this.directory
|
|
1146
|
+
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
1147
|
+
.get(scopeId);
|
|
1148
|
+
if (!scope || scope.tenant_id !== tenantId) {
|
|
1149
|
+
throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
1150
|
+
}
|
|
1151
|
+
this.directory
|
|
1152
|
+
.prepare('UPDATE scopes SET vertical_version_id = ?, vertical = ? WHERE scope_id = ?')
|
|
1153
|
+
.run(versionId, v.verticalSlug, scopeId);
|
|
1154
|
+
this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, null, {
|
|
1155
|
+
versionId,
|
|
1156
|
+
vertical: v.verticalSlug,
|
|
1157
|
+
version: v.version,
|
|
1158
|
+
});
|
|
1159
|
+
},
|
|
1160
|
+
createOrg: async (actor, input) => {
|
|
1161
|
+
const parsed = createOrgInput.parse(input);
|
|
1162
|
+
if (readOrg(parsed.tenantId, parsed.id))
|
|
1163
|
+
return; // idempotent, unaudited
|
|
1164
|
+
// Checked explicitly rather than left to the UNIQUE index: OR IGNORE would
|
|
1165
|
+
// swallow a collision from a DIFFERENT id and report success, silently not
|
|
1166
|
+
// creating the org the caller asked for. Fail closed instead (as createTenant).
|
|
1167
|
+
const slugOwner = this.directory
|
|
1168
|
+
.prepare('SELECT org_id FROM orgs WHERE tenant_id = ? AND slug = ?')
|
|
1169
|
+
.get(parsed.tenantId, parsed.slug);
|
|
1170
|
+
if (slugOwner) {
|
|
1171
|
+
throw new Error(`org slug '${parsed.slug}' already taken by ${slugOwner.org_id} (slugs are unique per tenant)`);
|
|
1172
|
+
}
|
|
1173
|
+
this.directory
|
|
1174
|
+
.prepare('INSERT INTO orgs (org_id, tenant_id, slug, name, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
1175
|
+
.run(parsed.id, parsed.tenantId, parsed.slug, parsed.name, new Date().toISOString());
|
|
1176
|
+
this.recordAdmin(actor, 'createOrg', { tenantId: parsed.tenantId }, null, parsed);
|
|
1177
|
+
},
|
|
1178
|
+
listOrgs: async (actor, tenantId) => {
|
|
1179
|
+
const rows = this.directory
|
|
1180
|
+
.prepare('SELECT * FROM orgs WHERE tenant_id = ? ORDER BY slug')
|
|
1181
|
+
.all(tenantId).map(mapOrg);
|
|
1182
|
+
this.recordAccess(actor, 'listOrgs', { tenantId }, null, rows.length);
|
|
1183
|
+
return rows;
|
|
1184
|
+
},
|
|
1185
|
+
getOrg: async (actor, tenantId, orgId) => {
|
|
1186
|
+
const o = readOrg(tenantId, orgId);
|
|
1187
|
+
this.recordAccess(actor, 'getOrg', { tenantId }, { orgId }, o ? 1 : 0);
|
|
1188
|
+
return o;
|
|
1189
|
+
},
|
|
610
1190
|
addMember: async (actor, tenantId, principal, orgId) => {
|
|
1191
|
+
requireOrg(tenantId, orgId);
|
|
1192
|
+
// INSERT OR REPLACE, so re-adding a revoked member clears the tombstone —
|
|
1193
|
+
// they are a member again. The add/revoke history is not lost: it lives in
|
|
1194
|
+
// the append-only admin log, which is where "what happened" belongs. The
|
|
1195
|
+
// tuple carries "what is true now" plus enough to explain a live proof.
|
|
611
1196
|
writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${orgId}`);
|
|
612
1197
|
this.recordAdmin(actor, 'addMember', { tenantId }, null, { principal, orgId });
|
|
613
1198
|
},
|
|
1199
|
+
removeMember: async (actor, tenantId, principal, orgId) => {
|
|
1200
|
+
requireOrg(tenantId, orgId);
|
|
1201
|
+
// Tombstone (K-21), never DELETE. Guarded on `revoked_at IS NULL` so a
|
|
1202
|
+
// repeat revoke neither moves the timestamp nor writes a second audit row.
|
|
1203
|
+
const info = this.directory
|
|
1204
|
+
.prepare(`UPDATE _substrat_tenant_tuples SET revoked_at = ?
|
|
1205
|
+
WHERE tenant_id = ? AND subject = ? AND relation = 'member' AND object = ?
|
|
1206
|
+
AND revoked_at IS NULL`)
|
|
1207
|
+
.run(new Date().toISOString(), tenantId, `principal:${principal}`, `org:${orgId}`);
|
|
1208
|
+
if (info.changes === 0)
|
|
1209
|
+
return; // never a member, or already revoked
|
|
1210
|
+
this.recordAdmin(actor, 'removeMember', { tenantId }, { principal, orgId }, null);
|
|
1211
|
+
},
|
|
1212
|
+
listMembers: async (actor, tenantId, orgId, options) => {
|
|
1213
|
+
requireOrg(tenantId, orgId);
|
|
1214
|
+
const rows = this.directory
|
|
1215
|
+
.prepare(`SELECT subject, revoked_at FROM _substrat_tenant_tuples
|
|
1216
|
+
WHERE tenant_id = ? AND relation = 'member' AND object = ?
|
|
1217
|
+
${options?.includeRevoked ? '' : 'AND revoked_at IS NULL'}
|
|
1218
|
+
ORDER BY subject`)
|
|
1219
|
+
.all(tenantId, `org:${orgId}`);
|
|
1220
|
+
this.recordAccess(actor, 'listMembers', { tenantId }, { orgId, ...options }, rows.length);
|
|
1221
|
+
return rows.map((r) => orgMembership.parse({
|
|
1222
|
+
principal: r.subject.slice('principal:'.length),
|
|
1223
|
+
orgId,
|
|
1224
|
+
revokedAt: r.revoked_at,
|
|
1225
|
+
}));
|
|
1226
|
+
},
|
|
614
1227
|
createTenant: async (actor, input) => {
|
|
615
1228
|
const parsed = createTenantInput.parse(input);
|
|
616
1229
|
// Idempotent: re-creating an existing tenant is a no-op, and a no-op is
|
|
@@ -642,9 +1255,18 @@ export class SqliteScopeHost {
|
|
|
642
1255
|
.run(status, tenantId);
|
|
643
1256
|
this.recordAdmin(actor, 'setTenantStatus', { tenantId }, { status: before.status }, { status });
|
|
644
1257
|
},
|
|
645
|
-
listTenants: async () =>
|
|
646
|
-
|
|
647
|
-
|
|
1258
|
+
listTenants: async (actor) => {
|
|
1259
|
+
const rows = this.directory.prepare('SELECT * FROM tenants ORDER BY tenant_id').all().map(mapTenant);
|
|
1260
|
+
// Enumerating every tenant on the platform is the read this log exists for.
|
|
1261
|
+
this.recordAccess(actor, 'listTenants', {}, null, rows.length);
|
|
1262
|
+
return rows;
|
|
1263
|
+
},
|
|
1264
|
+
getTenant: async (actor, tenantId) => {
|
|
1265
|
+
const t = readTenant(tenantId);
|
|
1266
|
+
this.recordAccess(actor, 'getTenant', { tenantId }, null, t ? 1 : 0);
|
|
1267
|
+
return t;
|
|
1268
|
+
},
|
|
1269
|
+
listScopes: async (actor, filter) => {
|
|
648
1270
|
const where = [];
|
|
649
1271
|
const params = [];
|
|
650
1272
|
if (filter?.tenantId) {
|
|
@@ -667,13 +1289,17 @@ export class SqliteScopeHost {
|
|
|
667
1289
|
const sql = 'SELECT * FROM scopes' +
|
|
668
1290
|
(where.length ? ` WHERE ${where.join(' AND ')}` : '') +
|
|
669
1291
|
' ORDER BY scope_id';
|
|
670
|
-
|
|
1292
|
+
const scopes = this.directory.prepare(sql).all(...params).map(mapScope);
|
|
1293
|
+
this.recordAccess(actor, 'listScopes', { tenantId: filter?.tenantId ?? null }, filter, scopes.length);
|
|
1294
|
+
return scopes;
|
|
671
1295
|
},
|
|
672
|
-
getScopeRecord: async (tenantId, scopeId) => {
|
|
1296
|
+
getScopeRecord: async (actor, tenantId, scopeId) => {
|
|
673
1297
|
const r = this.directory.prepare('SELECT * FROM scopes WHERE scope_id = ?').get(scopeId);
|
|
674
1298
|
// Cross-check the pair (K-3): a scope that exists under a DIFFERENT tenant
|
|
675
1299
|
// reads as absent, never as itself. Same rule as getScope's stub mint.
|
|
676
|
-
|
|
1300
|
+
const found = r && r.tenant_id === tenantId;
|
|
1301
|
+
this.recordAccess(actor, 'getScopeRecord', { tenantId, scopeId }, null, found ? 1 : 0);
|
|
1302
|
+
if (!found)
|
|
677
1303
|
return undefined;
|
|
678
1304
|
return mapScope(r);
|
|
679
1305
|
},
|
|
@@ -699,35 +1325,139 @@ export class SqliteScopeHost {
|
|
|
699
1325
|
return; // nothing held, nothing changed
|
|
700
1326
|
this.recordAdmin(actor, 'revokeEntitlement', { tenantId }, { entitlementKey }, null);
|
|
701
1327
|
},
|
|
702
|
-
listEntitlements: async (tenantId) =>
|
|
703
|
-
|
|
704
|
-
|
|
1328
|
+
listEntitlements: async (actor, tenantId) => {
|
|
1329
|
+
const keys = this.directory
|
|
1330
|
+
.prepare('SELECT entitlement_key FROM _substrat_entitlements WHERE tenant_id = ? ORDER BY entitlement_key')
|
|
1331
|
+
.all(tenantId).map((r) => r.entitlement_key);
|
|
1332
|
+
this.recordAccess(actor, 'listEntitlements', { tenantId }, null, keys.length);
|
|
1333
|
+
return keys;
|
|
1334
|
+
},
|
|
1335
|
+
registerIdentityPool: async (actor, input) => {
|
|
1336
|
+
const parsed = identityPool.parse(input);
|
|
1337
|
+
const existing = readPool(parsed.provider);
|
|
1338
|
+
if (existing) {
|
|
1339
|
+
// Idempotent on an identical registration. A CONFLICTING one throws:
|
|
1340
|
+
// flipping a live pool's topology silently reinterprets every row it owns
|
|
1341
|
+
// — the same externalId across tenants would change from one human to two.
|
|
1342
|
+
if (existing.topology === parsed.topology && existing.tenantId === parsed.tenantId) {
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
throw new Error(`identity pool '${parsed.provider}' is already registered as ${existing.topology}` +
|
|
1346
|
+
`${existing.tenantId ? ` for tenant ${existing.tenantId}` : ''}`);
|
|
1347
|
+
}
|
|
1348
|
+
this.directory
|
|
1349
|
+
.prepare('INSERT INTO _substrat_identity_pools (provider, topology, tenant_id, created_at) VALUES (?, ?, ?, ?)')
|
|
1350
|
+
.run(parsed.provider, parsed.topology, parsed.tenantId, new Date().toISOString());
|
|
1351
|
+
this.recordAdmin(actor, 'registerIdentityPool',
|
|
1352
|
+
// Null for a central pool: it belongs to no single tenant, which is what
|
|
1353
|
+
// made the admin log's tenantId nullable.
|
|
1354
|
+
{ tenantId: parsed.tenantId }, null, parsed);
|
|
1355
|
+
},
|
|
1356
|
+
getIdentityPool: async (actor, provider) => {
|
|
1357
|
+
const pool = readPool(provider);
|
|
1358
|
+
this.recordAccess(actor, 'getIdentityPool', {}, { provider }, pool ? 1 : 0);
|
|
1359
|
+
return pool;
|
|
1360
|
+
},
|
|
1361
|
+
listIdentityTenants: async (actor, provider, externalId) => {
|
|
1362
|
+
const pool = readPool(provider);
|
|
1363
|
+
if (!pool)
|
|
1364
|
+
throw new Error(`identity pool '${provider}' is not registered`);
|
|
1365
|
+
if (pool.topology !== 'central') {
|
|
1366
|
+
throw new Error(`identity pool '${provider}' is tenant-bound — enumerating tenants is only ` +
|
|
1367
|
+
`meaningful on a central pool, where the same externalId is the same person`);
|
|
1368
|
+
}
|
|
1369
|
+
const tenants = this.directory
|
|
1370
|
+
.prepare('SELECT tenant_id FROM _substrat_identities WHERE provider = ? AND external_id = ? ORDER BY tenant_id')
|
|
1371
|
+
.all(provider, externalId).map((r) => r.tenant_id);
|
|
1372
|
+
// Which tenants a given login touches — a cross-tenant question, and one
|
|
1373
|
+
// worth being able to ask who asked.
|
|
1374
|
+
this.recordAccess(actor, 'listIdentityTenants', {}, { provider }, tenants.length);
|
|
1375
|
+
return tenants;
|
|
1376
|
+
},
|
|
705
1377
|
linkIdentity: async (actor, input) => {
|
|
706
1378
|
const parsed = identityLink.parse(input);
|
|
707
|
-
|
|
708
|
-
|
|
1379
|
+
requirePoolServes(parsed.provider, parsed.tenantId);
|
|
1380
|
+
// Read before write. `INSERT OR IGNORE` alone cannot tell "already bound to the
|
|
1381
|
+
// same principal" (idempotent) from "already bound to someone else" (a
|
|
1382
|
+
// collision), and silently ignoring the second resolves one person as another.
|
|
1383
|
+
const existing = this.directory
|
|
1384
|
+
.prepare(`SELECT principal_id FROM _substrat_identities
|
|
1385
|
+
WHERE tenant_id = ? AND provider = ? AND external_id = ?`)
|
|
1386
|
+
.get(parsed.tenantId, parsed.provider, parsed.externalId);
|
|
1387
|
+
if (existing) {
|
|
1388
|
+
if (existing.principal_id === parsed.principal)
|
|
1389
|
+
return; // idempotent, unaudited
|
|
1390
|
+
throw new Error(`identity ${parsed.provider}:${parsed.externalId} in tenant ${parsed.tenantId} ` +
|
|
1391
|
+
`is already bound to ${existing.principal_id}`);
|
|
1392
|
+
}
|
|
1393
|
+
this.directory
|
|
1394
|
+
.prepare(`INSERT INTO _substrat_identities
|
|
709
1395
|
(provider, external_id, principal_id, tenant_id, scope_id, created_at)
|
|
710
1396
|
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
711
1397
|
.run(parsed.provider, parsed.externalId, parsed.principal, parsed.tenantId, parsed.scopeId ?? null, new Date().toISOString());
|
|
712
|
-
// Idempotent: an identity already bound is a no-op, and a no-op is not audited.
|
|
713
|
-
if (info.changes === 0)
|
|
714
|
-
return;
|
|
715
1398
|
this.recordAdmin(actor, 'linkIdentity', { tenantId: parsed.tenantId, scopeId: parsed.scopeId }, null, { provider: parsed.provider, externalId: parsed.externalId, principal: parsed.principal });
|
|
716
1399
|
},
|
|
717
|
-
resolveIdentity: async (provider, externalId) => {
|
|
1400
|
+
resolveIdentity: async (tenantId, provider, externalId) => {
|
|
718
1401
|
const row = this.directory
|
|
719
|
-
.prepare(`SELECT principal_id,
|
|
720
|
-
WHERE provider = ? AND external_id = ?`)
|
|
721
|
-
.get(provider, externalId);
|
|
1402
|
+
.prepare(`SELECT principal_id, scope_id FROM _substrat_identities
|
|
1403
|
+
WHERE tenant_id = ? AND provider = ? AND external_id = ?`)
|
|
1404
|
+
.get(tenantId, provider, externalId);
|
|
722
1405
|
if (!row)
|
|
723
1406
|
return undefined;
|
|
724
|
-
return resolvedIdentity.parse({
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1407
|
+
return resolvedIdentity.parse({ principal: row.principal_id, scopeId: row.scope_id });
|
|
1408
|
+
},
|
|
1409
|
+
accessLog: async (actor, filter) => {
|
|
1410
|
+
const where = [];
|
|
1411
|
+
const params = [];
|
|
1412
|
+
if (filter?.actor) {
|
|
1413
|
+
where.push('actor = ?');
|
|
1414
|
+
params.push(filter.actor);
|
|
1415
|
+
}
|
|
1416
|
+
if (filter?.tenantId) {
|
|
1417
|
+
where.push('tenant_id = ?');
|
|
1418
|
+
params.push(filter.tenantId);
|
|
1419
|
+
}
|
|
1420
|
+
if (filter?.method) {
|
|
1421
|
+
where.push('method = ?');
|
|
1422
|
+
params.push(filter.method);
|
|
1423
|
+
}
|
|
1424
|
+
let sql = 'SELECT * FROM _substrat_access_log';
|
|
1425
|
+
if (where.length)
|
|
1426
|
+
sql += ` WHERE ${where.join(' AND ')}`;
|
|
1427
|
+
sql += ' ORDER BY id';
|
|
1428
|
+
if (filter?.limit !== undefined) {
|
|
1429
|
+
sql += ' LIMIT ?';
|
|
1430
|
+
params.push(filter.limit);
|
|
1431
|
+
}
|
|
1432
|
+
const rows = this.directory.prepare(sql).all(...params);
|
|
1433
|
+
// Reading the access log is itself a read. Recorded BEFORE the rows are
|
|
1434
|
+
// returned, so the row describing this call is not in its own result.
|
|
1435
|
+
this.recordAccess(actor, 'accessLog', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
|
|
1436
|
+
return rows.map((r) => accessLogEntry.parse({
|
|
1437
|
+
id: r.id,
|
|
1438
|
+
actor: r.actor,
|
|
1439
|
+
method: r.method,
|
|
1440
|
+
tenantId: r.tenant_id,
|
|
1441
|
+
scopeId: r.scope_id,
|
|
1442
|
+
params: r.params,
|
|
1443
|
+
resultCount: r.result_count,
|
|
1444
|
+
drainedAt: r.drained_at,
|
|
1445
|
+
at: r.at,
|
|
1446
|
+
}));
|
|
1447
|
+
},
|
|
1448
|
+
pruneAccessLog: async (actor, limit) => {
|
|
1449
|
+
// ONLY drained rows. Age alone is not a licence to delete evidence.
|
|
1450
|
+
const info = this.directory
|
|
1451
|
+
.prepare(`DELETE FROM _substrat_access_log WHERE id IN (
|
|
1452
|
+
SELECT id FROM _substrat_access_log WHERE drained_at IS NOT NULL ORDER BY id LIMIT ?
|
|
1453
|
+
)`)
|
|
1454
|
+
.run(limit);
|
|
1455
|
+
if (info.changes > 0) {
|
|
1456
|
+
this.recordAdmin(actor, 'pruneAccessLog', { tenantId: null }, { pruned: info.changes }, null);
|
|
1457
|
+
}
|
|
1458
|
+
return info.changes;
|
|
729
1459
|
},
|
|
730
|
-
auditLog: async (filter) => {
|
|
1460
|
+
auditLog: async (actor, filter) => {
|
|
731
1461
|
const where = [];
|
|
732
1462
|
const params = [];
|
|
733
1463
|
if (filter?.tenantId) {
|
|
@@ -772,6 +1502,9 @@ export class SqliteScopeHost {
|
|
|
772
1502
|
params.push(filter.limit);
|
|
773
1503
|
}
|
|
774
1504
|
const rows = this.directory.prepare(sql).all(...params);
|
|
1505
|
+
// Reading the audit trail is itself audited. Who examined the record of
|
|
1506
|
+
// who did what is exactly the question an incident asks second.
|
|
1507
|
+
this.recordAccess(actor, 'auditLog', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
|
|
775
1508
|
return rows.map((r) => adminLogEntry.parse({
|
|
776
1509
|
id: r.id,
|
|
777
1510
|
actor: r.actor,
|
|
@@ -781,6 +1514,7 @@ export class SqliteScopeHost {
|
|
|
781
1514
|
vertical: r.vertical,
|
|
782
1515
|
before: r.before === null ? null : JSON.parse(r.before),
|
|
783
1516
|
after: r.after === null ? null : JSON.parse(r.after),
|
|
1517
|
+
causedBy: r.caused_by,
|
|
784
1518
|
at: r.at,
|
|
785
1519
|
}));
|
|
786
1520
|
},
|
|
@@ -796,7 +1530,92 @@ export class SqliteScopeHost {
|
|
|
796
1530
|
* within tenant"). Idempotent: on a fresh directory every column already
|
|
797
1531
|
* exists and every UPDATE matches nothing.
|
|
798
1532
|
*/
|
|
1533
|
+
/**
|
|
1534
|
+
* Additive column migration for a table that already exists in someone's data
|
|
1535
|
+
* directory. `PRAGMA table_info` is available in the pure adapter (the DO adapter
|
|
1536
|
+
* has to attempt-and-tolerate instead — see its `ensureDirectoryColumns`).
|
|
1537
|
+
*/
|
|
1538
|
+
ensureColumn(db, table, column, ddl) {
|
|
1539
|
+
const existing = db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === column);
|
|
1540
|
+
if (!existing)
|
|
1541
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* Rebuild `_substrat_identities` when it still carries the pre-K-22 global key.
|
|
1545
|
+
* A PRIMARY KEY cannot be ALTERed, so this is create-copy-drop-rename.
|
|
1546
|
+
*
|
|
1547
|
+
* The old shape is detected from `sqlite_master.sql` rather than `PRAGMA table_info`
|
|
1548
|
+
* (which reports PK membership but not composition readably), and because the same
|
|
1549
|
+
* check works on DO SQLite, where PRAGMA is restricted — so both adapters can use
|
|
1550
|
+
* one detection strategy.
|
|
1551
|
+
*
|
|
1552
|
+
* Rows carry `tenant_id` already, so the copy is lossless: what changes is which
|
|
1553
|
+
* columns are unique, not what is stored. Two pools that both issued `123` were
|
|
1554
|
+
* previously ONE row (the second link silently ignored); after the rebuild the
|
|
1555
|
+
* surviving row keeps its tenant and the other tenant's link can be made again.
|
|
1556
|
+
*/
|
|
1557
|
+
ensureIdentityKey() {
|
|
1558
|
+
const row = this.directory
|
|
1559
|
+
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
1560
|
+
.get('_substrat_identities');
|
|
1561
|
+
if (!row || row.sql.includes('PRIMARY KEY (tenant_id, provider, external_id)'))
|
|
1562
|
+
return;
|
|
1563
|
+
this.directory.exec(`
|
|
1564
|
+
CREATE TABLE _substrat_identities_new (
|
|
1565
|
+
provider TEXT NOT NULL,
|
|
1566
|
+
external_id TEXT NOT NULL,
|
|
1567
|
+
principal_id TEXT NOT NULL,
|
|
1568
|
+
tenant_id TEXT NOT NULL,
|
|
1569
|
+
scope_id TEXT,
|
|
1570
|
+
created_at TEXT NOT NULL,
|
|
1571
|
+
PRIMARY KEY (tenant_id, provider, external_id)
|
|
1572
|
+
);
|
|
1573
|
+
INSERT OR IGNORE INTO _substrat_identities_new
|
|
1574
|
+
(provider, external_id, principal_id, tenant_id, scope_id, created_at)
|
|
1575
|
+
SELECT provider, external_id, principal_id, tenant_id, scope_id, created_at
|
|
1576
|
+
FROM _substrat_identities;
|
|
1577
|
+
DROP TABLE _substrat_identities;
|
|
1578
|
+
ALTER TABLE _substrat_identities_new RENAME TO _substrat_identities;
|
|
1579
|
+
`);
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Drop the admin log's `tenant_id NOT NULL` (K-23). SQLite cannot relax a column
|
|
1583
|
+
* constraint in place, so this is the same create-copy-drop-rename the identity key
|
|
1584
|
+
* uses, detected the same way — from `sqlite_master.sql`, which works on DO SQLite
|
|
1585
|
+
* too. Rows are copied verbatim: the log stays append-only in content, this only
|
|
1586
|
+
* widens what a future row may say.
|
|
1587
|
+
*/
|
|
1588
|
+
ensureAdminLogTenantNullable() {
|
|
1589
|
+
const row = this.directory
|
|
1590
|
+
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
1591
|
+
.get('_substrat_admin_log');
|
|
1592
|
+
if (!row || !/tenant_id TEXT NOT NULL/.test(row.sql))
|
|
1593
|
+
return;
|
|
1594
|
+
this.directory.exec(`
|
|
1595
|
+
CREATE TABLE _substrat_admin_log_new (
|
|
1596
|
+
id TEXT PRIMARY KEY,
|
|
1597
|
+
actor TEXT NOT NULL,
|
|
1598
|
+
action TEXT NOT NULL,
|
|
1599
|
+
tenant_id TEXT,
|
|
1600
|
+
scope_id TEXT,
|
|
1601
|
+
vertical TEXT,
|
|
1602
|
+
before TEXT,
|
|
1603
|
+
after TEXT,
|
|
1604
|
+
at TEXT NOT NULL
|
|
1605
|
+
);
|
|
1606
|
+
INSERT INTO _substrat_admin_log_new
|
|
1607
|
+
SELECT id, actor, action, tenant_id, scope_id, vertical, before, after, at
|
|
1608
|
+
FROM _substrat_admin_log;
|
|
1609
|
+
DROP TABLE _substrat_admin_log;
|
|
1610
|
+
ALTER TABLE _substrat_admin_log_new RENAME TO _substrat_admin_log;
|
|
1611
|
+
`);
|
|
1612
|
+
}
|
|
799
1613
|
ensureDirectoryColumns() {
|
|
1614
|
+
this.ensureIdentityKey();
|
|
1615
|
+
this.ensureAdminLogTenantNullable();
|
|
1616
|
+
this.ensureColumn(this.directory, '_substrat_admin_log', 'caused_by', 'caused_by TEXT');
|
|
1617
|
+
// K-21's tombstone on tenant-level tuples (membership lives here).
|
|
1618
|
+
this.ensureColumn(this.directory, '_substrat_tenant_tuples', 'revoked_at', 'revoked_at TEXT');
|
|
800
1619
|
const existing = new Set(this.directory.prepare('PRAGMA table_info(scopes)').all().map((c) => c.name));
|
|
801
1620
|
for (const [column, ddl] of [
|
|
802
1621
|
['parent_scope_id', 'parent_scope_id TEXT'],
|
|
@@ -804,6 +1623,11 @@ export class SqliteScopeHost {
|
|
|
804
1623
|
['kind', 'kind TEXT'],
|
|
805
1624
|
['name', 'name TEXT'],
|
|
806
1625
|
['vertical', 'vertical TEXT'],
|
|
1626
|
+
['vertical_version_id', 'vertical_version_id TEXT'],
|
|
1627
|
+
['migration_failed_version', 'migration_failed_version TEXT'],
|
|
1628
|
+
['migration_error', 'migration_error TEXT'],
|
|
1629
|
+
['migration_attempts', 'migration_attempts INTEGER NOT NULL DEFAULT 0'],
|
|
1630
|
+
['migration_last_attempt_at', 'migration_last_attempt_at TEXT'],
|
|
807
1631
|
]) {
|
|
808
1632
|
if (!existing.has(column))
|
|
809
1633
|
this.directory.exec(`ALTER TABLE scopes ADD COLUMN ${ddl}`);
|
|
@@ -819,6 +1643,7 @@ export class SqliteScopeHost {
|
|
|
819
1643
|
// duplicates it exists to forbid (SQLite treats NULLs as distinct).
|
|
820
1644
|
this.directory.exec('CREATE UNIQUE INDEX IF NOT EXISTS scopes_tenant_slug ON scopes (tenant_id, slug)');
|
|
821
1645
|
this.directory.exec('CREATE UNIQUE INDEX IF NOT EXISTS tenants_slug ON tenants (slug)');
|
|
1646
|
+
this.directory.exec('CREATE UNIQUE INDEX IF NOT EXISTS orgs_tenant_slug ON orgs (tenant_id, slug)');
|
|
822
1647
|
}
|
|
823
1648
|
loadRoles() {
|
|
824
1649
|
const rows = this.directory
|
|
@@ -896,38 +1721,71 @@ export class SqliteScopeHost {
|
|
|
896
1721
|
// registers legitimately sits at schema_version '0'.
|
|
897
1722
|
if (pending.length === 0)
|
|
898
1723
|
return;
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
rt.db
|
|
911
|
-
|
|
912
|
-
.
|
|
913
|
-
|
|
1724
|
+
// The failing `module@version` and its cause, captured structurally rather than
|
|
1725
|
+
// re-parsed out of the thrown message — the directory record has to name both.
|
|
1726
|
+
let failure;
|
|
1727
|
+
try {
|
|
1728
|
+
await rt.actor.enqueue(() => {
|
|
1729
|
+
for (const { moduleId, migration } of pending) {
|
|
1730
|
+
const key = `${moduleId}@${migration.version}`;
|
|
1731
|
+
if (rt.appliedMigrations.has(key))
|
|
1732
|
+
continue;
|
|
1733
|
+
rt.db.exec('BEGIN IMMEDIATE');
|
|
1734
|
+
try {
|
|
1735
|
+
const already = rt.db
|
|
1736
|
+
.prepare('SELECT 1 FROM _substrat_migrations WHERE module_id = ? AND version = ?')
|
|
1737
|
+
.get(moduleId, migration.version);
|
|
1738
|
+
if (!already) {
|
|
1739
|
+
rt.db.exec(migration.sql);
|
|
1740
|
+
rt.db
|
|
1741
|
+
.prepare('INSERT INTO _substrat_migrations (module_id, version, applied_at) VALUES (?, ?, ?)')
|
|
1742
|
+
.run(moduleId, migration.version, new Date().toISOString());
|
|
1743
|
+
}
|
|
1744
|
+
rt.db.exec('COMMIT');
|
|
914
1745
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1746
|
+
catch (err) {
|
|
1747
|
+
rt.db.exec('ROLLBACK');
|
|
1748
|
+
failure = { version: key, error: err.message };
|
|
1749
|
+
throw new Error(`migration failed for ${key} — scope fails closed: ${err.message}`);
|
|
1750
|
+
}
|
|
1751
|
+
rt.appliedMigrations.add(key);
|
|
920
1752
|
}
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
finally {
|
|
1756
|
+
// `finally`, not the success path: a scope that failed closed is exactly the
|
|
1757
|
+
// one the fleet needs to see, and projecting only on success is what let a
|
|
1758
|
+
// half-migrated scope keep a stale `schema_version` and render as healthy
|
|
1759
|
+
// (#32). The throw still propagates — recording is not recovering.
|
|
1760
|
+
this.recordMigrationState(rt, failure);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* Project a scope's migration state into the directory — §5.4's "fleet questions
|
|
1765
|
+
* never fan out", so the index answers "which scopes are behind" and "which
|
|
1766
|
+
* failed" without waking anything.
|
|
1767
|
+
*
|
|
1768
|
+
* `appliedMigrations.size` is written on both paths: after a partial failure it
|
|
1769
|
+
* is the count that actually landed, which is more truthful than the pre-attempt
|
|
1770
|
+
* value. On success the failure columns are cleared, so `attempts` counts
|
|
1771
|
+
* *consecutive* failures — what the sweep's backoff (#49) needs.
|
|
1772
|
+
*/
|
|
1773
|
+
recordMigrationState(rt, failure) {
|
|
1774
|
+
const version = String(rt.appliedMigrations.size);
|
|
1775
|
+
if (!failure) {
|
|
1776
|
+
this.directory
|
|
1777
|
+
.prepare(`UPDATE scopes SET schema_version = ?, migration_failed_version = NULL,
|
|
1778
|
+
migration_error = NULL, migration_attempts = 0, migration_last_attempt_at = NULL
|
|
1779
|
+
WHERE scope_id = ?`)
|
|
1780
|
+
.run(version, rt.scopeId);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
928
1783
|
this.directory
|
|
929
|
-
.prepare(
|
|
930
|
-
|
|
1784
|
+
.prepare(`UPDATE scopes SET schema_version = ?, migration_failed_version = ?,
|
|
1785
|
+
migration_error = ?, migration_attempts = migration_attempts + 1,
|
|
1786
|
+
migration_last_attempt_at = ?
|
|
1787
|
+
WHERE scope_id = ?`)
|
|
1788
|
+
.run(version, failure.version, failure.error, new Date().toISOString(), rt.scopeId);
|
|
931
1789
|
}
|
|
932
1790
|
runtime(tenantId, scopeId) {
|
|
933
1791
|
const key = `${tenantId}/${scopeId}`;
|
|
@@ -937,6 +1795,9 @@ export class SqliteScopeHost {
|
|
|
937
1795
|
const db = new Database(join(this.dir, `${tenantId}__${scopeId}.sqlite`));
|
|
938
1796
|
db.pragma('journal_mode = WAL');
|
|
939
1797
|
db.exec(KERNEL_DDL);
|
|
1798
|
+
// KERNEL_DDL is all IF NOT EXISTS, so a scope DB created before K-21 keeps the
|
|
1799
|
+
// old shape — ALTER the tombstone in.
|
|
1800
|
+
this.ensureColumn(db, '_substrat_tuples', 'revoked_at', 'revoked_at TEXT');
|
|
940
1801
|
const appliedMigrations = new Set(db.prepare('SELECT module_id, version FROM _substrat_migrations').all().map((r) => `${r.module_id}@${r.version}`));
|
|
941
1802
|
const created = { tenantId, scopeId, db, actor: new ScopeActor(), appliedMigrations };
|
|
942
1803
|
this.scopes.set(key, created);
|