@substrat-run/adapter-cloudflare 0.160.0 → 0.162.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/control-plane-do.d.ts +97 -14
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +279 -39
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +111 -26
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +294 -196
- package/dist/host.js.map +1 -1
- package/dist/r2.d.ts.map +1 -1
- package/dist/r2.js +13 -10
- package/dist/r2.js.map +1 -1
- package/dist/scope-do.d.ts +27 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +290 -113
- package/dist/scope-do.js.map +1 -1
- package/package.json +9 -9
package/dist/scope-do.js
CHANGED
|
@@ -2,7 +2,7 @@ import { REWIND_REFUSED } from './rewind-refusal.js';
|
|
|
2
2
|
import { SYSTEM_DOOR_MOVED } from './system-door.js';
|
|
3
3
|
import { DurableObject } from 'cloudflare:workers';
|
|
4
4
|
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, toWireFailure, grantRefFromProof, principalId, principalBecomeCapabilityInput, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_DUMP_PAGE_MAX_BYTES, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, pageOf, countedPageOf, requestFingerprint, substratError, errorCodeOf, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
-
import { ulid, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, eraseSubjectFromModules, moduleErasurePlan, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_LEASE_EXPIRED_NOTE, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, delegatedRevokeSql, writeExplicitTupleIn, grantEntityShapeIn, topUpEntityGrantShapes, applyScopeRoleChange, changeScopeRole, revokeScopeRoles, scopeRoleHolders, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, SCOPE_SEATED_ROLES_SQL, reservedGrantRefusal, reservedRoleRefusal, reservedShapeRefusal, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, MIGRATION_DIGEST_FENCE_DDL, MIGRATION_DIGEST_MARK_LEGACY, assertJournalDumpCoherent, assertMigrationSql, migrationDivergence, migrationFailedError, migrationSetDivergence, migrationSteps, planMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, ATTACHMENT_BYTES_SQL, ATTACHMENT_CONDEMNED_DDL, ATTACHMENT_SWEEP_LICENCE_DDL, ATTACHMENT_SWEEP_LICENCE_TABLE, clearOrphanSweepLicence, condemnLicensedOrphanAttachments, consumeOrphanSweepLicence, licensedOrphanIds, recordOrphanSweepLicence, refuseUnlicensedOrphanSweep, refuseCondemnedAttachment, refuseCondemnedInDump, rowlessAttachmentIds, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, queueAttachmentTextBackfill, startAttachmentTextBackfill, recordAttachmentText, searchAttachments, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, SYSTEM_DOOR_WAIT, ancestorsWithin, } from '@substrat-run/kernel';
|
|
5
|
+
import { ulid, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, eraseSubjectFromModules, moduleErasurePlan, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_LEASE_EXPIRED_NOTE, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, delegatedRevokeSql, writeExplicitTupleIn, grantEntityShapeIn, topUpEntityGrantShapes, applyScopeRoleChange, changeScopeRole, revokeScopeRoles, scopeRoleHolders, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, SCOPE_SEATED_ROLES_SQL, inUnitRoleRefusal, reservedRoleSetRefusedRole, reservedGrantRefusal, reservedRoleRefusal, reservedShapeRefusal, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, MIGRATION_DIGEST_FENCE_DDL, MIGRATION_DIGEST_MARK_LEGACY, assertJournalDumpCoherent, assertMigrationSql, migrationDivergence, migrationFailedError, migrationSetDivergence, migrationSteps, planMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, ATTACHMENT_BYTES_SQL, ATTACHMENT_CONDEMNED_DDL, ATTACHMENT_SWEEP_LICENCE_DDL, ATTACHMENT_SWEEP_LICENCE_TABLE, clearOrphanSweepLicence, condemnLicensedOrphanAttachments, consumeOrphanSweepLicence, licensedOrphanIds, recordOrphanSweepLicence, refuseUnlicensedOrphanSweep, refuseCondemnedAttachment, refuseCondemnedInDump, rowlessAttachmentIds, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, queueAttachmentTextBackfill, startAttachmentTextBackfill, recordAttachmentText, searchAttachments, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, SYSTEM_DOOR_WAIT, ancestorsWithin, } from '@substrat-run/kernel';
|
|
6
6
|
import { isUpgradeRequest, readSubscription, LIVE_DECIDE_ATTEMPTS, LIVE_FANOUT_LIMIT, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, LIVE_EXPIRES_HEADER, LIVE_CLOSE, LIVE_SOCKETS_PER_PRINCIPAL, decodeLiveWithin, liveInstant, liveRefusal, liveRefusalStatus, } from './live-reads.js';
|
|
7
7
|
import { replyOf } from './do-reply.js';
|
|
8
8
|
import { OperationQueue } from './serialization.js';
|
|
@@ -44,6 +44,14 @@ const tenantRefusals = new WeakSet();
|
|
|
44
44
|
/** #1722: a load refused because its copy move's lease ran out (`CopyRestoreFence`), told apart
|
|
45
45
|
* from a store that moved, since the host must not read it as an applied retry. */
|
|
46
46
|
const fenceRefusals = new WeakSet();
|
|
47
|
+
/** #2531: a role set refused inside the unit writing it (`refuseRoleSetInUnit`), told apart by identity. */
|
|
48
|
+
const roleRefusals = new WeakSet();
|
|
49
|
+
/** `ProjectedRoleRow` as the reserved-key rule reads it. */
|
|
50
|
+
const roleShapeOfRow = (r) => ({
|
|
51
|
+
key: r.role_key,
|
|
52
|
+
permissions: JSON.parse(r.permissions),
|
|
53
|
+
...(r.holder === 'system' ? { holder: 'system' } : r.holder == null ? {} : { holder: 'person' }),
|
|
54
|
+
});
|
|
47
55
|
/**
|
|
48
56
|
* The scope spine, as this adapter builds it — one of two hand-written copies (#969).
|
|
49
57
|
*
|
|
@@ -1384,6 +1392,65 @@ options = {}) {
|
|
|
1384
1392
|
async attachmentBytesIfHeld() {
|
|
1385
1393
|
return (await this.holdsScope()) ? this.attachmentBytes() : null;
|
|
1386
1394
|
}
|
|
1395
|
+
/**
|
|
1396
|
+
* #2544: the introspection reads that name only a scope id, each as the held check and the read
|
|
1397
|
+
* in ONE call (as the size reads above are): `null` says the deployment does not hold this scope,
|
|
1398
|
+
* never an empty answer. The read itself is the one the unguarded method makes.
|
|
1399
|
+
*/
|
|
1400
|
+
async ifHeld(read) {
|
|
1401
|
+
return (await this.holdsScope()) ? read() : null;
|
|
1402
|
+
}
|
|
1403
|
+
facetEventsIfHeld(input) {
|
|
1404
|
+
return this.ifHeld(() => this.facetEvents(input));
|
|
1405
|
+
}
|
|
1406
|
+
eventEffectsIfHeld(input) {
|
|
1407
|
+
return this.ifHeld(() => this.eventEffects(input));
|
|
1408
|
+
}
|
|
1409
|
+
invocationEventsIfHeld(input) {
|
|
1410
|
+
return this.ifHeld(() => this.invocationEvents(input));
|
|
1411
|
+
}
|
|
1412
|
+
deadLettersIfHeld(input) {
|
|
1413
|
+
return this.ifHeld(() => this.deadLetters(input));
|
|
1414
|
+
}
|
|
1415
|
+
lifecycleFlowIfHeld(input) {
|
|
1416
|
+
return this.ifHeld(() => this.lifecycleFlow(input));
|
|
1417
|
+
}
|
|
1418
|
+
operationSeriesIfHeld(input) {
|
|
1419
|
+
return this.ifHeld(() => this.operationSeries(input));
|
|
1420
|
+
}
|
|
1421
|
+
eventCauseIfHeld(input) {
|
|
1422
|
+
return this.ifHeld(() => this.eventCause(input));
|
|
1423
|
+
}
|
|
1424
|
+
entityHistoryIfHeld(input) {
|
|
1425
|
+
return this.ifHeld(() => this.entityHistory(input));
|
|
1426
|
+
}
|
|
1427
|
+
appliedMigrationsIfHeld() {
|
|
1428
|
+
return this.ifHeld(() => this.appliedMigrations());
|
|
1429
|
+
}
|
|
1430
|
+
migrationBookmarksIfHeld() {
|
|
1431
|
+
return this.ifHeld(() => this.migrationBookmarks());
|
|
1432
|
+
}
|
|
1433
|
+
introspectTablesIfHeld() {
|
|
1434
|
+
return this.ifHeld(() => this.introspectTables());
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* #2563: the table page and the SQL console, each as the held check and the read in ONE call.
|
|
1438
|
+
* They answer as a `DoReply` (a refusal keeps its code across the hop, #113), `null` outside it
|
|
1439
|
+
* for a scope the deployment does not hold. The carry's reader of a wiped copy's tombstone does
|
|
1440
|
+
* not come through here: that copy is not held, by design, and it asks `introspectTableReply`.
|
|
1441
|
+
*/
|
|
1442
|
+
introspectTableIfHeld(table, limit, offset) {
|
|
1443
|
+
return this.ifHeld(() => this.introspectTableReply(table, limit, offset));
|
|
1444
|
+
}
|
|
1445
|
+
introspectQueryIfHeld(sql) {
|
|
1446
|
+
return this.ifHeld(() => this.introspectQueryReply(sql));
|
|
1447
|
+
}
|
|
1448
|
+
undrainedEventsReadIfHeld(limit) {
|
|
1449
|
+
return this.ifHeld(() => this.undrainedEventsRead(limit));
|
|
1450
|
+
}
|
|
1451
|
+
redrainCountIfHeld(drainedBefore) {
|
|
1452
|
+
return this.ifHeld(() => this.redrainCount(drainedBefore));
|
|
1453
|
+
}
|
|
1387
1454
|
/**
|
|
1388
1455
|
* #2541: whether this object is a scope the deployment holds, asked by a read that names only a
|
|
1389
1456
|
* scope id. False for a DO nothing provisioned or loaded here (never provisioned), for a reaped
|
|
@@ -3801,16 +3868,43 @@ options = {}) {
|
|
|
3801
3868
|
* set defined on this scope, or a role definition fanned out to it, is judged against.
|
|
3802
3869
|
*/
|
|
3803
3870
|
async roleStateHere(tenantId) {
|
|
3871
|
+
return this.roleStateSync(tenantId);
|
|
3872
|
+
}
|
|
3873
|
+
/** `roleStateHere`, read synchronously, so a unit that writes roles or seats can judge on what it holds (#2531). */
|
|
3874
|
+
roleStateSync(tenantId) {
|
|
3804
3875
|
// Spine tables only (built in the constructor), so no module migration runs ahead of a provision's.
|
|
3805
3876
|
const now = new Date().toISOString();
|
|
3806
|
-
const
|
|
3807
|
-
? this.sql.exec('SELECT role_key, permissions, holder FROM _substrat_roles WHERE tenant_id = ?
|
|
3877
|
+
const rows = this.hasTable('_substrat_roles')
|
|
3878
|
+
? this.sql.exec('SELECT role_key, permissions, holder, revoked_at FROM _substrat_roles WHERE tenant_id = ?', tenantId).toArray()
|
|
3808
3879
|
: [];
|
|
3880
|
+
const shapeOf = (r) => ({ key: r.role_key, permissions: JSON.parse(r.permissions), ...(r.holder ? { holder: r.holder } : {}) });
|
|
3881
|
+
const roles = rows.filter((r) => r.revoked_at === null).map(shapeOf);
|
|
3882
|
+
// #2531: the tombstones `applyProjection` leaves for the roles a projection dropped.
|
|
3883
|
+
const dropped = rows.filter((r) => r.revoked_at !== null).map(shapeOf);
|
|
3809
3884
|
// Scope-level seats only. The tenant-level ones are the directory's (the ControlPlaneDO reads
|
|
3810
3885
|
// them in its own unit), and this scope's projected copy can be stale: a fan-out skips an
|
|
3811
3886
|
// archived scope, and a CP-less projection carries none.
|
|
3812
3887
|
const seated = new Set(this.sql.exec(SCOPE_SEATED_ROLES_SQL, now).toArray().map((r) => r.role_key));
|
|
3813
|
-
return { roles, seated: [...seated] };
|
|
3888
|
+
return { roles, seated: [...seated], dropped };
|
|
3889
|
+
}
|
|
3890
|
+
/**
|
|
3891
|
+
* #2531: the in-unit judgement. Throws when `roles`, the set the caller's unit is writing for
|
|
3892
|
+
* `tenantId`, is refused by the reserved-key rule (`reservedRoleSetRefusal`, one call) against the
|
|
3893
|
+
* roles, tombstones and seats this scope holds at that moment, with the caller's reservations
|
|
3894
|
+
* joined with this class's. Synchronous, so the unit it runs in cannot interleave with a write
|
|
3895
|
+
* between this read and its own writes; a refusal rolls the whole unit back.
|
|
3896
|
+
*
|
|
3897
|
+
* The answer is a fixed sentence naming the role key only, whoever reads it. The rule's own
|
|
3898
|
+
* wording is the pre-check's, which a caller still asks first for the common case.
|
|
3899
|
+
*/
|
|
3900
|
+
refuseRoleSetInUnit(tenantId, roles, judge) {
|
|
3901
|
+
const state = this.roleStateSync(tenantId);
|
|
3902
|
+
const refused = reservedRoleSetRefusedRole(roles, { reserved: new Set([...judge.reserved, ...this.reservedKeys]), declared: new Set([...judge.declared, ...this.declaredKeys]) }, { roles: state.roles, seated: new Set(state.seated), ...(state.dropped ? { dropped: state.dropped } : {}) });
|
|
3903
|
+
if (refused === null)
|
|
3904
|
+
return;
|
|
3905
|
+
const error = substratError('forbidden', inUnitRoleRefusal(refused.key));
|
|
3906
|
+
roleRefusals.add(error);
|
|
3907
|
+
throw error;
|
|
3814
3908
|
}
|
|
3815
3909
|
/** #2447: what this class's modules reserve and declare — a coordinator judging a role definition joins its own. */
|
|
3816
3910
|
async reservationHere() {
|
|
@@ -5092,10 +5186,16 @@ options = {}) {
|
|
|
5092
5186
|
const names = this.sql
|
|
5093
5187
|
.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB '_cf_*' ORDER BY name`)
|
|
5094
5188
|
.toArray().map((r) => r.name);
|
|
5189
|
+
const count = (sql) => Number(this.sql.exec(sql).toArray()[0].c);
|
|
5095
5190
|
return names.map((name) => ({
|
|
5096
5191
|
name,
|
|
5097
|
-
rowCount:
|
|
5192
|
+
rowCount: count(`SELECT COUNT(*) AS c FROM "${name}"`),
|
|
5098
5193
|
system: isSystemTable(name),
|
|
5194
|
+
// #2531: rows whose `revoked_at` is null, for any table with that column (`ScopeTable.liveRowCount`).
|
|
5195
|
+
// Scope health reads it for `_substrat_roles`, where a dropped role is kept revoked.
|
|
5196
|
+
...(doBuiltColumnsOf(this.sql, name)?.includes('revoked_at')
|
|
5197
|
+
? { liveRowCount: count(`SELECT COUNT(*) AS c FROM "${name}" WHERE revoked_at IS NULL`) }
|
|
5198
|
+
: {}),
|
|
5099
5199
|
}));
|
|
5100
5200
|
}
|
|
5101
5201
|
/** A bounded page of one table. Unknown table names throw — never queried blind. Kept for a
|
|
@@ -5509,7 +5609,7 @@ options = {}) {
|
|
|
5509
5609
|
* in memory at once. `stageId` names the staged load the rows come from: checked inside the
|
|
5510
5610
|
* load's transaction, and its tables dropped in it once the rows are in.
|
|
5511
5611
|
*/
|
|
5512
|
-
async loadTables(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, provisionedFor, fence, } = {}, stageId) {
|
|
5612
|
+
async loadTables(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, provisionedFor, fence, roleRepair, onRolesProjected, } = {}, stageId) {
|
|
5513
5613
|
// The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
|
|
5514
5614
|
//
|
|
5515
5615
|
// Two distinct FK hazards, and both need the deferral — the second is why it cannot
|
|
@@ -5546,6 +5646,10 @@ options = {}) {
|
|
|
5546
5646
|
if (exact && !sourceScopeId) {
|
|
5547
5647
|
throw substratError('validation_failed', 'restore refused: `exact` needs the scope the dump came from');
|
|
5548
5648
|
}
|
|
5649
|
+
// #2531: a reaped store takes no role repair, judged or not, as `projectInUnit` drops a
|
|
5650
|
+
// projection there: the load comes back with no roles, no receipt and no enforcement flip,
|
|
5651
|
+
// as it did before the repair rode the load. Read before the unit, as `projectInUnit` reads it.
|
|
5652
|
+
const repair = roleRepair && !(await this.isReaped()) ? roleRepair : undefined;
|
|
5549
5653
|
const switched = await this.revision.transaction(async () => {
|
|
5550
5654
|
// #1722: first, before any read: a store a reap has since emptied must refuse the late load
|
|
5551
5655
|
// too, and the check is against this store's clock, inside the load's own transaction.
|
|
@@ -5636,7 +5740,8 @@ options = {}) {
|
|
|
5636
5740
|
// `_substrat_tenant_tuples` in its DIRECTORY database, so its per-scope dump omits
|
|
5637
5741
|
// them, and without them the very next permission check would raise a bare `no such
|
|
5638
5742
|
// table: _substrat_roles`. Roles land empty here and are re-projected by the
|
|
5639
|
-
// restore's repair leg (host.projectRolesLocal
|
|
5743
|
+
// restore's repair leg (`roleRepair` below, or host.projectRolesLocal after the load,
|
|
5744
|
+
// #2531) — the spine's job is only to exist so
|
|
5640
5745
|
// the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
|
|
5641
5746
|
// leaves to it.
|
|
5642
5747
|
for (const stmt of executableSqlStatements(KERNEL_DDL))
|
|
@@ -5717,9 +5822,28 @@ options = {}) {
|
|
|
5717
5822
|
// #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
|
|
5718
5823
|
// the spine and the re-point. A switch that throws rolls the whole restore back, so
|
|
5719
5824
|
// the dump's grants never commit live without the switch that should cover them.
|
|
5720
|
-
|
|
5825
|
+
const switchedOff = destScopeId && switchOff
|
|
5721
5826
|
? switchRecordedOff(this.switchSql(), { scopeId: destScopeId, ...switchOff })
|
|
5722
5827
|
: [];
|
|
5828
|
+
// #2531: the role repair, last, in this same transaction: judged on the roles, tombstones and
|
|
5829
|
+
// seats the dump has just brought (`refuseRoleSetInUnit`), then projected exactly as
|
|
5830
|
+
// `projectRolesLocal` projects it. A refusal rolls the whole load back, so the scope keeps
|
|
5831
|
+
// its rows and a staged load keeps its stage; an admitted load lands with its repair.
|
|
5832
|
+
if (repair) {
|
|
5833
|
+
try {
|
|
5834
|
+
this.projectionBody(repair.judge, repair.tenantId, repair.roles, []);
|
|
5835
|
+
}
|
|
5836
|
+
catch (e) {
|
|
5837
|
+
// The projection's own tenant refusal (its receipt check) is a tenant refusal of the
|
|
5838
|
+
// load, not the kept-copy `conflict` `checkedLoad` would otherwise read it as.
|
|
5839
|
+
if (e instanceof Error && !roleRefusals.has(e) && errorCodeOf(e) === 'conflict')
|
|
5840
|
+
tenantRefusals.add(e);
|
|
5841
|
+
throw e;
|
|
5842
|
+
}
|
|
5843
|
+
}
|
|
5844
|
+
if (repair)
|
|
5845
|
+
onRolesProjected?.();
|
|
5846
|
+
return switchedOff;
|
|
5723
5847
|
}
|
|
5724
5848
|
finally {
|
|
5725
5849
|
this.revisionSuspended = false;
|
|
@@ -5780,6 +5904,28 @@ options = {}) {
|
|
|
5780
5904
|
async importDumpChecked(tables, destScopeId, opts) {
|
|
5781
5905
|
return this.checkedLoad(() => this.importDump(tables, destScopeId, opts));
|
|
5782
5906
|
}
|
|
5907
|
+
/**
|
|
5908
|
+
* #2531: `importDumpChecked` with the role repair judged and projected in the load's own
|
|
5909
|
+
* transaction (`roleRepair`), answered `rolesProjected: true` when it landed there. A reaped
|
|
5910
|
+
* store takes no repair (`loadTables`), so its load answers without it. A method of its
|
|
5911
|
+
* own, so a host meeting a ScopeDO from before it is refused by the runtime, nothing loaded,
|
|
5912
|
+
* rather than loading with the repair silently dropped.
|
|
5913
|
+
*/
|
|
5914
|
+
async importDumpJudged(tables, destScopeId, opts, roleRepair) {
|
|
5915
|
+
return this.judgedLoad((onRolesProjected) => this.checkedLoad(() => this.importDump(tables, destScopeId, { ...opts, roleRepair, onRolesProjected })));
|
|
5916
|
+
}
|
|
5917
|
+
/** #2531: `commitStagedDumpChecked` with the role repair in the load's transaction (`importDumpJudged`). */
|
|
5918
|
+
async commitStagedDumpJudged(stageId, destScopeId, opts, roleRepair) {
|
|
5919
|
+
return this.judgedLoad((onRolesProjected) => this.commitStagedDumpChecked(stageId, destScopeId, { ...opts, roleRepair, onRolesProjected }));
|
|
5920
|
+
}
|
|
5921
|
+
/** A judged load, answered `rolesProjected: true` only when its repair was written in it. */
|
|
5922
|
+
async judgedLoad(run) {
|
|
5923
|
+
let projected = false;
|
|
5924
|
+
const out = await run(() => {
|
|
5925
|
+
projected = true;
|
|
5926
|
+
});
|
|
5927
|
+
return out.refused === false && projected ? { ...out, rolesProjected: true } : out;
|
|
5928
|
+
}
|
|
5783
5929
|
/**
|
|
5784
5930
|
* Stage one page of a dump for a later `commitStagedDump` (#2450). The rows go into this
|
|
5785
5931
|
* store's own staging tables, untyped, so every cell is kept as it arrived; nothing of the
|
|
@@ -5942,6 +6088,8 @@ options = {}) {
|
|
|
5942
6088
|
catch (e) {
|
|
5943
6089
|
if (e instanceof Error && fenceRefusals.has(e))
|
|
5944
6090
|
return { refused: 'lapsed' };
|
|
6091
|
+
if (e instanceof Error && roleRefusals.has(e))
|
|
6092
|
+
return { refused: 'roles', message: e.message };
|
|
5945
6093
|
const code = errorCodeOf(e);
|
|
5946
6094
|
if (code === 'precondition_failed')
|
|
5947
6095
|
return { refused: 'changed' };
|
|
@@ -6959,6 +7107,17 @@ options = {}) {
|
|
|
6959
7107
|
applyProjectionReply(...args) {
|
|
6960
7108
|
return replyOf(() => this.applyProjection(...args));
|
|
6961
7109
|
}
|
|
7110
|
+
/**
|
|
7111
|
+
* #2531: `applyProjectionReply` with the role set judged INSIDE the projection's unit, against
|
|
7112
|
+
* the roles, tombstones and seats this scope holds as it writes (`refuseRoleSetInUnit`). A
|
|
7113
|
+
* refusal writes nothing: no role row, tombstone, receipt, seat, switch or enforcement flip.
|
|
7114
|
+
* A method of its own, not an argument, so a host meeting a ScopeDO from before it is refused
|
|
7115
|
+
* by the runtime (no such method) before anything is written, rather than projecting unjudged.
|
|
7116
|
+
* The directory fan-out does not call it (K-55, #2531).
|
|
7117
|
+
*/
|
|
7118
|
+
applyJudgedProjectionReply(judge, ...args) {
|
|
7119
|
+
return replyOf(() => this.projectInUnit(judge, ...args));
|
|
7120
|
+
}
|
|
6962
7121
|
/**
|
|
6963
7122
|
* Replace this scope's projected view of a tenant's roles + tenant-level tuples
|
|
6964
7123
|
* with a fresh snapshot, and make it authoritative (source = 'local'). A full
|
|
@@ -7003,6 +7162,10 @@ options = {}) {
|
|
|
7003
7162
|
switchOff,
|
|
7004
7163
|
/** Declared service subjects excluded only from human lockout repair (#1896). */
|
|
7005
7164
|
serviceSubjects) {
|
|
7165
|
+
return this.projectInUnit(undefined, tenantId, roles, tuples, entitlements, scopeTuples, identities, connectionKeys, switchOff, serviceSubjects);
|
|
7166
|
+
}
|
|
7167
|
+
/** `applyProjection`'s unit, its role set judged in it when `judge` is given (#2531). */
|
|
7168
|
+
async projectInUnit(judge, ...args) {
|
|
7006
7169
|
if (await this.isReaped())
|
|
7007
7170
|
return [];
|
|
7008
7171
|
// A projection that arrives after this scope was reaped is dropped, not
|
|
@@ -7013,117 +7176,131 @@ options = {}) {
|
|
|
7013
7176
|
// The whole body is synchronous storage work, so it is ONE transaction (#1738): the queue
|
|
7014
7177
|
// serializes but does not roll back, and a role insert that throws part-way must not leave
|
|
7015
7178
|
// the receipt (or a half-replaced role set) behind for `servesTenant` to trust.
|
|
7016
|
-
return this.queue.enqueue(() => this.revision.transactionSync(() =>
|
|
7017
|
-
|
|
7018
|
-
|
|
7019
|
-
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
|
|
7043
|
-
|
|
7044
|
-
|
|
7045
|
-
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
|
|
7052
|
-
|
|
7053
|
-
|
|
7054
|
-
|
|
7055
|
-
|
|
7056
|
-
|
|
7179
|
+
return this.queue.enqueue(() => this.revision.transactionSync(() => this.projectionBody(judge, ...args)));
|
|
7180
|
+
}
|
|
7181
|
+
/**
|
|
7182
|
+
* The projection's writes, synchronous, for a unit the caller holds: `projectInUnit`'s, or a
|
|
7183
|
+
* load's (`loadTables`, #2531), so a restore's role repair lands in the load's transaction.
|
|
7184
|
+
*/
|
|
7185
|
+
projectionBody(judge, ...[tenantId, roles, tuples, entitlements, scopeTuples, identities, connectionKeys, switchOff, serviceSubjects]) {
|
|
7186
|
+
// #1738: the receipt `servesTenant` reads. First writer wins: absent or equal is written,
|
|
7187
|
+
// a receipt for ANOTHER tenant refuses the whole projection (K-3), before a single row
|
|
7188
|
+
// moves, so a misdirected projection can never re-point a scope or leave its roles behind.
|
|
7189
|
+
// A restore does not trip this: `importDump` keeps the destination's own receipt and
|
|
7190
|
+
// never the dump's (#2016), so the repair projection that follows finds its own tenant.
|
|
7191
|
+
// #2016: a scope with no receipt yet takes its first one only where its role rows agree
|
|
7192
|
+
// (`tenantVerdict`, the same reading the lifecycle back-fill uses), so a projection for
|
|
7193
|
+
// another tenant cannot pin a legacy scope to it.
|
|
7194
|
+
const { verdict, held } = this.tenantVerdict(tenantId);
|
|
7195
|
+
if (verdict === 'foreign') {
|
|
7196
|
+
throw substratError('conflict', held !== null
|
|
7197
|
+
? `applyProjection refused: this scope was provisioned for tenant ${held}, and a projection for tenant ${tenantId} would re-point it`
|
|
7198
|
+
: `applyProjection ${foreignTenant(held, tenantId)}`);
|
|
7199
|
+
}
|
|
7200
|
+
// #2531: judged before the first write of the unit, on what the scope holds now.
|
|
7201
|
+
if (judge)
|
|
7202
|
+
this.refuseRoleSetInUnit(tenantId, roles.map(roleShapeOfRow), judge);
|
|
7203
|
+
// Written before any guard below can return early, so every projection leaves it.
|
|
7204
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES (?, ?)`, PROVISIONED_FOR_KEY, tenantId);
|
|
7205
|
+
// #2531: a role the new set leaves out is TOMBSTONED, not deleted: it keeps the keys and the
|
|
7206
|
+
// holder it had, and grants nothing (every reader skips a revoked row). Kept for good, as
|
|
7207
|
+
// evidence (K-21): the reserved-key rule reads a system-held tombstone as the stored
|
|
7208
|
+
// definition it was, so a release that drops a role and a later one that adds it back
|
|
7209
|
+
// are judged as one that kept it. A row already tombstoned keeps its first `revoked_at`;
|
|
7210
|
+
// a role the set names is written live again below, which clears it.
|
|
7211
|
+
this.sql.exec(`UPDATE _substrat_roles SET revoked_at = ? WHERE tenant_id = ? AND revoked_at IS NULL`, new Date().toISOString(), tenantId);
|
|
7212
|
+
for (const r of roles) {
|
|
7213
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_roles (tenant_id, role_key, permissions, source, holder, revoked_at)
|
|
7214
|
+
VALUES (?, ?, ?, ?, ?, NULL)`, tenantId, r.role_key, r.permissions, r.source, r.holder === 'system' ? 'system' : r.holder == null ? null : 'person');
|
|
7215
|
+
}
|
|
7216
|
+
this.sql.exec(`DELETE FROM _substrat_tenant_tuples WHERE tenant_id = ?`, tenantId);
|
|
7217
|
+
for (const t of tuples) {
|
|
7218
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_tenant_tuples
|
|
7219
|
+
(tenant_id, subject, relation, object, expires_at, revoked_at)
|
|
7220
|
+
VALUES (?, ?, ?, ?, ?, ?)`, tenantId, t.subject, t.relation, t.object, t.expires_at, t.revoked_at);
|
|
7221
|
+
}
|
|
7222
|
+
if (entitlements !== undefined) {
|
|
7223
|
+
this.sql.exec(`DELETE FROM _substrat_entitlements WHERE tenant_id = ?`, tenantId);
|
|
7224
|
+
for (const e of entitlements) {
|
|
7225
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_entitlements
|
|
7226
|
+
(tenant_id, entitlement_key, expires_at, quota, plan)
|
|
7227
|
+
VALUES (?, ?, ?, ?, ?)`, tenantId, e.entitlement_key, e.expires_at, e.quota, e.plan);
|
|
7057
7228
|
}
|
|
7058
|
-
// #
|
|
7059
|
-
//
|
|
7060
|
-
//
|
|
7061
|
-
|
|
7062
|
-
|
|
7063
|
-
|
|
7064
|
-
|
|
7065
|
-
|
|
7066
|
-
|
|
7067
|
-
|
|
7229
|
+
// #304: once a scope has been projected WITH entitlements even once, its gate
|
|
7230
|
+
// switches from trust-upstream to strict fail-closed (a missing/expired key
|
|
7231
|
+
// denies). Left unset, a scope provisioned before #304 keeps trusting upstream
|
|
7232
|
+
// until a projection (fanOut / reconcile / re-provision) back-fills it — so the
|
|
7233
|
+
// enforcement flip is per-scope and never strands an un-back-filled scope.
|
|
7234
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('entitlements_enforced', '1')`);
|
|
7235
|
+
}
|
|
7236
|
+
// #406: identity links ride the same snapshot. Full replace, so an unlink is
|
|
7237
|
+
// durable against every later projection — unlike the compiled-in map this
|
|
7238
|
+
// replaces, where a version rollback silently resurrected a removed login.
|
|
7239
|
+
if (identities !== undefined) {
|
|
7240
|
+
this.sql.exec(`DELETE FROM _substrat_identity_links WHERE tenant_id = ?`, tenantId);
|
|
7241
|
+
for (const i of identities) {
|
|
7242
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_identity_links
|
|
7243
|
+
(tenant_id, provider, external_id, principal_id, scope_id)
|
|
7244
|
+
VALUES (?, ?, ?, ?, ?)`, tenantId, i.provider, i.external_id, i.principal_id, i.scope_id);
|
|
7068
7245
|
}
|
|
7069
|
-
|
|
7070
|
-
|
|
7071
|
-
|
|
7072
|
-
|
|
7073
|
-
|
|
7074
|
-
|
|
7075
|
-
|
|
7076
|
-
|
|
7077
|
-
|
|
7078
|
-
|
|
7246
|
+
}
|
|
7247
|
+
// #687: connection sealing keys ride the same snapshot. Full replace, so a
|
|
7248
|
+
// revoked connection's key stops being projected and `sealToConnection` starts
|
|
7249
|
+
// refusing — the same fail-closed direction an unlinked identity takes.
|
|
7250
|
+
if (connectionKeys !== undefined) {
|
|
7251
|
+
this.sql.exec(`DELETE FROM _substrat_connection_keys WHERE tenant_id = ?`, tenantId);
|
|
7252
|
+
for (const k of connectionKeys) {
|
|
7253
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_connection_keys
|
|
7254
|
+
(tenant_id, connection_id, provider, key_id, public_key)
|
|
7255
|
+
VALUES (?, ?, ?, ?, ?)`, tenantId, k.connection_id, k.provider, k.key_id, k.public_key);
|
|
7079
7256
|
}
|
|
7080
|
-
|
|
7081
|
-
|
|
7082
|
-
|
|
7083
|
-
|
|
7084
|
-
|
|
7085
|
-
|
|
7086
|
-
|
|
7087
|
-
|
|
7088
|
-
|
|
7089
|
-
|
|
7257
|
+
}
|
|
7258
|
+
// #332: scope-level grants (the owner's role tuple at provision) are written in the
|
|
7259
|
+
// SAME enqueued unit as the projection and the enforcement flip below. Additive upsert
|
|
7260
|
+
// — NOT a full replace — so existing scope tuples are preserved. This is what keeps a
|
|
7261
|
+
// scope from ever being left "roles projected, permission_source=local, zero tuples" by
|
|
7262
|
+
// a write that lands the projection but drops before a follow-up owner grant.
|
|
7263
|
+
//
|
|
7264
|
+
// #1659: SEATED, so a reconcile creates what is missing and leaves a revoke alone. It
|
|
7265
|
+
// used to be `INSERT OR REPLACE … revoked_at = NULL`, which undid an operator's revoke
|
|
7266
|
+
// of the owner seat or of a `system:` schedule grant on the next reconcile. And a
|
|
7267
|
+
// module switched off (#1666) gets no `system:` grant seated, new or old.
|
|
7268
|
+
for (const st of scopeTuples ?? []) {
|
|
7269
|
+
const seat = seatScopeTuple(st.subject, st.relation, st.object, st.expires_at);
|
|
7270
|
+
this.sql.exec(seat.sql, ...seat.params);
|
|
7271
|
+
}
|
|
7272
|
+
// #1659's one exception: the owner-of-record's seat comes back over a revoke when
|
|
7273
|
+
// No HUMAN holder remains — declared service subjects cannot prevent lockout repair.
|
|
7274
|
+
// That is the #332 lockout this path exists to repair. The flip guard below still
|
|
7275
|
+
// counts service authority; only the repair excludes the declared service subjects.
|
|
7276
|
+
// With any other effective holder, the revoke stands: a hand-over that seats a
|
|
7277
|
+
// successor before unseating the owner is not undone by the next promote. A holder of
|
|
7278
|
+
// a role the vertical no longer defines is NOT one — it passes no check, so it must
|
|
7279
|
+
// not stand in for the holder this repair exists to restore.
|
|
7280
|
+
if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId, serviceSubjects)) {
|
|
7090
7281
|
for (const st of scopeTuples ?? []) {
|
|
7091
|
-
|
|
7092
|
-
|
|
7093
|
-
|
|
7094
|
-
|
|
7095
|
-
// No HUMAN holder remains — declared service subjects cannot prevent lockout repair.
|
|
7096
|
-
// That is the #332 lockout this path exists to repair. The flip guard below still
|
|
7097
|
-
// counts service authority; only the repair excludes the declared service subjects.
|
|
7098
|
-
// With any other effective holder, the revoke stands: a hand-over that seats a
|
|
7099
|
-
// successor before unseating the owner is not undone by the next promote. A holder of
|
|
7100
|
-
// a role the vertical no longer defines is NOT one — it passes no check, so it must
|
|
7101
|
-
// not stand in for the holder this repair exists to restore.
|
|
7102
|
-
if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId, serviceSubjects)) {
|
|
7103
|
-
for (const st of scopeTuples ?? []) {
|
|
7104
|
-
if (!st.lockout_reseat)
|
|
7105
|
-
continue;
|
|
7106
|
-
this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at, revoked_at)
|
|
7107
|
-
VALUES (?, ?, ?, ?, NULL)`, st.subject, st.relation, st.object, st.expires_at);
|
|
7108
|
-
}
|
|
7282
|
+
if (!st.lockout_reseat)
|
|
7283
|
+
continue;
|
|
7284
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at, revoked_at)
|
|
7285
|
+
VALUES (?, ?, ?, ?, NULL)`, st.subject, st.relation, st.object, st.expires_at);
|
|
7109
7286
|
}
|
|
7110
|
-
|
|
7111
|
-
|
|
7112
|
-
|
|
7113
|
-
|
|
7114
|
-
|
|
7115
|
-
|
|
7116
|
-
|
|
7117
|
-
|
|
7118
|
-
|
|
7119
|
-
|
|
7120
|
-
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('permission_source', 'local')`);
|
|
7287
|
+
}
|
|
7288
|
+
// #1742: the directory's recorded-off modules go back off HERE, after the seat and in
|
|
7289
|
+
// its unit. A wiped scope's seat has just re-created their `system:` grants (#1659), and
|
|
7290
|
+
// a re-assert arriving later from the control plane left a window in which this
|
|
7291
|
+
// deployment's own sweeper could fire them. Nothing runs between these two lines.
|
|
7292
|
+
const switched = switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
|
|
7293
|
+
// #332: only switch on strict local enforcement when SOMEONE actually holds a role.
|
|
7294
|
+
// A projection that leaves role definitions but no effective principal→role grant would
|
|
7295
|
+
// make every check fail closed — a scope serving nothing but denials, unfixable from
|
|
7296
|
+
// inside. Leave `permission_source` as-is instead; a reconcile that restores the owner
|
|
7297
|
+
// grant re-runs this and flips safely. (A CP-less vertical uses the local reader
|
|
7298
|
+
// regardless of this flag, so the owner grant is written above in the same unit — this
|
|
7299
|
+
// guard is the belt to that suspenders, and it protects the CP-backed flip outright.)
|
|
7300
|
+
if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId))
|
|
7125
7301
|
return switched;
|
|
7126
|
-
|
|
7302
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('permission_source', 'local')`);
|
|
7303
|
+
return switched;
|
|
7127
7304
|
}
|
|
7128
7305
|
/**
|
|
7129
7306
|
* Resolve an external identity from this scope's projected links (#406) — the
|