@substrat-run/adapter-cloudflare 0.116.0 → 0.117.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 +7 -1
- package/dist/checker.js.map +1 -1
- package/dist/control-plane-do.d.ts +31 -0
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +54 -11
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +94 -16
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +302 -48
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/scope-do.d.ts +0 -16
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +421 -148
- package/dist/scope-do.js.map +1 -1
- package/dist/sql.d.ts +6 -0
- package/dist/sql.d.ts.map +1 -1
- package/dist/sql.js +14 -0
- package/dist/sql.js.map +1 -1
- package/package.json +4 -4
package/dist/scope-do.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
-
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND,
|
|
3
|
-
import { ulid, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, JOB_RUN_DDL, jobRunListLimit, } from '@substrat-run/kernel';
|
|
2
|
+
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
3
|
+
import { ulid, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, seatScopeTuple, effectiveRoleGrantQuery, switchSystemSchedules, systemScheduleState, systemSwitchedOff, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, JOB_RUN_DDL, jobRunListLimit, } from '@substrat-run/kernel';
|
|
4
4
|
import { isUpgradeRequest, readSubscription, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
|
|
5
5
|
import { OperationQueue } from './serialization.js';
|
|
6
|
-
import { doScopedSql } from './sql.js';
|
|
7
|
-
import { facetEvents, readDeadLetters, readHistory, readInvocation, walkEventCause, walkEventEffects } from '@substrat-run/kernel';
|
|
6
|
+
import { doScopedSql, doSpineSql } from './sql.js';
|
|
7
|
+
import { actorOf, assertNoSecret, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, createCapabilityVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, resolveCapabilitySession, revokeCapabilityAsPlatform, domainEventOf, facetEvents, readDeadLetters, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
|
|
8
8
|
import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
|
|
9
9
|
/**
|
|
10
10
|
* The key marking a scope DO whose storage was destroyed (`destroyStorage`).
|
|
@@ -310,6 +310,10 @@ const KERNEL_DDL = `
|
|
|
310
310
|
${OUTBOX_ENTITY_INDEX}
|
|
311
311
|
-- #116: the request-dedupe table, kernel-owned so no vertical migrates for it.
|
|
312
312
|
${IDEMPOTENCY_DDL}
|
|
313
|
+
-- #1672: capabilities — authority carried by a secret (a link share), and the sessions
|
|
314
|
+
-- an exchange trades that secret for. Shared with the pure adapter from
|
|
315
|
+
-- @substrat-run/kernel so the two cannot part company; the column comments are there.
|
|
316
|
+
${CAPABILITY_DDL}
|
|
313
317
|
`;
|
|
314
318
|
/**
|
|
315
319
|
* Workers RPC carries a plain `Error`'s MESSAGE faithfully and nothing else. A custom
|
|
@@ -368,27 +372,6 @@ function attachSubject(principal, connectionId) {
|
|
|
368
372
|
function isSystemTable(name) {
|
|
369
373
|
return name.startsWith('_substrat') || name.startsWith('sqlite_');
|
|
370
374
|
}
|
|
371
|
-
/**
|
|
372
|
-
* A stored row → the `PlatformRequest` contract shape (JSON columns parsed). The coordinator
|
|
373
|
-
* maps the RPC's raw rows the same way in `host.ts`; this copy exists because `ctx.platformRequests`
|
|
374
|
-
* (#618) answers INSIDE the DO, where the row never crosses an RPC boundary at all.
|
|
375
|
-
*/
|
|
376
|
-
function rowToPlatformRequest(r) {
|
|
377
|
-
return platformRequest.parse({
|
|
378
|
-
id: r.id,
|
|
379
|
-
kind: r.kind,
|
|
380
|
-
payload: JSON.parse(r.payload),
|
|
381
|
-
requestedBy: JSON.parse(r.requested_by),
|
|
382
|
-
impersonation: r.impersonation == null ? null : JSON.parse(r.impersonation),
|
|
383
|
-
status: r.status,
|
|
384
|
-
attempts: r.attempts,
|
|
385
|
-
lastError: r.last_error,
|
|
386
|
-
failure: r.last_failure == null ? null : JSON.parse(r.last_failure),
|
|
387
|
-
result: r.result === null ? null : JSON.parse(r.result),
|
|
388
|
-
requestedAt: r.requested_at,
|
|
389
|
-
settledAt: r.settled_at,
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
375
|
/** SQLite cell → a JSON-safe value: bigints stringify, blobs (ArrayBuffer) read as null. */
|
|
393
376
|
function cellToJson(v) {
|
|
394
377
|
if (v == null)
|
|
@@ -709,9 +692,11 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
709
692
|
* and skips the write when nothing changed.
|
|
710
693
|
*
|
|
711
694
|
* `ensureMigrations` memoises its promise, so every later call on a warm DO
|
|
712
|
-
* resolves to the SAME `true` without applying anything
|
|
713
|
-
*
|
|
714
|
-
*
|
|
695
|
+
* resolves to the SAME `true` without applying anything — until something
|
|
696
|
+
* clears the memo (`retryMigrations`, `importDump`), which is a fresh pass and
|
|
697
|
+
* may apply. Reporting on each of the cached ones would bill a control-plane
|
|
698
|
+
* RPC per stub mint to store a number that has not moved — hence the
|
|
699
|
+
* once-per-instance latch.
|
|
715
700
|
*/
|
|
716
701
|
async migrate() {
|
|
717
702
|
const applied = await this.ensureMigrations();
|
|
@@ -767,6 +752,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
767
752
|
migrationFailure() {
|
|
768
753
|
return this.lastFailure ? { ...this.lastFailure, applied: this.applied.size } : null;
|
|
769
754
|
}
|
|
755
|
+
/**
|
|
756
|
+
* This scope's database size in bytes (#1524): `SqlStorage.databaseSize`, which Cloudflare
|
|
757
|
+
* bills on. Its one caller is an on-demand storage reading, never a sweep, because reaching
|
|
758
|
+
* it wakes this DO.
|
|
759
|
+
*/
|
|
760
|
+
databaseSize() {
|
|
761
|
+
return this.sql.databaseSize;
|
|
762
|
+
}
|
|
770
763
|
/**
|
|
771
764
|
* The PITR bookmarks this scope recorded before migration passes (#286),
|
|
772
765
|
* newest first — what a backout UI offers as rewind points. Rows taken after
|
|
@@ -791,26 +784,26 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
791
784
|
}));
|
|
792
785
|
}
|
|
793
786
|
/**
|
|
794
|
-
* The events not yet shipped to Tier 2 (#1334), oldest first
|
|
795
|
-
* is chronological (ULID) and stable, so a drain
|
|
787
|
+
* The events not yet shipped to Tier 2 (#1334), oldest first — and what the read
|
|
788
|
+
* stepped over (#1636). `ORDER BY id` is chronological (ULID) and stable, so a drain
|
|
789
|
+
* resumes where it stopped.
|
|
790
|
+
*
|
|
791
|
+
* The kernel's read, shared with the pure adapter: a row that will not decode is
|
|
792
|
+
* neither returned nor stamped, and the rows behind it still come back. An object
|
|
793
|
+
* rather than an array because it crosses the RPC — a property on an array would not.
|
|
794
|
+
*/
|
|
795
|
+
undrainedEventsRead(limit) {
|
|
796
|
+
return readUndrainedOutbox((offset, count) => this.sql
|
|
797
|
+
.exec(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ? OFFSET ?`, count, offset)
|
|
798
|
+
.toArray(), limit);
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* The same read as a bare array, for a coordinator deployed before
|
|
802
|
+
* `undrainedEventsRead` (#1636) — kept so that pairing still drains, and still steps
|
|
803
|
+
* over a bad row rather than stalling on it. It just cannot say that it did.
|
|
796
804
|
*/
|
|
797
805
|
undrainedEvents(limit) {
|
|
798
|
-
|
|
799
|
-
.exec(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ?`, limit)
|
|
800
|
-
.toArray();
|
|
801
|
-
return rows.map((r) => ({
|
|
802
|
-
...this.parseOutboxRow(r),
|
|
803
|
-
operation: r.operation ?? null,
|
|
804
|
-
version: r.version ?? null,
|
|
805
|
-
// #1237 — lifted like the two above, and for the same reason: the column
|
|
806
|
-
// exists on the outbox but not on the envelope `parseOutboxRow` returns,
|
|
807
|
-
// whose `domainEvent.parse` strips anything it does not declare.
|
|
808
|
-
causedBy: r.caused_by ?? null,
|
|
809
|
-
// …and the invocation, for the same reason again: a lake that kept cause and
|
|
810
|
-
// dropped the call could say what set an event off and never which request did
|
|
811
|
-
// it, which is the grouping a trace is built on.
|
|
812
|
-
invocationId: r.invocation_id ?? null,
|
|
813
|
-
}));
|
|
806
|
+
return this.undrainedEventsRead(limit).events;
|
|
814
807
|
}
|
|
815
808
|
/**
|
|
816
809
|
* Stamp `drained_at` on shipped events (#1334). Idempotent — a re-mark is a no-op,
|
|
@@ -1007,13 +1000,30 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1007
1000
|
return rows.sort((a, b) => a.subject.localeCompare(b.subject) || a.relation.localeCompare(b.relation));
|
|
1008
1001
|
});
|
|
1009
1002
|
}
|
|
1010
|
-
/**
|
|
1003
|
+
/**
|
|
1004
|
+
* Admin scope-tuple write (role assignment / grant scoped to this scope) — the
|
|
1005
|
+
* EXPLICIT grant. `INSERT OR REPLACE`, so it clears a tombstone: a re-grant grants.
|
|
1006
|
+
* Provisioning does not come through here; it seats with `seatTuple` (#1659).
|
|
1007
|
+
*/
|
|
1011
1008
|
async writeTuple(subject, relation, object, expiresAt) {
|
|
1012
1009
|
await this.queue.enqueue(() => {
|
|
1013
1010
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
|
|
1014
1011
|
VALUES (?, ?, ?, ?)`, subject, relation, object, expiresAt);
|
|
1015
1012
|
});
|
|
1016
1013
|
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Provisioning's scope-tuple write (#1659): create the row if it is missing, follow the
|
|
1016
|
+
* platform's expiry if it is live, and leave it alone if it was revoked — so a re-run
|
|
1017
|
+
* provision cannot undo an operator's revoke — and seat nothing at all for a module
|
|
1018
|
+
* whose schedule kill switch is off (#1666). `seatScopeTuple` is the statement, shared
|
|
1019
|
+
* with `applyProjection`'s `scopeTuples` and with the pure adapter.
|
|
1020
|
+
*/
|
|
1021
|
+
async seatTuple(subject, relation, object, expiresAt) {
|
|
1022
|
+
await this.queue.enqueue(() => {
|
|
1023
|
+
const seat = seatScopeTuple(subject, relation, object, expiresAt);
|
|
1024
|
+
this.sql.exec(seat.sql, ...seat.params);
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1017
1027
|
/**
|
|
1018
1028
|
* The dispatch capability's admission (#726 remedy B): this delivery may read the
|
|
1019
1029
|
* attachments of the entity its own spine row names, and no others.
|
|
@@ -1103,19 +1113,28 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1103
1113
|
* this scope; the DO uses it for the two-actor stamp and for the read-only
|
|
1104
1114
|
* bound, and nothing here can be reached without it having been minted.
|
|
1105
1115
|
*/
|
|
1106
|
-
impersonation
|
|
1116
|
+
impersonation,
|
|
1117
|
+
/**
|
|
1118
|
+
* #1672: the HASH of a capability session token — the coordinator hashed the token
|
|
1119
|
+
* and the plaintext never crosses. Resolved to its capability INSIDE the queued body,
|
|
1120
|
+
* on every call, so a revoke between two calls refuses the second. `principal` is
|
|
1121
|
+
* then a random placeholder that holds nothing, and the reply carries
|
|
1122
|
+
* `capability.honoured`: an old DO that ignored this argument would run the call as
|
|
1123
|
+
* that placeholder, and the coordinator refuses a success without the acknowledgement.
|
|
1124
|
+
*/
|
|
1125
|
+
capabilitySession) {
|
|
1107
1126
|
if (!failureEnvelope) {
|
|
1108
1127
|
// Legacy path, byte-for-byte what it was: rewrapped so a non-plain error (a
|
|
1109
1128
|
// ZodError, whose `message` is a getter) still arrives with its message.
|
|
1110
1129
|
try {
|
|
1111
|
-
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation);
|
|
1130
|
+
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession);
|
|
1112
1131
|
}
|
|
1113
1132
|
catch (err) {
|
|
1114
1133
|
throw toRpcError(err);
|
|
1115
1134
|
}
|
|
1116
1135
|
}
|
|
1117
1136
|
try {
|
|
1118
|
-
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation);
|
|
1137
|
+
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession);
|
|
1119
1138
|
}
|
|
1120
1139
|
catch (err) {
|
|
1121
1140
|
// The ONE place the error keeps its structure: flattened here, rebuilt by the
|
|
@@ -1127,7 +1146,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1127
1146
|
/** The operation path itself. Throws; `invoke` decides how that reaches the caller. */
|
|
1128
1147
|
async invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions,
|
|
1129
1148
|
/** K-42: the session, resolved coordinator-side. See `invoke` above. */
|
|
1130
|
-
impersonation
|
|
1149
|
+
impersonation,
|
|
1150
|
+
/** #1672: a capability session's hash. See `invoke` above. */
|
|
1151
|
+
capabilitySession) {
|
|
1131
1152
|
await this.ensureMigrations();
|
|
1132
1153
|
const handler = this.operations.get(operation);
|
|
1133
1154
|
// `not_found`, not a bare throw (#113): every vertical hand-matched this message
|
|
@@ -1186,7 +1207,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1186
1207
|
// The subject a key is scoped to — the same three-way read `recordDenial`
|
|
1187
1208
|
// makes below, hoisted because both need it. A key belongs to whoever sent
|
|
1188
1209
|
// it: two principals choosing `1` must not reach each other's response.
|
|
1189
|
-
|
|
1210
|
+
let idempotencySubjectRef = systemModuleId
|
|
1190
1211
|
? { kind: 'system', id: systemModuleId }
|
|
1191
1212
|
: connectionId
|
|
1192
1213
|
? { kind: 'connection', id: connectionId }
|
|
@@ -1210,6 +1231,19 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1210
1231
|
// one call. Same placement as the SQLite adapter's actor task, for this reason.
|
|
1211
1232
|
this.invocationId = invokeOptions?.invocationId ?? null;
|
|
1212
1233
|
try {
|
|
1234
|
+
// #1672: the capability session, resolved on EVERY call and here — inside the queued
|
|
1235
|
+
// body, the one region where this call holds the DO to itself — so nothing can
|
|
1236
|
+
// revoke between this read and the transaction. Refuses a stale session, a revoked
|
|
1237
|
+
// or expired capability and an operation off its allowlist before anything opens;
|
|
1238
|
+
// none of those is a K-35 denial (no key was checked).
|
|
1239
|
+
let capabilityId;
|
|
1240
|
+
if (capabilitySession !== undefined) {
|
|
1241
|
+
capabilityId = resolveCapabilitySession(doSpineSql(this.sql), capabilitySession, instant.parse(new Date().toISOString()), operation);
|
|
1242
|
+
idempotencySubjectRef = { kind: 'capability', id: capabilityId };
|
|
1243
|
+
}
|
|
1244
|
+
// #1672: the secrets this call mints — withheld from its idempotency recording, and
|
|
1245
|
+
// what the tripwire on its writes looks for.
|
|
1246
|
+
const minted = [];
|
|
1213
1247
|
/**
|
|
1214
1248
|
* #938: the outbox's high-water mark BEFORE this call wrote anything, so the
|
|
1215
1249
|
* post-commit fan-out can name exactly the events this call (and the consumers
|
|
@@ -1252,7 +1286,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1252
1286
|
// emitted events back as one — verified across `await` in workerd.
|
|
1253
1287
|
try {
|
|
1254
1288
|
await this.ctx.storage.transaction(async () => {
|
|
1255
|
-
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation, operation);
|
|
1289
|
+
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation, operation, capabilityId, minted);
|
|
1256
1290
|
// #116: a retry is answered from the recording, and nothing else runs
|
|
1257
1291
|
// — not the guards, not the handler, not the permission check inside
|
|
1258
1292
|
// it. Keyed by SUBJECT, so a caller only ever reaches its own
|
|
@@ -1293,7 +1327,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1293
1327
|
// it describes. The prune rides along, on the only path that adds a row.
|
|
1294
1328
|
if (idempotencyKey !== undefined && fingerprint !== undefined) {
|
|
1295
1329
|
const at = new Date().toISOString();
|
|
1296
|
-
const record = idempotencyRecordStatement(idempotencySubjectRef, idempotencyKey, operation, fingerprint,
|
|
1330
|
+
const record = idempotencyRecordStatement(idempotencySubjectRef, idempotencyKey, operation, fingerprint,
|
|
1331
|
+
// #1672: a replay of a mint returns the placeholder, never the secret.
|
|
1332
|
+
redactSecrets(result, minted), committedVersion, at);
|
|
1297
1333
|
this.sql.exec(record.sql, ...record.params);
|
|
1298
1334
|
const prune = idempotencyPruneStatement(at);
|
|
1299
1335
|
this.sql.exec(prune.sql, ...prune.params);
|
|
@@ -1316,6 +1352,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1316
1352
|
result,
|
|
1317
1353
|
platformRequests: 0,
|
|
1318
1354
|
impersonation: { honoured: true },
|
|
1355
|
+
...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
|
|
1319
1356
|
...(idempotencyKey !== undefined
|
|
1320
1357
|
? { idempotency: { keyHonoured: true, replayed } }
|
|
1321
1358
|
: {}),
|
|
@@ -1332,11 +1369,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1332
1369
|
// K-35: the transaction has rolled back; record a refused check now, as its own
|
|
1333
1370
|
// write (outside that transaction), so the denial survives the rollback.
|
|
1334
1371
|
if (err instanceof PermissionDenied) {
|
|
1335
|
-
this.recordDenial(
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
? { kind: 'connection', id: connectionId }
|
|
1339
|
-
: { kind: 'principal', id: principal }, tenantId, operation, err,
|
|
1372
|
+
this.recordDenial(
|
|
1373
|
+
// The subject the call acted as — the capability's, once resolved above.
|
|
1374
|
+
idempotencySubjectRef, tenantId, operation, err,
|
|
1340
1375
|
// Inside the queued body, which is the one region where this call holds
|
|
1341
1376
|
// the DO to itself — so the field is this call's own (#1237).
|
|
1342
1377
|
this.invocationId, impersonation);
|
|
@@ -1362,6 +1397,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1362
1397
|
result,
|
|
1363
1398
|
platformRequests: signals.platformRequests,
|
|
1364
1399
|
...(impersonation ? { impersonation: { honoured: true } } : {}),
|
|
1400
|
+
// #1672: the acknowledgement the coordinator's skew check reads — see `invoke`.
|
|
1401
|
+
...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
|
|
1365
1402
|
// The acknowledgement the coordinator's skew check reads (#116), on the
|
|
1366
1403
|
// same reasoning as `ifMatchChecked` below and with a sharper failure: a
|
|
1367
1404
|
// DO too old to know about keys would EXECUTE THE OPERATION AGAIN and
|
|
@@ -1841,19 +1878,106 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1841
1878
|
return record;
|
|
1842
1879
|
});
|
|
1843
1880
|
}
|
|
1881
|
+
/** The kernel's schedule-switch SQL (#1666), over this DO's storage. */
|
|
1882
|
+
switchSql() {
|
|
1883
|
+
return {
|
|
1884
|
+
all: (sql, ...params) => this.sql.exec(sql, ...params).toArray(),
|
|
1885
|
+
run: (sql, ...params) => {
|
|
1886
|
+
this.sql.exec(sql, ...params);
|
|
1887
|
+
},
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1844
1890
|
/**
|
|
1845
|
-
*
|
|
1846
|
-
*
|
|
1847
|
-
*
|
|
1891
|
+
* Where a module's schedules stand on this scope (#383, #1666) — the kernel's
|
|
1892
|
+
* `systemScheduleState`, the predicate the pure adapter runs too: `on` with a live
|
|
1893
|
+
* `system:<moduleId>` grant, `off` while the kill switch's marker is live, whatever
|
|
1894
|
+
* else is, and `ungranted` on a scope that never ran the module (a foreign vertical's,
|
|
1895
|
+
* which the sweep skips quietly).
|
|
1896
|
+
*/
|
|
1897
|
+
async systemScheduleState(moduleId) {
|
|
1898
|
+
return systemScheduleState(this.switchSql(), moduleId, new Date().toISOString());
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* The pre-#1666 read, kept for ONE reason: a coordinator a deploy behind this DO still
|
|
1902
|
+
* calls it. It answers the new question, not the old one — the old predicate counted any
|
|
1903
|
+
* live `system:` tuple, and the kill switch's OFF marker is one, so an old coordinator
|
|
1904
|
+
* asking the old question would run a switched-off scope's schedules.
|
|
1848
1905
|
*/
|
|
1849
1906
|
async hasSystemGrant(moduleId) {
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1907
|
+
return (await this.systemScheduleState(moduleId)) === 'on';
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* `grantToSystem`'s scope-level write (#1666): the explicit grant — `INSERT OR REPLACE`,
|
|
1911
|
+
* as `writeTuple` — EXCEPT while the module's schedule kill switch is off, when it writes
|
|
1912
|
+
* nothing and answers `false`. The check and the write are one queued unit, so no switch
|
|
1913
|
+
* can move between them. Restore is the lever; a grant is not.
|
|
1914
|
+
*/
|
|
1915
|
+
async writeSystemGrant(moduleId, relation, object, expiresAt) {
|
|
1916
|
+
return this.queue.enqueue(() => {
|
|
1917
|
+
if (systemSwitchedOff(this.switchSql(), moduleId))
|
|
1918
|
+
return false;
|
|
1919
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
|
|
1920
|
+
VALUES (?, ?, ?, ?)`, `system:${moduleId}`, relation, object, expiresAt);
|
|
1921
|
+
return true;
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* Move a module's schedule switch on this scope (#1666) — the kernel's
|
|
1926
|
+
* `switchSystemSchedules`, serialized on the queue with every other tuple write and run
|
|
1927
|
+
* as one `transactionSync`, so its reads and writes are one unit.
|
|
1928
|
+
*/
|
|
1929
|
+
async switchSystemSchedules(moduleId, scopeId, to, at) {
|
|
1930
|
+
return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at })));
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* The exchange (#1672) — a capability's secret traded for a session, or for the
|
|
1934
|
+
* principal a `become` capability yields. Runs the kernel's `exchangeCapability`, the
|
|
1935
|
+
* function the pure adapter runs, in this DO: one queued body and one storage
|
|
1936
|
+
* transaction, so the use it takes and the `capability.exercised` event recording it
|
|
1937
|
+
* commit together or not at all; the event's consumers then settle as an invoke's do.
|
|
1938
|
+
*
|
|
1939
|
+
* The secret itself reaches this DO — it must, to be hashed and looked up — and goes
|
|
1940
|
+
* no further: nothing here stores or returns it.
|
|
1941
|
+
*/
|
|
1942
|
+
async exchangeCapability(secret, tenantId, scopeId, mode) {
|
|
1943
|
+
await this.ensureMigrations();
|
|
1944
|
+
return await this.queue.enqueue(async () => {
|
|
1945
|
+
const liveSince = this.liveHighWaterMark();
|
|
1946
|
+
// ONE instant for the whole exchange: the row's `last_used_at`, the session's times
|
|
1947
|
+
// and the event's `occurredAt` are one fact, and two clock reads could disagree.
|
|
1948
|
+
const now = instant.parse(new Date().toISOString());
|
|
1949
|
+
let outcome = null;
|
|
1950
|
+
await this.ctx.storage.transaction(async () => {
|
|
1951
|
+
outcome = await exchangeCapability({
|
|
1952
|
+
sql: doSpineSql(this.sql),
|
|
1953
|
+
now,
|
|
1954
|
+
// Stamped `{ capability }` by the ordinary emit path, under the exchange's own
|
|
1955
|
+
// pseudo-operation name — the actor is the capability, as on every event it
|
|
1956
|
+
// goes on to cause.
|
|
1957
|
+
emit: (capability, event) => this.operationContext(principalId.parse(ulid()), tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, CAPABILITY_EXCHANGE_OPERATION, capability, [], now).emit(event),
|
|
1958
|
+
}, secret, mode);
|
|
1959
|
+
});
|
|
1960
|
+
if (outcome)
|
|
1961
|
+
await this.settleCommitted(tenantId, scopeId, liveSince, null);
|
|
1962
|
+
return outcome;
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* The platform's mint (#1672, `HostAdmin.mintCapability`) — a `become` capability,
|
|
1967
|
+
* serialized on the queue with every other spine write. One INSERT, so it needs no
|
|
1968
|
+
* transaction of its own; the hash is computed before it.
|
|
1969
|
+
*/
|
|
1970
|
+
async mintBecomeCapability(input, actor) {
|
|
1971
|
+
await this.ensureMigrations();
|
|
1972
|
+
return await this.queue.enqueue(() => mintBecomeCapability(doSpineSql(this.sql), input, actor, instant.parse(new Date().toISOString())));
|
|
1973
|
+
}
|
|
1974
|
+
/**
|
|
1975
|
+
* The platform's revoke (#1672, `HostAdmin.revokeCapability`) — of any capability in
|
|
1976
|
+
* this scope. Returns the record as it stood before, for the admin log, or `null`.
|
|
1977
|
+
*/
|
|
1978
|
+
async revokeCapabilityAsPlatform(id, actor) {
|
|
1979
|
+
await this.ensureMigrations();
|
|
1980
|
+
return await this.queue.enqueue(() => this.ctx.storage.transactionSync(() => revokeCapabilityAsPlatform(doSpineSql(this.sql), id, actor, instant.parse(new Date().toISOString()))) ?? null);
|
|
1857
1981
|
}
|
|
1858
1982
|
/**
|
|
1859
1983
|
* The last time a schedule's operation ran on this scope (#383), or null.
|
|
@@ -2173,8 +2297,15 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2173
2297
|
*
|
|
2174
2298
|
* "Not yet consumed" means never attempted, or retrying and now due (#100).
|
|
2175
2299
|
* Terminal rows — delivered or dead-lettered — are excluded by the join.
|
|
2176
|
-
|
|
2177
|
-
|
|
2300
|
+
*
|
|
2301
|
+
* Decoded per row (#1636). A row that will not decode comes back in `undecodable`,
|
|
2302
|
+
* never as an event: the coordinator journals it as a dead letter, and its handler is
|
|
2303
|
+
* never handed an event built from stand-ins. The whole list used to be one
|
|
2304
|
+
* `rows.map(decode)`, so one bad row threw the executor's pending list on every pass.
|
|
2305
|
+
* The journal write stays on the coordinator, beside every other attempt it records —
|
|
2306
|
+
* this stays a read.
|
|
2307
|
+
*/
|
|
2308
|
+
pendingExecutorDeliveries(deliveryId, eventType) {
|
|
2178
2309
|
const rows = this.sql
|
|
2179
2310
|
.exec(`SELECT o.* FROM _substrat_outbox o
|
|
2180
2311
|
LEFT JOIN _substrat_deliveries d
|
|
@@ -2184,7 +2315,26 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2184
2315
|
OR (d.next_attempt_at IS NOT NULL AND d.next_attempt_at <= ?))
|
|
2185
2316
|
ORDER BY o.id`, deliveryId, eventType, new Date().toISOString())
|
|
2186
2317
|
.toArray();
|
|
2187
|
-
|
|
2318
|
+
const events = [];
|
|
2319
|
+
const undecodable = [];
|
|
2320
|
+
for (const r of rows) {
|
|
2321
|
+
try {
|
|
2322
|
+
events.push(domainEventOf(r));
|
|
2323
|
+
}
|
|
2324
|
+
catch (err) {
|
|
2325
|
+
undecodable.push({ eventId: r.id, error: String(err) });
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
return { events, undecodable };
|
|
2329
|
+
}
|
|
2330
|
+
/**
|
|
2331
|
+
* The same read as a bare list of events, for a coordinator deployed before
|
|
2332
|
+
* `pendingExecutorDeliveries` (#1636). An undecodable row is left out rather than
|
|
2333
|
+
* thrown, so that pairing still delivers the rows behind it; the next coordinator
|
|
2334
|
+
* dead-letters it.
|
|
2335
|
+
*/
|
|
2336
|
+
pendingExecutorEvents(deliveryId, eventType) {
|
|
2337
|
+
return this.pendingExecutorDeliveries(deliveryId, eventType).events;
|
|
2188
2338
|
}
|
|
2189
2339
|
/**
|
|
2190
2340
|
* Journal one executor attempt (#100). `error` null means delivered;
|
|
@@ -2273,12 +2423,21 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2273
2423
|
* `result` is COALESCE'd so a value written on an earlier pass (e.g. a minted sibling scope id,
|
|
2274
2424
|
* for two-phase idempotency) survives a null on retry. `attempts` bumps each settle; `settled_at`
|
|
2275
2425
|
* is set only on a terminal outcome.
|
|
2426
|
+
*
|
|
2427
|
+
* **Compare-and-set on `pending` (#1600 review).** The drain reads pending rows, runs a
|
|
2428
|
+
* handler, then settles — and between the read and the settle a subject erasure can redact
|
|
2429
|
+
* the row. Settling by `id` alone let that stale pass overwrite the redaction and write a
|
|
2430
|
+
* provider's reply, which can quote the person, back into `last_error`. Nothing legitimate
|
|
2431
|
+
* is refused: `pendingPlatformRequests` returns only pending rows, so every settle targets
|
|
2432
|
+
* one that was pending when it was read. A settle that finds the row already terminal does
|
|
2433
|
+
* nothing, deliberately silently — throwing would make the drain's blanket catch retry a
|
|
2434
|
+
* row that is correctly over.
|
|
2276
2435
|
*/
|
|
2277
2436
|
settlePlatformRequest(id, status, result, lastError, lastFailure = null) {
|
|
2278
2437
|
this.sql.exec(`UPDATE _substrat_platform_requests
|
|
2279
2438
|
SET status = ?, result = COALESCE(?, result), last_error = ?, last_failure = ?,
|
|
2280
2439
|
attempts = attempts + 1, settled_at = ?
|
|
2281
|
-
WHERE id =
|
|
2440
|
+
WHERE id = ? AND status = 'pending'`, status, result, lastError, lastFailure, status === 'pending' ? null : new Date().toISOString(), id);
|
|
2282
2441
|
}
|
|
2283
2442
|
/**
|
|
2284
2443
|
* Turn one connector delivery into a `connector:<provider>` platform intent (#574
|
|
@@ -2496,6 +2655,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2496
2655
|
* `destScopeId` is the scope being written INTO. A dump carries scope-level tuples
|
|
2497
2656
|
* naming the scope it was captured from, so restoring one anywhere else needs them
|
|
2498
2657
|
* re-pointed — see `rewriteScopeTuples`.
|
|
2658
|
+
*
|
|
2659
|
+
* What the dump did NOT carry is rebuilt by the next migration pass, which is why
|
|
2660
|
+
* this ends by forgetting the memoised one (#1589) — see the tail of the method.
|
|
2499
2661
|
*/
|
|
2500
2662
|
/**
|
|
2501
2663
|
* The additive spine-column migrations. KERNEL_DDL is all IF NOT EXISTS, so a
|
|
@@ -2699,6 +2861,29 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2699
2861
|
.toArray()) {
|
|
2700
2862
|
this.applied.add(`${row.module_id}@${row.version}`);
|
|
2701
2863
|
}
|
|
2864
|
+
// …and forget that this INSTANCE ever ran a migration pass (#1589). Refreshing
|
|
2865
|
+
// the set above is not enough on its own: `ensureMigrations` memoises its
|
|
2866
|
+
// promise, so a warm DO answers "already migrated" from the cache and never
|
|
2867
|
+
// reads the set again. A restore replays only what the dump carries, so a dump
|
|
2868
|
+
// that omits a module's tables — a world that keeps part of the spine
|
|
2869
|
+
// elsewhere, a targeted repair supplying only the tables being fixed — leaves
|
|
2870
|
+
// them dropped and never rebuilt, and the next operation touching one fails
|
|
2871
|
+
// with a bare `no such table` until an eviction or `retryMigrations` resets the
|
|
2872
|
+
// latch. The pure host has no such memo (it re-reads `appliedMigrations` on
|
|
2873
|
+
// every pass), so this is the line that makes the two adapters agree; without
|
|
2874
|
+
// it the divergence is invisible to dev, CI and self-host, which is the #969
|
|
2875
|
+
// class exactly. Same two fields `retryMigrations` clears, for the same reason:
|
|
2876
|
+
// the next pass has to be a fresh one. Already-journaled versions are skipped
|
|
2877
|
+
// by the `applied` set and the in-transaction re-check, so a dump that DID
|
|
2878
|
+
// carry its tables re-applies nothing.
|
|
2879
|
+
//
|
|
2880
|
+
// `schemaVersionReported` is deliberately left set, as `retryMigrations` leaves
|
|
2881
|
+
// it. `migrate()` reports only when a pass APPLIED something, and a pass that
|
|
2882
|
+
// applies after this reset ends with every code-defined migration journaled —
|
|
2883
|
+
// the same `applied.size` the first pass already reported. A dump whose frontier
|
|
2884
|
+
// is complete applies nothing, so there is nothing to report either way.
|
|
2885
|
+
this.migrationPromise = undefined;
|
|
2886
|
+
this.lastFailure = null;
|
|
2702
2887
|
}
|
|
2703
2888
|
/**
|
|
2704
2889
|
* Re-point scope-level tuples at the scope they now live in.
|
|
@@ -2760,12 +2945,23 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2760
2945
|
* and transaction facts remain". A timeline still shows that something happened, to
|
|
2761
2946
|
* what, and when; it no longer shows who, or what was said about them.
|
|
2762
2947
|
*
|
|
2948
|
+
* **Two tables, one verb (#1600).** The outbox is not the only place the spine holds
|
|
2949
|
+
* an event's payload: this host is the CP-less one, so every connector delivery it
|
|
2950
|
+
* cannot run becomes a `connector:<provider>` intent carrying the whole event, and
|
|
2951
|
+
* nothing ever deletes those rows. Redacting one table and not the other left the
|
|
2952
|
+
* name in the live database and in every copy taken from it afterwards. One RPC
|
|
2953
|
+
* rather than two for `routeExecutorEventToPlatform`'s reason — a crash cannot land
|
|
2954
|
+
* half an erasure — and both halves are idempotent anyway, so a retry converges.
|
|
2955
|
+
*
|
|
2763
2956
|
* This is the one sanctioned write that mutates the outbox. It is kernel code, not
|
|
2764
2957
|
* module code, and an erasure request is precisely the case the append-only rule has
|
|
2765
2958
|
* to yield to — the alternative is telling a data subject that the spine's convenience
|
|
2766
2959
|
* outranks their Article 17 right.
|
|
2767
2960
|
*/
|
|
2768
2961
|
async redactSubject(subjectId) {
|
|
2962
|
+
// One instant for the whole erasure — the intent tombstones must not disagree with
|
|
2963
|
+
// each other about when a person was erased.
|
|
2964
|
+
const at = new Date().toISOString();
|
|
2769
2965
|
const doomed = this.sql
|
|
2770
2966
|
.exec(`SELECT id FROM _substrat_outbox
|
|
2771
2967
|
WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`, subjectId)
|
|
@@ -2773,7 +2969,30 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2773
2969
|
for (const id of doomed) {
|
|
2774
2970
|
this.sql.exec('UPDATE _substrat_outbox SET payload = NULL WHERE id = ?', id);
|
|
2775
2971
|
}
|
|
2776
|
-
return doomed.length;
|
|
2972
|
+
return { events: doomed.length, intents: this.redactSubjectIntents(subjectId, at) };
|
|
2973
|
+
}
|
|
2974
|
+
/**
|
|
2975
|
+
* The intent-journal half of `redactSubject` (#1600) — the SQLite adapter's twin.
|
|
2976
|
+
*
|
|
2977
|
+
* Row-by-row rather than one `UPDATE … WHERE`, because the decision is structural and
|
|
2978
|
+
* SQL cannot make it: an intent payload is opaque JSON with no `pii_class` column to
|
|
2979
|
+
* test, so the SQL narrows to rows that could possibly match and the kernel predicate
|
|
2980
|
+
* decides. Query, predicate and statement all come from the kernel, so this adapter
|
|
2981
|
+
* and the pure one cannot drift about what a redacted intent is.
|
|
2982
|
+
*/
|
|
2983
|
+
redactSubjectIntents(subjectId, at) {
|
|
2984
|
+
const q = platformRequestRedactionQuery(subjectId);
|
|
2985
|
+
const candidates = this.sql
|
|
2986
|
+
.exec(q.sql, ...q.params)
|
|
2987
|
+
.toArray();
|
|
2988
|
+
let redacted = 0;
|
|
2989
|
+
for (const row of candidates) {
|
|
2990
|
+
if (!intentPayloadCarriesSubject(row.payload, subjectId))
|
|
2991
|
+
continue;
|
|
2992
|
+
this.sql.exec(PLATFORM_REQUEST_REDACTION_SQL, ...platformRequestRedactionParams(row.id, subjectId, at));
|
|
2993
|
+
redacted += 1;
|
|
2994
|
+
}
|
|
2995
|
+
return redacted;
|
|
2777
2996
|
}
|
|
2778
2997
|
// -- event dispatch (port of dispatch) ------------------------------------
|
|
2779
2998
|
/**
|
|
@@ -2820,7 +3039,21 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2820
3039
|
ORDER BY o.id`, consumer.eventType, mod.id)
|
|
2821
3040
|
.toArray();
|
|
2822
3041
|
for (const row of rows) {
|
|
2823
|
-
|
|
3042
|
+
let event;
|
|
3043
|
+
try {
|
|
3044
|
+
event = domainEventOf(row);
|
|
3045
|
+
}
|
|
3046
|
+
catch (err) {
|
|
3047
|
+
// #1636: dead-letter an event that does not decode, exactly as a failed
|
|
3048
|
+
// handler is — the decode sat ABOVE the `try` below, so one bad row halted
|
|
3049
|
+
// every event of this type behind it, on every pass. The consumer is never
|
|
3050
|
+
// handed it: an event built from stand-ins is not one it may act on. Only
|
|
3051
|
+
// the decode is caught; the journal write is not.
|
|
3052
|
+
this.sql.exec(`INSERT INTO _substrat_deliveries
|
|
3053
|
+
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
3054
|
+
VALUES (?, ?, ?, ?, ?)`, row.id, mod.id, new Date().toISOString(), String(err), invocationId);
|
|
3055
|
+
continue;
|
|
3056
|
+
}
|
|
2824
3057
|
// #1237: anything this consumer emits was emitted BECAUSE of this event
|
|
2825
3058
|
// — the step a backwards walk used to stop dead at, since a consumer
|
|
2826
3059
|
// emit records no operation either.
|
|
@@ -2891,11 +3124,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2891
3124
|
// no permission key and is left to the module.
|
|
2892
3125
|
if (!err.permission || !err.node)
|
|
2893
3126
|
return;
|
|
2894
|
-
const actor = subject
|
|
2895
|
-
? { system: subject.id }
|
|
2896
|
-
: subject.kind === 'connection'
|
|
2897
|
-
? { connection: subject.id }
|
|
2898
|
-
: subject.id;
|
|
3127
|
+
const actor = actorOf(subject);
|
|
2899
3128
|
this.sql.exec(`INSERT INTO _substrat_denials
|
|
2900
3129
|
(id, actor, permission, tenant_id, scope_id, operation, impersonation,
|
|
2901
3130
|
invocation_id, at)
|
|
@@ -2903,29 +3132,6 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2903
3132
|
// #1525: the call this refusal belongs to, as the caller named it.
|
|
2904
3133
|
invocationId, new Date().toISOString());
|
|
2905
3134
|
}
|
|
2906
|
-
parseOutboxRow(row) {
|
|
2907
|
-
return domainEvent.parse({
|
|
2908
|
-
id: row.id,
|
|
2909
|
-
type: row.type,
|
|
2910
|
-
schemaVersion: row.schema_version,
|
|
2911
|
-
occurredAt: row.occurred_at,
|
|
2912
|
-
tenantId: row.tenant_id,
|
|
2913
|
-
scopeId: row.scope_id,
|
|
2914
|
-
actor: JSON.parse(row.actor),
|
|
2915
|
-
entity: { entityType: row.entity_type, entityId: row.entity_id },
|
|
2916
|
-
piiClass: row.pii_class,
|
|
2917
|
-
...(row.subject_id ? { subjectId: row.subject_id } : {}),
|
|
2918
|
-
...(row.authorization ? { authorization: JSON.parse(row.authorization) } : {}),
|
|
2919
|
-
// K-42: the stamp survives the read, so a consumer's event and an executor's
|
|
2920
|
-
// are the same fact the stored row is. Absent rather than null when nobody
|
|
2921
|
-
// was impersonating, because `DomainEvent.impersonation` is optional — the
|
|
2922
|
-
// shape module code never sees is also the shape it cannot branch on.
|
|
2923
|
-
...(row.impersonation ? { impersonation: JSON.parse(row.impersonation) } : {}),
|
|
2924
|
-
// #1231: absent rather than null, the same shape rule as the stamp above.
|
|
2925
|
-
...(row.operation ? { operation: row.operation } : {}),
|
|
2926
|
-
payload: row.payload === null ? undefined : JSON.parse(row.payload),
|
|
2927
|
-
});
|
|
2928
|
-
}
|
|
2929
3135
|
// -- operation context (port of operationContext) -------------------------
|
|
2930
3136
|
operationContext(principal, tenantId, scopeId, systemActor, connectionId, systemModuleId,
|
|
2931
3137
|
/** #458: per-invoke tally of `ctx.requestPlatform` calls; absent for consumer dispatch. */
|
|
@@ -2943,7 +3149,28 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2943
3149
|
* onto every event it emits. Absent for consumer dispatch: a consumer runs on
|
|
2944
3150
|
* behalf of no operation, and the emitted row's NULL says so.
|
|
2945
3151
|
*/
|
|
2946
|
-
operation
|
|
3152
|
+
operation,
|
|
3153
|
+
/**
|
|
3154
|
+
* #1672: set when the caller holds a CAPABILITY session — already resolved from its
|
|
3155
|
+
* hash inside the queued body, before the transaction. Mutually exclusive with
|
|
3156
|
+
* `connectionId` and `systemModuleId`; `principal` is then a placeholder that the
|
|
3157
|
+
* subject below never reads.
|
|
3158
|
+
*/
|
|
3159
|
+
capabilityId,
|
|
3160
|
+
/**
|
|
3161
|
+
* #1672: the secrets `ctx.capabilities.mint` hands out during this invocation. The
|
|
3162
|
+
* caller owns the array (it withholds them from the idempotency recording); this
|
|
3163
|
+
* context appends to it and holds `ctx.sql`, `ctx.emit` and `ctx.requestPlatform` to
|
|
3164
|
+
* it — the tripwire that catches a module persisting one by accident (not a boundary
|
|
3165
|
+
* against one that means to; see `assertNoSecret`).
|
|
3166
|
+
*/
|
|
3167
|
+
minted = [],
|
|
3168
|
+
/**
|
|
3169
|
+
* #1672: the instant to stamp, when the caller already read one. The exchange passes
|
|
3170
|
+
* the instant its row write used, so the `capability.exercised` event's `occurredAt`
|
|
3171
|
+
* and the row's `last_used_at` are the same value — a second read could disagree.
|
|
3172
|
+
*/
|
|
3173
|
+
instantOverride) {
|
|
2947
3174
|
const checker = this.checker;
|
|
2948
3175
|
const relations = this.relations;
|
|
2949
3176
|
const searchPlans = this.searchPlans;
|
|
@@ -2957,20 +3184,18 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2957
3184
|
* on, and it holds identically here: `ctx.now()`, every `occurredAt` and
|
|
2958
3185
|
* every `requested_at` in one operation are the same value.
|
|
2959
3186
|
*/
|
|
2960
|
-
const at = instant.parse(new Date().toISOString());
|
|
3187
|
+
const at = instantOverride ?? instant.parse(new Date().toISOString());
|
|
2961
3188
|
// The permission subject and the derived event actor for a NON-override caller
|
|
2962
3189
|
// (#383/#97): a scheduled module, a connection, or a person. `systemActor` (the
|
|
2963
3190
|
// override, used only by consumer dispatch) stays a separate bypass path below.
|
|
2964
|
-
const subject =
|
|
2965
|
-
? { kind: '
|
|
2966
|
-
:
|
|
2967
|
-
? { kind: '
|
|
2968
|
-
:
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
? { connection: subject.id }
|
|
2973
|
-
: principal;
|
|
3191
|
+
const subject = capabilityId
|
|
3192
|
+
? { kind: 'capability', id: capabilityId }
|
|
3193
|
+
: systemModuleId
|
|
3194
|
+
? { kind: 'system', id: systemModuleId }
|
|
3195
|
+
: connectionId
|
|
3196
|
+
? { kind: 'connection', id: connectionId }
|
|
3197
|
+
: { kind: 'principal', id: principal };
|
|
3198
|
+
const derivedActor = actorOf(subject);
|
|
2974
3199
|
// #304: entitlement reads pick the same local-vs-RPC reader the permission checker
|
|
2975
3200
|
// uses (projected scope → local table; console-managed → CP over RPC), resolved per
|
|
2976
3201
|
// call so a scope that flips to 'local' is picked up without rebuilding the context.
|
|
@@ -2999,7 +3224,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
2999
3224
|
// Lifted so `grant` reuses the SAME check the operation itself passes —
|
|
3000
3225
|
// a delegation check that could differ from the operation's would be a
|
|
3001
3226
|
// second opinion about what the caller holds.
|
|
3002
|
-
const runCheck = async (
|
|
3227
|
+
const runCheck = async (unparsed, entity) => {
|
|
3228
|
+
// #1642: parsed before the system actor's early return, which never reaches
|
|
3229
|
+
// the checker — a cast key would otherwise become that path's proof relation.
|
|
3230
|
+
const permission = assertPermissionKey(unparsed);
|
|
3003
3231
|
if (systemActor) {
|
|
3004
3232
|
return {
|
|
3005
3233
|
allowed: true,
|
|
@@ -3034,15 +3262,19 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3034
3262
|
}
|
|
3035
3263
|
return checker.covers(subject, role.permissions, { tenantId, scopeId });
|
|
3036
3264
|
};
|
|
3037
|
-
|
|
3265
|
+
const ctxRef = {
|
|
3038
3266
|
tenantId,
|
|
3039
3267
|
scopeId,
|
|
3040
|
-
|
|
3041
|
-
|
|
3268
|
+
// #1672: a capability's own id stands in so the type holds — it is not a person, and
|
|
3269
|
+
// the event actor says what it is instead. Every other door passes its own value.
|
|
3270
|
+
principal: capabilityId ? capabilityId : principal,
|
|
3271
|
+
sql: guardSecrets(doScopedSql(sql), minted),
|
|
3042
3272
|
now: () => at,
|
|
3043
3273
|
emit: (event) => {
|
|
3044
3274
|
assertImpersonationWrites(impersonation, 'ctx.emit');
|
|
3045
3275
|
const parsed = domainEventInput.parse(event);
|
|
3276
|
+
// #1672: the COMPLETE parsed event — entity id, type and subject as well as payload.
|
|
3277
|
+
assertNoSecret('ctx.emit', parsed, minted);
|
|
3046
3278
|
const full = domainEvent.parse({
|
|
3047
3279
|
...parsed,
|
|
3048
3280
|
// #956: from the operation's instant, not a second reading of the clock.
|
|
@@ -3079,6 +3311,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3079
3311
|
requestPlatform: (request) => {
|
|
3080
3312
|
assertImpersonationWrites(impersonation, 'ctx.requestPlatform');
|
|
3081
3313
|
const input = platformRequestInput.parse(request);
|
|
3314
|
+
// #1672: the COMPLETE parsed request — its `kind` is persisted as surely as its payload.
|
|
3315
|
+
assertNoSecret('ctx.requestPlatform', input, minted);
|
|
3082
3316
|
// #1474: a platform-authored kind (`sweep-runs`) never comes from module code —
|
|
3083
3317
|
// the sweeper enqueues it through `enqueueSweepRuns`, which does not pass here.
|
|
3084
3318
|
assertModuleEnqueueableKind(input.kind);
|
|
@@ -3103,7 +3337,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3103
3337
|
// predicate is needed or possible: the DO IS the scope.
|
|
3104
3338
|
platformRequests: (filter) => {
|
|
3105
3339
|
const q = platformRequestHistoryQuery(filter);
|
|
3106
|
-
|
|
3340
|
+
// The kernel's decoder, the one the coordinator maps the RPC's rows with (#1588):
|
|
3341
|
+
// tolerant, so one undecodable row cannot hide this scope's other intents from it.
|
|
3342
|
+
return sql.exec(q.sql, ...q.params).toArray().map(platformRequestOf);
|
|
3107
3343
|
},
|
|
3108
3344
|
// #901. Mirror of the pure adapter, and the reason the contract suite
|
|
3109
3345
|
// runs on both: the query is ordinary SQL, but it is only a seek rather
|
|
@@ -3182,6 +3418,22 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3182
3418
|
sql.exec(`DELETE FROM _substrat_tuples WHERE subject = ? AND relation = ? AND object = ?`, `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
|
|
3183
3419
|
},
|
|
3184
3420
|
atomic: createAtomic(runSub, { passed, signals }),
|
|
3421
|
+
// #1672: mint / revoke / list, written once in the kernel — the pure adapter hands
|
|
3422
|
+
// the same function the same four things. The raw spine seam (the kernel's own write
|
|
3423
|
+
// to `_substrat_capabilities`), the operation's OWN check, and `ctx.emit`. A
|
|
3424
|
+
// consumer's override actor is passed as the system actor it is, so a consumer
|
|
3425
|
+
// cannot mint: its checks allow unconditionally, which would make "the minter holds
|
|
3426
|
+
// it" vacuous.
|
|
3427
|
+
capabilities: createCapabilityVerbs({
|
|
3428
|
+
sql: doSpineSql(sql),
|
|
3429
|
+
subject: systemActor ? { kind: 'system', id: systemActor.system } : subject,
|
|
3430
|
+
now: at,
|
|
3431
|
+
check: runCheck,
|
|
3432
|
+
emit: (event) => ctxRef.emit(event),
|
|
3433
|
+
isOperation: (name) => this.operations.has(name),
|
|
3434
|
+
assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
|
|
3435
|
+
minted,
|
|
3436
|
+
}),
|
|
3185
3437
|
link: (child, parent) => {
|
|
3186
3438
|
assertImpersonationWrites(impersonation, 'ctx.link');
|
|
3187
3439
|
const allowed = relations.get(child.entityType);
|
|
@@ -3215,6 +3467,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3215
3467
|
return sealTo({ keyId: row.key_id, publicKey: row.public_key }, plaintext);
|
|
3216
3468
|
},
|
|
3217
3469
|
};
|
|
3470
|
+
return ctxRef;
|
|
3218
3471
|
}
|
|
3219
3472
|
/** True once this scope has had entitlements projected at least once (#304) — the switch
|
|
3220
3473
|
* from trust-upstream to strict fail-closed entitlement enforcement on the local path. */
|
|
@@ -3317,11 +3570,16 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3317
3570
|
* while passing a list — even `[]` — full-replaces them. This keeps pre-#304 callers
|
|
3318
3571
|
* from silently wiping a scope's entitlements. */
|
|
3319
3572
|
entitlements,
|
|
3320
|
-
/** Scope-level tuples (e.g. the owner's role grant at provision)
|
|
3573
|
+
/** Scope-level tuples (e.g. the owner's role grant at provision) seated into
|
|
3321
3574
|
* `_substrat_tuples` in this SAME transaction, additively (#332). Preserve-on-undefined:
|
|
3322
3575
|
* omitting it leaves existing scope tuples untouched, so a role-only re-projection keeps
|
|
3323
3576
|
* the owner grant. Passing them here (rather than a follow-up `writeTuple`) is what makes
|
|
3324
|
-
* provision atomic — the grant and the enforcement flip land together or not at all.
|
|
3577
|
+
* provision atomic — the grant and the enforcement flip land together or not at all.
|
|
3578
|
+
*
|
|
3579
|
+
* Seated, not replaced (#1659): a missing tuple is created, a revoked one stays revoked.
|
|
3580
|
+
* `lockout_reseat` marks the one exception — the owner-of-record's seat, which is
|
|
3581
|
+
* re-seated even over a revoke when the scope would otherwise hold no effective role
|
|
3582
|
+
* grant (`hasEffectiveRoleGrant`). */
|
|
3325
3583
|
scopeTuples,
|
|
3326
3584
|
/** The tenant's identity links (#406) — projected alongside the rest so a CP-less
|
|
3327
3585
|
* vertical's auth adapter resolves logins locally. Same preserve-on-undefined
|
|
@@ -3396,18 +3654,39 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3396
3654
|
// — NOT a full replace — so existing scope tuples are preserved. This is what keeps a
|
|
3397
3655
|
// scope from ever being left "roles projected, permission_source=local, zero tuples" by
|
|
3398
3656
|
// a write that lands the projection but drops before a follow-up owner grant.
|
|
3657
|
+
//
|
|
3658
|
+
// #1659: SEATED, so a reconcile creates what is missing and leaves a revoke alone. It
|
|
3659
|
+
// used to be `INSERT OR REPLACE … revoked_at = NULL`, which undid an operator's revoke
|
|
3660
|
+
// of the owner seat or of a `system:` schedule grant on the next reconcile. And a
|
|
3661
|
+
// module switched off (#1666) gets no `system:` grant seated, new or old.
|
|
3399
3662
|
for (const st of scopeTuples ?? []) {
|
|
3400
|
-
|
|
3401
|
-
|
|
3663
|
+
const seat = seatScopeTuple(st.subject, st.relation, st.object, st.expires_at);
|
|
3664
|
+
this.sql.exec(seat.sql, ...seat.params);
|
|
3665
|
+
}
|
|
3666
|
+
// #1659's one exception: the owner-of-record's seat comes back over a revoke when
|
|
3667
|
+
// NOTHING else would let anyone act here — roles projected, no effective role grant.
|
|
3668
|
+
// That is the #332 lockout this path exists to repair, and it is decided by the same
|
|
3669
|
+
// predicate as the flip guard below, so "locked out" means one thing in this unit.
|
|
3670
|
+
// With any other effective holder, the revoke stands: a hand-over that seats a
|
|
3671
|
+
// successor before unseating the owner is not undone by the next promote. A holder of
|
|
3672
|
+
// a role the vertical no longer defines is NOT one — it passes no check, so it must
|
|
3673
|
+
// not stand in for the holder this repair exists to restore.
|
|
3674
|
+
if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId)) {
|
|
3675
|
+
for (const st of scopeTuples ?? []) {
|
|
3676
|
+
if (!st.lockout_reseat)
|
|
3677
|
+
continue;
|
|
3678
|
+
this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at, revoked_at)
|
|
3679
|
+
VALUES (?, ?, ?, ?, NULL)`, st.subject, st.relation, st.object, st.expires_at);
|
|
3680
|
+
}
|
|
3402
3681
|
}
|
|
3403
3682
|
// #332: only switch on strict local enforcement when SOMEONE actually holds a role.
|
|
3404
|
-
// A projection that leaves role definitions but no
|
|
3405
|
-
// every check fail closed — a scope serving nothing but denials, unfixable from
|
|
3406
|
-
// Leave `permission_source` as-is instead; a reconcile that restores the owner
|
|
3407
|
-
// re-runs this and flips safely. (A CP-less vertical uses the local reader
|
|
3408
|
-
// this flag, so the owner grant is written above in the same unit — this
|
|
3409
|
-
// belt to that suspenders, and it protects the CP-backed flip outright.)
|
|
3410
|
-
if (roles.length > 0 && !this.
|
|
3683
|
+
// A projection that leaves role definitions but no effective principal→role grant would
|
|
3684
|
+
// make every check fail closed — a scope serving nothing but denials, unfixable from
|
|
3685
|
+
// inside. Leave `permission_source` as-is instead; a reconcile that restores the owner
|
|
3686
|
+
// grant re-runs this and flips safely. (A CP-less vertical uses the local reader
|
|
3687
|
+
// regardless of this flag, so the owner grant is written above in the same unit — this
|
|
3688
|
+
// guard is the belt to that suspenders, and it protects the CP-backed flip outright.)
|
|
3689
|
+
if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId))
|
|
3411
3690
|
return;
|
|
3412
3691
|
this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('permission_source', 'local')`);
|
|
3413
3692
|
});
|
|
@@ -3428,23 +3707,17 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3428
3707
|
return undefined;
|
|
3429
3708
|
return { principal: row.principal_id, scopeId: row.scope_id };
|
|
3430
3709
|
}
|
|
3431
|
-
/** True if
|
|
3432
|
-
* at scope OR tenant level
|
|
3433
|
-
*
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
return true;
|
|
3443
|
-
return (this.sql
|
|
3444
|
-
.exec(`SELECT 1 FROM _substrat_tenant_tuples
|
|
3445
|
-
WHERE tenant_id = ? AND relation LIKE 'role:%' AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
|
|
3446
|
-
LIMIT 1`, tenantId, now)
|
|
3447
|
-
.toArray().length > 0);
|
|
3710
|
+
/** True if some principal holds a role this scope can actually EXPAND — a live
|
|
3711
|
+
* (non-revoked, unexpired) `role:<key>` tuple, at scope OR tenant level, whose key names a
|
|
3712
|
+
* current, non-revoked role definition for this tenant. The one predicate behind both the
|
|
3713
|
+
* #332 flip guard and #1659's owner re-seat in `applyProjection`; the query is the
|
|
3714
|
+
* kernel's `effectiveRoleGrantQuery`, where it is tested against a real SQLite. A tuple
|
|
3715
|
+
* for a role the vertical no longer defines counts for nothing, exactly as in the local
|
|
3716
|
+
* checker, which expands a role only through its definition. */
|
|
3717
|
+
hasEffectiveRoleGrant(tenantId) {
|
|
3718
|
+
const q = effectiveRoleGrantQuery(tenantId, new Date().toISOString());
|
|
3719
|
+
const row = this.sql.exec(q.sql, ...q.params).toArray()[0];
|
|
3720
|
+
return row?.effective === 1;
|
|
3448
3721
|
}
|
|
3449
3722
|
};
|
|
3450
3723
|
}
|