@substrat-run/adapter-cloudflare 0.137.0 → 0.138.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 +7 -1
- package/dist/checker.d.ts.map +1 -1
- package/dist/checker.js +2 -2
- package/dist/checker.js.map +1 -1
- package/dist/control-plane-do.d.ts +104 -31
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +224 -68
- package/dist/control-plane-do.js.map +1 -1
- package/dist/do-reply.d.ts +25 -0
- package/dist/do-reply.d.ts.map +1 -0
- package/dist/do-reply.js +17 -0
- package/dist/do-reply.js.map +1 -0
- package/dist/host.d.ts +117 -12
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +795 -435
- package/dist/host.js.map +1 -1
- package/dist/live-reads.d.ts +39 -1
- package/dist/live-reads.d.ts.map +1 -1
- package/dist/live-reads.js +79 -0
- package/dist/live-reads.js.map +1 -1
- package/dist/scope-do.d.ts +2 -7
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +336 -99
- package/dist/scope-do.js.map +1 -1
- package/package.json +11 -11
package/dist/control-plane-do.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
-
import { IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, assertRowLimit, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind,
|
|
2
|
+
import { membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, MEMBERSHIP_FENCES_TABLE, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, tenantCoverage, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, assertRowLimit, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, PEER_SWITCHES_DDL, SWITCH_OWED_DDL, clearSwitchOwed, markSwitchOwed, scopeOwesSwitch, switchesOwedOf, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, recordSwitchedOff, recordSwitchedOn, recordWriteSuperseded, restoreSwitchRecord, scopesSwitchedOffFor, switchRecordStatesOf, switchRecordsOf, switchesBackfillSqlOf, switchesDdlOf, switchesTableExists, switchesTableOf, tenantHeldOf, VERSION_MIGRATIONS_DDL, splitVersionMigrationsBatch, versionMigrationsOf, versionsAwaitSplit, writeVersionMigrations, MODEL_USAGE_RETENTION_DAYS, DO_SQL_LIMITS, ulid, isPrimaryScopeRow, resolveVerticalInstanceFrom, LEGACY_SCOPE_ROWS_BACKFILL, loadDirectoryDump, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, } from '@substrat-run/kernel';
|
|
3
|
+
import { replyOf } from './do-reply.js';
|
|
3
4
|
import { splitSqlStatements, switchSqlOver } from './scope-do.js';
|
|
4
5
|
import { doBuiltColumnsOf, doRedactionSql } from './sql.js';
|
|
5
|
-
import { assertReplayableDump, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, substratError } from '@substrat-run/contracts';
|
|
6
|
+
import { assertReplayableDump, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
|
|
6
7
|
/**
|
|
7
8
|
* The router's per-request directory read (`readRoute`), lifted out so its plan can be asked for
|
|
8
9
|
* (#2005): the scope's hostname set is a correlated subquery on the hot path every request takes,
|
|
@@ -456,6 +457,9 @@ const DIRECTORY_DDL = `
|
|
|
456
457
|
);
|
|
457
458
|
${IMPERSONATION_DDL}
|
|
458
459
|
${SYSTEM_SWITCHES_DDL}
|
|
460
|
+
${MEMBERSHIP_FENCES_DDL}
|
|
461
|
+
${PEER_SWITCHES_DDL}
|
|
462
|
+
${SWITCH_OWED_DDL}
|
|
459
463
|
CREATE TABLE IF NOT EXISTS _substrat_admin_log (
|
|
460
464
|
id TEXT PRIMARY KEY,
|
|
461
465
|
actor TEXT NOT NULL,
|
|
@@ -709,6 +713,19 @@ export const BACKFILL_FAILURES_KEY = 'versionMigrationsBackfillFailures';
|
|
|
709
713
|
export function backfillBackoffMs(failures) {
|
|
710
714
|
return Math.min(BACKFILL_PAUSE_MS * 2 ** failures, BACKFILL_BACKOFF_MAX_MS);
|
|
711
715
|
}
|
|
716
|
+
/** The directory writes `ControlPlaneDO.reply` answers for (#113): each refuses with a typed throw. */
|
|
717
|
+
export const REPLIED_METHODS = [
|
|
718
|
+
'createTenant',
|
|
719
|
+
'setTenantStatus',
|
|
720
|
+
'reapTenant',
|
|
721
|
+
'setTenantName',
|
|
722
|
+
'putTenantStore',
|
|
723
|
+
'putBlobStore',
|
|
724
|
+
'provisionScope',
|
|
725
|
+
'createOrg',
|
|
726
|
+
'registerIdentityPool',
|
|
727
|
+
'linkIdentity',
|
|
728
|
+
];
|
|
712
729
|
export class ControlPlaneDO extends DurableObject {
|
|
713
730
|
sql;
|
|
714
731
|
/** The directory's store as the kernel's SQL handle — the switch record's helpers (#1674). */
|
|
@@ -792,20 +809,30 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
792
809
|
* construction. A directory restore runs `buildDirectorySchema` inside its own transaction
|
|
793
810
|
* instead (`importDump`).
|
|
794
811
|
*
|
|
795
|
-
*
|
|
796
|
-
* creates its table, and its table is created in the SAME transaction as the
|
|
797
|
-
* (Copilot review): the gate is "the table does not exist yet", so a table committed
|
|
798
|
-
* of a backfill that then failed would read as already migrated on every later run. On
|
|
812
|
+
* Each kill switch's record (#1674, and #2029's for a peer) is backfilled from the admin log
|
|
813
|
+
* on a run that creates its table, and its table is created in the SAME transaction as the
|
|
814
|
+
* backfill (Copilot review): the gate is "the table does not exist yet", so a table committed
|
|
815
|
+
* ahead of a backfill that then failed would read as already migrated on every later run. On
|
|
799
816
|
* such a run its statements are held back from the loop below and run with the backfill.
|
|
800
817
|
*/
|
|
801
818
|
applyDirectorySchema() {
|
|
802
|
-
const
|
|
803
|
-
|
|
804
|
-
|
|
819
|
+
const newRecords = SWITCH_KINDS.filter((kind) => !switchesTableExists(this.kernelSql, kind));
|
|
820
|
+
const fencesAreNew = !membershipFencesTableExists(this.kernelSql);
|
|
821
|
+
this.buildDirectorySchema({ holdSwitchRecords: newRecords, holdFences: fencesAreNew });
|
|
822
|
+
for (const kind of newRecords) {
|
|
823
|
+
this.ctx.storage.transactionSync(() => {
|
|
824
|
+
for (const stmt of splitSqlStatements(switchesDdlOf(kind)))
|
|
825
|
+
this.sql.exec(stmt);
|
|
826
|
+
this.sql.exec(switchesBackfillSqlOf(kind));
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
// #1184: the removal fence and its backfill from the admin log, together, the same way.
|
|
830
|
+
if (fencesAreNew) {
|
|
805
831
|
this.ctx.storage.transactionSync(() => {
|
|
806
|
-
for (const stmt of splitSqlStatements(
|
|
832
|
+
for (const stmt of splitSqlStatements(MEMBERSHIP_FENCES_DDL))
|
|
807
833
|
this.sql.exec(stmt);
|
|
808
|
-
this
|
|
834
|
+
// Stamped now, inside this unit (`membershipFencesBackfillSql` says why).
|
|
835
|
+
this.sql.exec(membershipFencesBackfillSql(new Date().toISOString()));
|
|
809
836
|
});
|
|
810
837
|
}
|
|
811
838
|
}
|
|
@@ -813,12 +840,15 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
813
840
|
* The directory's tables as this code builds them, carrying whatever the directory already
|
|
814
841
|
* holds forward to that shape: the construction's pass, and a restore's (#1898, #1912), which
|
|
815
842
|
* runs it inside its transaction onto an emptied directory, before loading any row.
|
|
816
|
-
* `
|
|
817
|
-
* table with its backfill.
|
|
843
|
+
* `holdSwitchRecords` leaves those kinds' record statements (#1674, #2029) to the caller, which
|
|
844
|
+
* creates each table with its backfill; `holdFences` does the same for #1184's removal fence.
|
|
818
845
|
*/
|
|
819
|
-
buildDirectorySchema({
|
|
846
|
+
buildDirectorySchema({ holdSwitchRecords, holdFences = false }) {
|
|
847
|
+
const held = holdSwitchRecords.map(switchesTableOf);
|
|
820
848
|
for (const stmt of DIRECTORY_DDL_PLAN.loop) {
|
|
821
|
-
if (
|
|
849
|
+
if (held.some((table) => stmt.includes(table)))
|
|
850
|
+
continue;
|
|
851
|
+
if (holdFences && stmt.includes(MEMBERSHIP_FENCES_TABLE))
|
|
822
852
|
continue;
|
|
823
853
|
this.sql.exec(stmt);
|
|
824
854
|
}
|
|
@@ -1135,12 +1165,13 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1135
1165
|
// Every table from this code's DDL, onto an empty directory, so none of the pass's
|
|
1136
1166
|
// rebuilds fires (each would reach `transactionSync`, which this async transaction cannot
|
|
1137
1167
|
// hold): every table is created on the current shape.
|
|
1138
|
-
this.buildDirectorySchema({
|
|
1168
|
+
this.buildDirectorySchema({ holdSwitchRecords: [] });
|
|
1139
1169
|
// Refuses a table this code does not build, adds unknown columns bare, loads the rows by
|
|
1140
1170
|
// name, then the legacy scope and #1674 backfills, all inside this transaction.
|
|
1141
1171
|
loadDirectoryDump(tables, {
|
|
1142
1172
|
columnsOf: (name) => doBuiltColumnsOf(this.sql, name),
|
|
1143
1173
|
exec: (sql) => this.sql.exec(sql),
|
|
1174
|
+
now: () => new Date().toISOString(),
|
|
1144
1175
|
maxColumns: DO_SQL_LIMITS.columns,
|
|
1145
1176
|
insert: (sql, rows) => {
|
|
1146
1177
|
for (const row of rows)
|
|
@@ -1199,7 +1230,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1199
1230
|
.exec('SELECT tenant_id FROM tenants WHERE slug = ?', slug)
|
|
1200
1231
|
.toArray()[0];
|
|
1201
1232
|
if (slugOwner) {
|
|
1202
|
-
throw
|
|
1233
|
+
throw substratError('conflict', `tenant slug '${slug}' already taken by ${slugOwner.tenant_id} (slugs are unique)`);
|
|
1203
1234
|
}
|
|
1204
1235
|
this.sql.exec(`INSERT INTO tenants (tenant_id, slug, name, status, created_at, provisioned_by_tenant)
|
|
1205
1236
|
VALUES (?, ?, ?, 'active', ?, ?)`, id, slug, name, createdAt, provisionedByTenant);
|
|
@@ -1209,12 +1240,12 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1209
1240
|
setTenantStatus(tenantId, status) {
|
|
1210
1241
|
const before = this.readTenant(tenantId);
|
|
1211
1242
|
if (!before)
|
|
1212
|
-
throw
|
|
1243
|
+
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
1213
1244
|
// `reaped` is terminal and destroys data — unreachable here, only ever set by
|
|
1214
1245
|
// reapTenant, so a plain status flip cannot forge a tombstone over live data
|
|
1215
1246
|
// (§4.8, the tenant analogue of reapScope's archived-only gate).
|
|
1216
1247
|
if (status === 'reaped') {
|
|
1217
|
-
throw
|
|
1248
|
+
throw substratError('validation_failed', `tenant ${tenantId} cannot be set to 'reaped' via setTenantStatus — reap goes through reapTenant (control-plane.md §4.8)`);
|
|
1218
1249
|
}
|
|
1219
1250
|
// Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
|
|
1220
1251
|
// entering `deleting` stamps, leaving it (an un-delete) clears — the same shape
|
|
@@ -1241,9 +1272,9 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1241
1272
|
reapTenant(tenantId) {
|
|
1242
1273
|
const before = this.readTenant(tenantId);
|
|
1243
1274
|
if (!before)
|
|
1244
|
-
throw
|
|
1275
|
+
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
1245
1276
|
if (before.status !== 'deleting') {
|
|
1246
|
-
throw
|
|
1277
|
+
throw substratError('conflict', `tenant ${tenantId} is ${before.status}, not deleting — only a deleting tenant may be reaped`);
|
|
1247
1278
|
}
|
|
1248
1279
|
// KEPT as the tombstone: the `tenants` row itself and `_substrat_admin_log` (the
|
|
1249
1280
|
// compliance witness — never swept). Scope rows were already reaped individually.
|
|
@@ -1255,6 +1286,9 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1255
1286
|
'_substrat_entitlements', // per-tenant SKU flags
|
|
1256
1287
|
'orgs', // K-22 org records
|
|
1257
1288
|
'_substrat_system_switches', // #1674: the schedule switch's record, per scope
|
|
1289
|
+
'_substrat_membership_fences', // #1184: the latest removal, per principal
|
|
1290
|
+
'_substrat_peer_switches', // #2029: the peer switch's record, per scope
|
|
1291
|
+
'_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
|
|
1258
1292
|
]) {
|
|
1259
1293
|
this.sql.exec(`DELETE FROM ${table} WHERE tenant_id = ?`, tenantId);
|
|
1260
1294
|
}
|
|
@@ -1270,7 +1304,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1270
1304
|
setTenantName(tenantId, name) {
|
|
1271
1305
|
const before = this.readTenant(tenantId);
|
|
1272
1306
|
if (!before)
|
|
1273
|
-
throw
|
|
1307
|
+
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
1274
1308
|
this.sql.exec('UPDATE tenants SET name = ? WHERE tenant_id = ?', name, tenantId);
|
|
1275
1309
|
return before.name;
|
|
1276
1310
|
}
|
|
@@ -1304,10 +1338,10 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1304
1338
|
.exec('SELECT status FROM tenants WHERE tenant_id = ?', row.tenantId)
|
|
1305
1339
|
.toArray()[0];
|
|
1306
1340
|
if (!tenantRow) {
|
|
1307
|
-
throw
|
|
1341
|
+
throw substratError('not_found', `cannot provision tenant store under unknown tenant: ${row.tenantId}`);
|
|
1308
1342
|
}
|
|
1309
1343
|
if (tenantRow.status !== 'active') {
|
|
1310
|
-
throw
|
|
1344
|
+
throw substratError('conflict', `cannot provision tenant store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
|
|
1311
1345
|
}
|
|
1312
1346
|
this.sql.exec(`INSERT OR IGNORE INTO tenant_stores (tenant_id, vertical, binding, kind, ref, created_at)
|
|
1313
1347
|
VALUES (?, ?, ?, ?, ?, ?)`, row.tenantId, row.vertical, row.binding, row.kind, row.ref, row.createdAt);
|
|
@@ -1348,10 +1382,10 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1348
1382
|
.exec('SELECT status FROM tenants WHERE tenant_id = ?', row.tenantId)
|
|
1349
1383
|
.toArray()[0];
|
|
1350
1384
|
if (!tenantRow) {
|
|
1351
|
-
throw
|
|
1385
|
+
throw substratError('not_found', `cannot provision blob store under unknown tenant: ${row.tenantId}`);
|
|
1352
1386
|
}
|
|
1353
1387
|
if (tenantRow.status !== 'active') {
|
|
1354
|
-
throw
|
|
1388
|
+
throw substratError('conflict', `cannot provision blob store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
|
|
1355
1389
|
}
|
|
1356
1390
|
this.sql.exec(`INSERT OR IGNORE INTO blob_stores (tenant_id, vertical, binding, kind, ref, created_at)
|
|
1357
1391
|
VALUES (?, ?, ?, ?, ?, ?)`, row.tenantId, row.vertical, row.binding, row.kind, row.ref, row.createdAt);
|
|
@@ -1388,10 +1422,10 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1388
1422
|
.exec('SELECT status FROM tenants WHERE tenant_id = ?', tenantId)
|
|
1389
1423
|
.toArray()[0];
|
|
1390
1424
|
if (!tenantRow) {
|
|
1391
|
-
throw
|
|
1425
|
+
throw substratError('conflict', `cannot provision scope under unknown tenant: ${tenantId}`);
|
|
1392
1426
|
}
|
|
1393
1427
|
if (tenantRow.status !== 'active') {
|
|
1394
|
-
throw
|
|
1428
|
+
throw substratError('conflict', `cannot provision scope under non-active tenant (status: ${tenantRow.status}): ${tenantId}`);
|
|
1395
1429
|
}
|
|
1396
1430
|
// Idempotency is on the scope_id (§3.3: idempotent and journaled — safe to
|
|
1397
1431
|
// re-run), so an existing scope short-circuits before the slug check:
|
|
@@ -1404,7 +1438,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1404
1438
|
.exec("SELECT scope_id FROM scopes WHERE tenant_id = ? AND slug = ? AND status NOT IN ('archived', 'reaped')", tenantId, record.slug)
|
|
1405
1439
|
.toArray()[0];
|
|
1406
1440
|
if (slugOwner) {
|
|
1407
|
-
throw
|
|
1441
|
+
throw substratError('conflict', `scope slug '${record.slug}' already taken under tenant ${tenantId} ` +
|
|
1408
1442
|
`by ${slugOwner.scope_id} (slugs are unique within a tenant)`);
|
|
1409
1443
|
}
|
|
1410
1444
|
// A scope born while its vertical serves in place (#286) is born ON the serving
|
|
@@ -1486,6 +1520,21 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1486
1520
|
return undefined;
|
|
1487
1521
|
return row;
|
|
1488
1522
|
}
|
|
1523
|
+
/**
|
|
1524
|
+
* A directory write, its refusal answered as DATA (#113): the typed throws in these methods
|
|
1525
|
+
* would reach the coordinator flattened, so it calls them through here and rethrows the
|
|
1526
|
+
* refusal with its code (`unwrapReply`). Called directly, they still throw — a coordinator
|
|
1527
|
+
* from before this change does — and that caller reads the code folded into the message.
|
|
1528
|
+
*/
|
|
1529
|
+
async reply(method, args) {
|
|
1530
|
+
return replyOf(() => {
|
|
1531
|
+
// An RPC argument, so held to the list rather than trusted to be one of its names.
|
|
1532
|
+
if (!REPLIED_METHODS.includes(method))
|
|
1533
|
+
throw new Error(`not a replied directory method: ${String(method)}`);
|
|
1534
|
+
const run = this[method];
|
|
1535
|
+
return run.apply(this, args);
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1489
1538
|
/**
|
|
1490
1539
|
* The getScope gate (control-plane.md §4.1/§4.2): validate the scope belongs
|
|
1491
1540
|
* to the tenant and both records are active, or throw the fail-closed reason.
|
|
@@ -1502,11 +1551,10 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1502
1551
|
* The same gate, answering its refusal as DATA (#1718). An error thrown in a Durable
|
|
1503
1552
|
* Object arrives at the coordinator flattened, its code gone, so a typed throw here
|
|
1504
1553
|
* would still reach the caller untyped (#1714 measured it). A record crosses intact, so
|
|
1505
|
-
* the coordinator throws the refusal itself, typed
|
|
1506
|
-
*
|
|
1554
|
+
* the coordinator throws the refusal itself, typed (#113: every refusal but a failed
|
|
1555
|
+
* migration carries its code). Null means access is allowed.
|
|
1507
1556
|
*/
|
|
1508
1557
|
scopeAccessRefusal(tenantId, scopeId) {
|
|
1509
|
-
const refuse = (message) => ({ code: null, message });
|
|
1510
1558
|
const row = this.sql
|
|
1511
1559
|
.exec('SELECT tenant_id, status FROM scopes WHERE scope_id = ?', scopeId)
|
|
1512
1560
|
.toArray()[0];
|
|
@@ -1517,10 +1565,14 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1517
1565
|
.exec('SELECT status FROM tenants WHERE tenant_id = ?', tenantId)
|
|
1518
1566
|
.toArray()[0];
|
|
1519
1567
|
if (!tenantRow) {
|
|
1520
|
-
return
|
|
1568
|
+
return { code: 'not_found', message: `scope has no tenant record: (${tenantId}, ${scopeId})`, reason: SCOPE_GATE_REASONS.unrecorded };
|
|
1521
1569
|
}
|
|
1522
1570
|
if (tenantRow.status !== 'active') {
|
|
1523
|
-
return
|
|
1571
|
+
return {
|
|
1572
|
+
code: 'conflict',
|
|
1573
|
+
message: `tenant not active (status: ${tenantRow.status}): ${tenantId}`,
|
|
1574
|
+
reason: SCOPE_GATE_REASONS.notActive,
|
|
1575
|
+
};
|
|
1524
1576
|
}
|
|
1525
1577
|
if (row.status !== 'active') {
|
|
1526
1578
|
// A scope stuck in provisioning because its migrations failed must say so.
|
|
@@ -1531,10 +1583,17 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1531
1583
|
.exec('SELECT migration_failed_version, migration_error FROM scopes WHERE scope_id = ?', scopeId)
|
|
1532
1584
|
.toArray()[0];
|
|
1533
1585
|
if (failure?.migration_failed_version) {
|
|
1534
|
-
return
|
|
1535
|
-
|
|
1586
|
+
return {
|
|
1587
|
+
code: null,
|
|
1588
|
+
message: `migration failed for ${failure.migration_failed_version} — scope fails closed: ` +
|
|
1589
|
+
`${failure.migration_error ?? 'unknown error'}`,
|
|
1590
|
+
};
|
|
1536
1591
|
}
|
|
1537
|
-
return
|
|
1592
|
+
return {
|
|
1593
|
+
code: 'conflict',
|
|
1594
|
+
message: `scope not active (status: ${row.status}): ${scopeId}`,
|
|
1595
|
+
reason: SCOPE_GATE_REASONS.notActive,
|
|
1596
|
+
};
|
|
1538
1597
|
}
|
|
1539
1598
|
return null;
|
|
1540
1599
|
}
|
|
@@ -1569,7 +1628,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1569
1628
|
if (!from.includes(row.status)) {
|
|
1570
1629
|
return {
|
|
1571
1630
|
ok: false,
|
|
1572
|
-
code:
|
|
1631
|
+
code: 'conflict',
|
|
1573
1632
|
message: `illegal scope transition for ${action}: ${row.status} → ${to} ` +
|
|
1574
1633
|
`(allowed from: ${from.join('|')})`,
|
|
1575
1634
|
};
|
|
@@ -1590,7 +1649,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1590
1649
|
this.sql.exec('UPDATE scopes SET status = ? WHERE scope_id = ?', to, scopeId);
|
|
1591
1650
|
}
|
|
1592
1651
|
if (to === 'reaped') {
|
|
1593
|
-
|
|
1652
|
+
forgetSwitchesOf(this.kernelSql, scopeId);
|
|
1594
1653
|
this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
|
|
1595
1654
|
}
|
|
1596
1655
|
this.bumpLifecycleRevision(`scope:${scopeId}`); // #1713, with the status it counts
|
|
@@ -1998,6 +2057,11 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
1998
2057
|
* for a repair that failed, which is the one outcome that must keep being retried.
|
|
1999
2058
|
*/
|
|
2000
2059
|
markScopeProvisioned(scopeId, versionId) {
|
|
2060
|
+
// #2045: a scope owed a switch re-assert keeps no receipt, so the sweep reconciles it again
|
|
2061
|
+
// until its re-assert has settled every mark — including a mark written while this
|
|
2062
|
+
// reconcile ran, after its re-assert had read none.
|
|
2063
|
+
if (versionId !== null && scopeOwesSwitch(this.kernelSql, scopeId))
|
|
2064
|
+
versionId = null;
|
|
2001
2065
|
this.sql.exec('UPDATE scopes SET provisioned_version_id = ? WHERE scope_id = ?', versionId, scopeId);
|
|
2002
2066
|
}
|
|
2003
2067
|
/**
|
|
@@ -2008,7 +2072,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2008
2072
|
deleteScopeDirectory(scopeId) {
|
|
2009
2073
|
this.ctx.storage.transactionSync(() => {
|
|
2010
2074
|
this.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
|
|
2011
|
-
|
|
2075
|
+
forgetSwitchesOf(this.kernelSql, scopeId);
|
|
2012
2076
|
this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
|
|
2013
2077
|
this.sql.exec('DELETE FROM scopes WHERE scope_id = ?', scopeId);
|
|
2014
2078
|
});
|
|
@@ -2075,7 +2139,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2075
2139
|
.exec('SELECT org_id FROM orgs WHERE tenant_id = ? AND slug = ?', tenantId, slug)
|
|
2076
2140
|
.toArray()[0];
|
|
2077
2141
|
if (slugOwner) {
|
|
2078
|
-
throw
|
|
2142
|
+
throw substratError('conflict', `org slug '${slug}' already taken by ${slugOwner.org_id} (slugs are unique per tenant)`);
|
|
2079
2143
|
}
|
|
2080
2144
|
this.sql.exec('INSERT INTO orgs (org_id, tenant_id, slug, name, created_at) VALUES (?, ?, ?, ?, ?)', orgId, tenantId, slug, name, createdAt);
|
|
2081
2145
|
return true;
|
|
@@ -2085,13 +2149,69 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2085
2149
|
.exec('SELECT * FROM orgs WHERE tenant_id = ? ORDER BY slug', tenantId)
|
|
2086
2150
|
.toArray();
|
|
2087
2151
|
}
|
|
2152
|
+
/** The tenant-level reads, synchronous, for a bound asked inside one of this object's units (#1184). */
|
|
2153
|
+
directoryReader = {
|
|
2154
|
+
now: () => new Date().toISOString(),
|
|
2155
|
+
tenantTuples: (tenantId, subject, prefix) => this.tenantTuples(tenantId, subject, prefix),
|
|
2156
|
+
getRole: (tenantId, key) => this.getRole(tenantId, key),
|
|
2157
|
+
};
|
|
2088
2158
|
/**
|
|
2089
|
-
*
|
|
2090
|
-
*
|
|
2091
|
-
*
|
|
2159
|
+
* A membership change as ONE DO unit (#1184) — `HostAdmin.applyMembership`. Synchronous, so
|
|
2160
|
+
* no other request into this object runs between its reads and its writes: an add's removal
|
|
2161
|
+
* fence, then the bound — `boundedBy` covering every permission `roleKey` carries, against the
|
|
2162
|
+
* tuples and the role as they stand now (`tenantCoverage`) — then the write: the TENANT role
|
|
2163
|
+
* and its audit row for an add, `revokeAndFence`'s three for a removal. The coordinator mints
|
|
2164
|
+
* the row (id, actor, attribution, `causedBy`).
|
|
2092
2165
|
*/
|
|
2093
|
-
|
|
2094
|
-
|
|
2166
|
+
applyMembership(change, row) {
|
|
2167
|
+
const { tenantId, principal, roleKey, boundedBy } = change;
|
|
2168
|
+
return this.ctx.storage.transactionSync(() => {
|
|
2169
|
+
if (change.op === 'add') {
|
|
2170
|
+
const fence = this.sql.exec(MEMBERSHIP_FENCE_SINCE_SQL, tenantId, principal, change.unlessRemovedSince).toArray()[0];
|
|
2171
|
+
if (fence)
|
|
2172
|
+
return { applied: false, removedAt: fence.removed_at };
|
|
2173
|
+
}
|
|
2174
|
+
const role = this.getRole(tenantId, roleKey);
|
|
2175
|
+
if (!role)
|
|
2176
|
+
return { applied: false, unknownRole: roleKey };
|
|
2177
|
+
const bound = tenantCoverage(this.directoryReader, tenantId, boundedBy, role.permissions);
|
|
2178
|
+
if (!bound.covered)
|
|
2179
|
+
return { applied: false, missing: bound.missing };
|
|
2180
|
+
if (change.op === 'remove') {
|
|
2181
|
+
return { applied: true, changed: this.revokeAndFenceIn(tenantId, principal, `role:${roleKey}`, `tenant:${tenantId}`, row) };
|
|
2182
|
+
}
|
|
2183
|
+
this.writeTenantTuple(tenantId, `principal:${principal}`, `role:${roleKey}`, `tenant:${tenantId}`, null);
|
|
2184
|
+
this.recordAdmin({ ...row, at: new Date().toISOString() });
|
|
2185
|
+
return { applied: true };
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* A tenant-level removal as ONE DO unit (#1184) — `unassignRole` at the tenant node, and
|
|
2190
|
+
* `removeMember`: the tombstone, the principal's removal fence, and the audit row, which
|
|
2191
|
+
* K-21 writes only when something changed. The fence is raised either way: a removal of
|
|
2192
|
+
* someone whose add is still on its way takes nothing, and must still win. One method, so
|
|
2193
|
+
* no add can run between the revoke and the fence. Returns whether anything changed.
|
|
2194
|
+
*/
|
|
2195
|
+
revokeAndFence(tenantId, principal, relation, object, row) {
|
|
2196
|
+
return this.ctx.storage.transactionSync(() => this.revokeAndFenceIn(tenantId, principal, relation, object, row));
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* `revokeAndFence`'s writes, for inside a transaction a caller already holds.
|
|
2200
|
+
*
|
|
2201
|
+
* The time is minted HERE, in the unit, never taken from `row.at`: the coordinator mints its
|
|
2202
|
+
* row before an awaited RPC, and a queue delay in between would age the fence by however
|
|
2203
|
+
* long the call waited, which an add's cutoff would read as a removal from before its window
|
|
2204
|
+
* (Codex round 3). The fence is compared with an add's `occurredAt`, stamped by the ScopeDO's
|
|
2205
|
+
* clock. Both are this platform's own NTP-disciplined time, sub-second apart, which is far
|
|
2206
|
+
* inside `MEMBERSHIP_REMOVAL_SKEW_MS`.
|
|
2207
|
+
*/
|
|
2208
|
+
revokeAndFenceIn(tenantId, principal, relation, object, row) {
|
|
2209
|
+
const at = new Date().toISOString();
|
|
2210
|
+
const changed = this.revokeTenantTuple(tenantId, `principal:${principal}`, relation, object, at);
|
|
2211
|
+
this.sql.exec(RAISE_MEMBERSHIP_FENCE_SQL, tenantId, principal, at);
|
|
2212
|
+
if (changed)
|
|
2213
|
+
this.recordAdmin({ ...row, at });
|
|
2214
|
+
return changed;
|
|
2095
2215
|
}
|
|
2096
2216
|
/** Tombstone any tenant tuple by its exact (subject, relation, object). Returns
|
|
2097
2217
|
* whether a live row changed, so a repeat revoke is a silent no-op. */
|
|
@@ -2248,7 +2368,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2248
2368
|
if (existing) {
|
|
2249
2369
|
if (existing.topology === topology && existing.tenant_id === tenantId)
|
|
2250
2370
|
return false;
|
|
2251
|
-
throw
|
|
2371
|
+
throw substratError('conflict', `identity pool '${provider}' is already registered as ${existing.topology}` +
|
|
2252
2372
|
`${existing.tenant_id ? ` for tenant ${existing.tenant_id}` : ''}`);
|
|
2253
2373
|
}
|
|
2254
2374
|
this.sql.exec('INSERT INTO _substrat_identity_pools (provider, topology, tenant_id, created_at) VALUES (?, ?, ?, ?)', provider, topology, tenantId, createdAt);
|
|
@@ -2451,38 +2571,74 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2451
2571
|
granted_by, granted_at, revoked_at)
|
|
2452
2572
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`, row.connectionId, row.tenantId, row.vertical, row.permission, row.scopeId, row.expiresAt, row.grantedBy, row.grantedAt);
|
|
2453
2573
|
}
|
|
2454
|
-
// -- the
|
|
2574
|
+
// -- the kill switches' record (#1674, #2029) — `system-switch-record.ts` is the whole rule ---
|
|
2455
2575
|
/** OFF's write, before the scope's switch moves (#1823). Answers the row as it was. */
|
|
2456
|
-
|
|
2457
|
-
return
|
|
2576
|
+
recordSwitchedOff(row) {
|
|
2577
|
+
return this.writeSwitchIntent(row, recordSwitchedOff);
|
|
2458
2578
|
}
|
|
2459
|
-
/** ON's write, before the scope moves. Answers the row as it was, for a
|
|
2460
|
-
|
|
2461
|
-
return
|
|
2579
|
+
/** ON's write, before the scope moves. Answers the row as it was, for a call that held nothing to restore. */
|
|
2580
|
+
recordSwitchedOn(row) {
|
|
2581
|
+
return this.writeSwitchIntent(row, recordSwitchedOn);
|
|
2462
2582
|
}
|
|
2463
|
-
/**
|
|
2464
|
-
|
|
2465
|
-
|
|
2583
|
+
/**
|
|
2584
|
+
* #2045 (Codex r3): a switch call's record write is also its write-ahead intent, in ONE
|
|
2585
|
+
* transaction: the record, the subject's owed mark under this call's id, and the scope's reconcile
|
|
2586
|
+
* receipt cleared, so the sweep reconciles it and its re-assert settles the mark. Nothing that
|
|
2587
|
+
* happens to the move after this (a throw, a lost answer, a failed clear) can leave the scope
|
|
2588
|
+
* apart from its record with no mark to repair it. A write a newer call's row refuses writes
|
|
2589
|
+
* nothing at all.
|
|
2590
|
+
*/
|
|
2591
|
+
writeSwitchIntent(row, write) {
|
|
2592
|
+
return this.ctx.storage.transactionSync(() => {
|
|
2593
|
+
const prior = write(this.kernelSql, row);
|
|
2594
|
+
if (recordWriteSuperseded(prior, row))
|
|
2595
|
+
return prior;
|
|
2596
|
+
markSwitchOwed(this.kernelSql, row.kind, row.tenantId, row.scopeId, row.key, row.operationId);
|
|
2597
|
+
this.sql.exec('UPDATE scopes SET provisioned_version_id = NULL WHERE scope_id = ?', row.scopeId);
|
|
2598
|
+
return prior;
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
2601
|
+
/**
|
|
2602
|
+
* Put a row back as the switch call's record write found it — the move that followed it held
|
|
2603
|
+
* nothing. #2045: with `clearOwed`, the call's own owed mark goes in the same transaction. The
|
|
2604
|
+
* row put back carries the PRIOR call's operation id, which is the fence every later re-assert
|
|
2605
|
+
* confirms under, and a mark newer than that fence is never cleared by one; so a mark left behind
|
|
2606
|
+
* by a separate clear that failed would keep the scope un-receipted on every pass.
|
|
2607
|
+
*/
|
|
2608
|
+
restoreSwitchRecord(key, prior, clearOwed = false) {
|
|
2609
|
+
this.ctx.storage.transactionSync(() => {
|
|
2610
|
+
restoreSwitchRecord(this.kernelSql, key, prior);
|
|
2611
|
+
if (clearOwed)
|
|
2612
|
+
clearSwitchOwed(this.kernelSql, key.kind, key.tenantId, key.scopeId, key.key, key.operationId);
|
|
2613
|
+
});
|
|
2466
2614
|
}
|
|
2467
2615
|
/**
|
|
2468
|
-
* #1823: which of these
|
|
2469
|
-
* switch passes the scope as `tenantHeld`, so a
|
|
2470
|
-
* tuple is still switchable. Read here, where the tenant tuples live.
|
|
2616
|
+
* #1823 (#2030 for a peer): which of these subjects the tenant holds a live TENANT-level grant
|
|
2617
|
+
* for — what the switch passes the scope as `tenantHeld`, so a subject whose only authority
|
|
2618
|
+
* there is a tenant tuple is still switchable. Read here, where the tenant tuples live.
|
|
2471
2619
|
*/
|
|
2472
|
-
|
|
2473
|
-
return
|
|
2620
|
+
tenantHeldOf(kind, tenantId, keys, now) {
|
|
2621
|
+
return tenantHeldOf(this.kernelSql, kind, tenantId, keys, now);
|
|
2474
2622
|
}
|
|
2475
2623
|
/** The fleet read. */
|
|
2476
2624
|
listSystemSwitches(filter) {
|
|
2477
2625
|
return listSystemSwitchRecords(this.kernelSql, filter);
|
|
2478
2626
|
}
|
|
2479
|
-
/** One scope's recorded positions, by
|
|
2480
|
-
|
|
2481
|
-
return [...
|
|
2627
|
+
/** One scope's recorded positions of one kind, by subject — the status read's `recorded` join. */
|
|
2628
|
+
switchRecordsOf(kind, tenantId, scopeId) {
|
|
2629
|
+
return [...switchRecordsOf(this.kernelSql, kind, tenantId, scopeId)];
|
|
2630
|
+
}
|
|
2631
|
+
/** #2045: each record row's position and fence, by subject, read together — `switchRecordStatesOf`. */
|
|
2632
|
+
switchRecordStatesOf(kind, tenantId, scopeId) {
|
|
2633
|
+
return [...switchRecordStatesOf(this.kernelSql, kind, tenantId, scopeId)];
|
|
2634
|
+
}
|
|
2635
|
+
/** #2045: the subjects of one kind owed a re-assert on one scope. */
|
|
2636
|
+
switchesOwedOf(kind, tenantId, scopeId) {
|
|
2637
|
+
return [...switchesOwedOf(this.kernelSql, kind, tenantId, scopeId)];
|
|
2482
2638
|
}
|
|
2483
|
-
/**
|
|
2484
|
-
|
|
2485
|
-
|
|
2639
|
+
/** #2045: the scope confirmed a move of this subject under `fence` — `clearSwitchOwed`. */
|
|
2640
|
+
clearSwitchOwed(kind, tenantId, scopeId, key, fence) {
|
|
2641
|
+
clearSwitchOwed(this.kernelSql, kind, tenantId, scopeId, key, fence);
|
|
2486
2642
|
}
|
|
2487
2643
|
/** The tenant's LIVE connection grants (#592) — the provision/reconcile gather read. */
|
|
2488
2644
|
listConnectionGrants(tenantId) {
|
|
@@ -2546,7 +2702,7 @@ export class ControlPlaneDO extends DurableObject {
|
|
|
2546
2702
|
if (existing) {
|
|
2547
2703
|
if (existing.principal_id === principal)
|
|
2548
2704
|
return false;
|
|
2549
|
-
throw
|
|
2705
|
+
throw substratError('conflict', `identity ${provider}:${externalId} in tenant ${tenantId} is already bound to ${existing.principal_id}`);
|
|
2550
2706
|
}
|
|
2551
2707
|
this.sql.exec(`INSERT INTO _substrat_identities
|
|
2552
2708
|
(provider, external_id, principal_id, tenant_id, scope_id, created_at)
|