@substrat-run/adapter-cloudflare 0.118.0 → 0.120.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 +63 -11
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +108 -18
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +153 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +492 -32
- 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/route-resolver.d.ts +63 -4
- package/dist/route-resolver.d.ts.map +1 -1
- package/dist/route-resolver.js +116 -5
- package/dist/route-resolver.js.map +1 -1
- package/dist/scope-do.d.ts +11 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +347 -15
- package/dist/scope-do.js.map +1 -1
- package/package.json +5 -4
package/dist/scope-do.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { DurableObject } from 'cloudflare:workers';
|
|
2
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, redactSubjectJobRuns, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, 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';
|
|
3
|
+
import { ulid, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, effectiveRoleGrantQuery, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, 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, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, 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
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';
|
|
7
|
+
import { actorOf, admitPeer, collectPeers, switchPeer, assertNoSecret, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, 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`).
|
|
@@ -150,6 +150,10 @@ const KERNEL_DDL = `
|
|
|
150
150
|
-- builds and the shape a self-host builds cannot part company; the column
|
|
151
151
|
-- comments, and the reason coalescing is NOT a unique index, are in there.
|
|
152
152
|
${JOB_RUN_DDL}
|
|
153
|
+
-- #1705: cross-vertical delivery. The consumer's journal of what it received (envelope
|
|
154
|
+
-- only) and its watermark per producer, plus the producer-side (type, id) outbox index the
|
|
155
|
+
-- export read seeks on. Shared with the pure adapter from @substrat-run/kernel.
|
|
156
|
+
${VERTICAL_EVENTS_DDL}
|
|
153
157
|
CREATE TABLE IF NOT EXISTS _substrat_migrations (
|
|
154
158
|
module_id TEXT NOT NULL,
|
|
155
159
|
version TEXT NOT NULL,
|
|
@@ -487,6 +491,18 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
487
491
|
/** #116: the operations that declared `idempotency: false` — refusals, not participants. */
|
|
488
492
|
operationIdempotencyOptOut = new Set();
|
|
489
493
|
modules = new Map();
|
|
494
|
+
/** #1705: what this deployment exports to other verticals and imports from them. */
|
|
495
|
+
crossVertical = new CrossVerticalRegistry();
|
|
496
|
+
/**
|
|
497
|
+
* #1706: every registered module's `peers`, as declared. The door's admission reads the
|
|
498
|
+
* union (`collectPeers`), built once on first use: registration finishes in the
|
|
499
|
+
* constructor, before any call can reach the door.
|
|
500
|
+
*/
|
|
501
|
+
peerSources = [];
|
|
502
|
+
peerUnion;
|
|
503
|
+
get peers() {
|
|
504
|
+
return (this.peerUnion ??= collectPeers(this.peerSources));
|
|
505
|
+
}
|
|
490
506
|
guards = new Map();
|
|
491
507
|
predicates = new Map();
|
|
492
508
|
withdrawn = new Map();
|
|
@@ -558,6 +574,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
558
574
|
// -- module registration (port of SqliteScopeHost.registerModule) ---------
|
|
559
575
|
registerModule(registration) {
|
|
560
576
|
const manifest = registration.manifest;
|
|
577
|
+
if (manifest.peers?.length)
|
|
578
|
+
this.peerSources.push({ peers: manifest.peers });
|
|
561
579
|
// #827: the FTS indexes `searchables` declares, appended after the module's
|
|
562
580
|
// own migrations so the content table exists when the trigger references it.
|
|
563
581
|
// Same derivation as the pure adapter — both call the kernel, neither owns
|
|
@@ -579,6 +597,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
579
597
|
}
|
|
580
598
|
this.listPlans.set(plan.entityType, plan);
|
|
581
599
|
}
|
|
600
|
+
// #1705: the same registry, the same refusals, as the coordinator and the pure host.
|
|
601
|
+
this.crossVertical.register(manifest, registration.imports);
|
|
582
602
|
this.modules.set(manifest.id, {
|
|
583
603
|
id: manifest.id,
|
|
584
604
|
migrations: [
|
|
@@ -811,6 +831,167 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
811
831
|
* `drainEvents` admin receipt only when this is nonzero, so a retried pass records
|
|
812
832
|
* no egress it did not actually perform.
|
|
813
833
|
*/
|
|
834
|
+
/**
|
|
835
|
+
* #1705: the producer's release to `input.consumer`, decided by THIS deployment's own
|
|
836
|
+
* declarations. The pure adapter's `readExports`, row for row. The plan and the decision
|
|
837
|
+
* are the kernel's, so the two hosts cannot release different rows from the same outbox.
|
|
838
|
+
*
|
|
839
|
+
* On the queue, so no operation is mid-transaction while the outbox is read. An event
|
|
840
|
+
* must not reach another vertical before the transaction that wrote it has committed.
|
|
841
|
+
*/
|
|
842
|
+
async exportedEventsRead(input, tenantId, scopeId) {
|
|
843
|
+
await this.ensureMigrations();
|
|
844
|
+
const plan = exportReadPlan(this.crossVertical.exports(), input.wants);
|
|
845
|
+
const quiet = {
|
|
846
|
+
events: [],
|
|
847
|
+
withheld: [],
|
|
848
|
+
unexported: plan.unexported,
|
|
849
|
+
paused: null,
|
|
850
|
+
next: input.after,
|
|
851
|
+
more: false,
|
|
852
|
+
};
|
|
853
|
+
if (plan.types.length === 0)
|
|
854
|
+
return quiet;
|
|
855
|
+
const missing = (await this.peerCovers(tenantId, scopeId, input.consumer, plan.keys))
|
|
856
|
+
.filter((c) => !c.held)
|
|
857
|
+
.map((c) => c.permission);
|
|
858
|
+
if (missing.length > 0)
|
|
859
|
+
return { ...quiet, paused: { missing } };
|
|
860
|
+
return await this.queue.enqueue(() => {
|
|
861
|
+
const q = exportReadQuery(plan.types, input.after, input.limit);
|
|
862
|
+
const rows = this.sql.exec(q.sql, ...q.params).toArray();
|
|
863
|
+
const batch = planExportBatch({
|
|
864
|
+
rows,
|
|
865
|
+
wanted: plan.wanted,
|
|
866
|
+
after: input.after,
|
|
867
|
+
limit: input.limit,
|
|
868
|
+
hopsBefore: (row) => row.caused_by
|
|
869
|
+
? ((this.sql.exec(EXPORT_HOPS_SQL, row.caused_by).toArray()[0]?.hops) ?? 0)
|
|
870
|
+
: 0,
|
|
871
|
+
});
|
|
872
|
+
return { ...batch, unexported: plan.unexported, paused: null };
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
/** #1705: what this deployment imports, and this scope's watermark per producer. */
|
|
876
|
+
async importStateRead() {
|
|
877
|
+
await this.ensureMigrations();
|
|
878
|
+
const consumes = this.crossVertical.consumes();
|
|
879
|
+
if (consumes.length === 0)
|
|
880
|
+
return { consumes: [], cursors: [] };
|
|
881
|
+
const cursors = this.sql
|
|
882
|
+
.exec(IMPORT_CURSORS_SQL)
|
|
883
|
+
.toArray()
|
|
884
|
+
.map((r) => ({
|
|
885
|
+
source: r.source_scope_id,
|
|
886
|
+
vertical: r.source_vertical,
|
|
887
|
+
cursor: r.cursor,
|
|
888
|
+
updatedAt: r.updated_at,
|
|
889
|
+
}));
|
|
890
|
+
return { consumes, cursors };
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* #1705: apply a batch another vertical exported. The pure adapter's `deliverToPeer`
|
|
894
|
+
* body, on this scope's queue: the compare-and-set on the watermark, a dead letter per
|
|
895
|
+
* importing module for each withheld event, one storage transaction per (event, module)
|
|
896
|
+
* with its journal row, the watermark moved last, then the post-commit tail.
|
|
897
|
+
*
|
|
898
|
+
* The coordinator parses the batch before it gets here and runs the executors after.
|
|
899
|
+
* Executors live on the coordinator here, as they do after an invoke.
|
|
900
|
+
*/
|
|
901
|
+
async importApply(batch, tenantId, scopeId) {
|
|
902
|
+
await this.ensureMigrations();
|
|
903
|
+
const source = batch.source;
|
|
904
|
+
return await this.queue.enqueue(async () => {
|
|
905
|
+
const liveSince = this.liveHighWaterMark();
|
|
906
|
+
const result = {
|
|
907
|
+
delivered: 0,
|
|
908
|
+
deadLettered: 0,
|
|
909
|
+
duplicates: 0,
|
|
910
|
+
withheld: 0,
|
|
911
|
+
cursor: batch.after,
|
|
912
|
+
stale: false,
|
|
913
|
+
paused: null,
|
|
914
|
+
};
|
|
915
|
+
// #1706's door, for a delivery (`operation: null`), inside the queued body: the producer
|
|
916
|
+
// is a declared peer with its switch on, or nothing runs and the edge pauses.
|
|
917
|
+
let peerSubject;
|
|
918
|
+
try {
|
|
919
|
+
peerSubject = admitPeer(this.switchSql(), this.peers, { vertical: source.vertical, scope: source.scopeId }, null);
|
|
920
|
+
}
|
|
921
|
+
catch (err) {
|
|
922
|
+
return { ...result, paused: { reason: err instanceof Error ? err.message : String(err) } };
|
|
923
|
+
}
|
|
924
|
+
const current = (this.sql.exec(IMPORT_CURSOR_OF_SQL, source.scopeId).toArray()[0]
|
|
925
|
+
?.cursor ?? null);
|
|
926
|
+
if (current !== batch.after)
|
|
927
|
+
return { ...result, cursor: current, stale: true };
|
|
928
|
+
const at = new Date().toISOString();
|
|
929
|
+
const journaled = (id, moduleId) => this.sql
|
|
930
|
+
.exec('SELECT 1 FROM _substrat_deliveries WHERE event_id = ? AND consumer_module = ?', id, moduleId)
|
|
931
|
+
.toArray().length > 0;
|
|
932
|
+
const deadLetter = (id, moduleId, when, error) => {
|
|
933
|
+
this.sql.exec(`INSERT OR IGNORE INTO _substrat_deliveries
|
|
934
|
+
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
935
|
+
VALUES (?, ?, ?, ?, NULL)`, id, moduleId, when, error);
|
|
936
|
+
};
|
|
937
|
+
for (const w of batch.withheld) {
|
|
938
|
+
this.sql.exec(IMPORT_RECORD_SQL, w.id, source.scopeId, source.vertical, w.type, w.schemaVersion, w.occurredAt, w.entity.entityType, w.entity.entityId, 0, w.reason, at);
|
|
939
|
+
for (const moduleId of this.crossVertical.modulesImporting(source.vertical, w.type)) {
|
|
940
|
+
deadLetter(w.id, moduleId, at, withheldNote(w.reason, source.vertical));
|
|
941
|
+
}
|
|
942
|
+
result.withheld += 1;
|
|
943
|
+
}
|
|
944
|
+
for (const e of batch.events) {
|
|
945
|
+
this.sql.exec(IMPORT_RECORD_SQL, e.id, source.scopeId, source.vertical, e.type, e.schemaVersion, e.occurredAt, e.entity.entityType, e.entity.entityId, e.hops, null, at);
|
|
946
|
+
let ran = false;
|
|
947
|
+
for (const imp of this.crossVertical.handlersFor(source.vertical, e.type)) {
|
|
948
|
+
if (journaled(e.id, imp.moduleId))
|
|
949
|
+
continue;
|
|
950
|
+
ran = true;
|
|
951
|
+
if (imp.schemaVersion !== e.schemaVersion) {
|
|
952
|
+
deadLetter(e.id, imp.moduleId, at, withheldNote('version', source.vertical));
|
|
953
|
+
result.deadLettered += 1;
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
const { hops: _hops, ...fact } = e;
|
|
957
|
+
const event = structuredClone({ ...fact, source });
|
|
958
|
+
this.causedBy = e.id;
|
|
959
|
+
try {
|
|
960
|
+
await this.ctx.storage.transaction(async () => {
|
|
961
|
+
// As the producer's principal: real checks against the grants this vertical's
|
|
962
|
+
// `peers` gave it; its emits carry `{ vertical, scope }` and what they passed.
|
|
963
|
+
await imp.handler(this.importContext(tenantId, scopeId, peerSubject), event);
|
|
964
|
+
this.sql.exec(`INSERT INTO _substrat_deliveries (event_id, consumer_module, delivered_at, invocation_id)
|
|
965
|
+
VALUES (?, ?, ?, NULL)`, e.id, imp.moduleId, new Date().toISOString());
|
|
966
|
+
});
|
|
967
|
+
result.delivered += 1;
|
|
968
|
+
}
|
|
969
|
+
catch (err) {
|
|
970
|
+
// Dead-letter (v0), outside the rolled-back transaction.
|
|
971
|
+
deadLetter(e.id, imp.moduleId, new Date().toISOString(), String(err));
|
|
972
|
+
result.deadLettered += 1;
|
|
973
|
+
}
|
|
974
|
+
finally {
|
|
975
|
+
this.causedBy = null;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
if (!ran)
|
|
979
|
+
result.duplicates += 1;
|
|
980
|
+
}
|
|
981
|
+
this.sql.exec(IMPORT_CURSOR_ADVANCE_SQL, source.scopeId, source.vertical, batch.next, at);
|
|
982
|
+
result.cursor = batch.next;
|
|
983
|
+
await this.settleCommitted(tenantId, scopeId, liveSince, null);
|
|
984
|
+
return result;
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* #1705: the context an import handler runs in: the peer door's subject, as the invoke path
|
|
989
|
+
* passes it (#1706). `principal` is a placeholder that the subject never reads, and there is
|
|
990
|
+
* no operation, so the emitted rows' `operation` is NULL, as it is for any consumer.
|
|
991
|
+
*/
|
|
992
|
+
importContext(tenantId, scopeId, peerSubject) {
|
|
993
|
+
return this.operationContext(principalId.parse(ulid()), tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, undefined, undefined, [], undefined, peerSubject);
|
|
994
|
+
}
|
|
814
995
|
async markEventsDrained(eventIds, at) {
|
|
815
996
|
if (eventIds.length === 0)
|
|
816
997
|
return 0;
|
|
@@ -1122,19 +1303,28 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1122
1303
|
* `capability.honoured`: an old DO that ignored this argument would run the call as
|
|
1123
1304
|
* that placeholder, and the coordinator refuses a success without the acknowledgement.
|
|
1124
1305
|
*/
|
|
1125
|
-
capabilitySession
|
|
1306
|
+
capabilitySession,
|
|
1307
|
+
/**
|
|
1308
|
+
* #1706: set when the caller is ANOTHER VERTICAL of the same tenant — the platform's
|
|
1309
|
+
* word for who is calling, carried through the coordinator. Admitted INSIDE the queued
|
|
1310
|
+
* body on every call (`admitPeer`: declared, switched on, operation allowlisted).
|
|
1311
|
+
* `principal` is then a random placeholder that holds nothing, and the reply carries
|
|
1312
|
+
* `vertical.honoured`: an old DO that ignored this argument would run the call as that
|
|
1313
|
+
* placeholder, and the coordinator refuses a success without the acknowledgement.
|
|
1314
|
+
*/
|
|
1315
|
+
verticalCaller) {
|
|
1126
1316
|
if (!failureEnvelope) {
|
|
1127
1317
|
// Legacy path, byte-for-byte what it was: rewrapped so a non-plain error (a
|
|
1128
1318
|
// ZodError, whose `message` is a getter) still arrives with its message.
|
|
1129
1319
|
try {
|
|
1130
|
-
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession);
|
|
1320
|
+
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller);
|
|
1131
1321
|
}
|
|
1132
1322
|
catch (err) {
|
|
1133
1323
|
throw toRpcError(err);
|
|
1134
1324
|
}
|
|
1135
1325
|
}
|
|
1136
1326
|
try {
|
|
1137
|
-
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession);
|
|
1327
|
+
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller);
|
|
1138
1328
|
}
|
|
1139
1329
|
catch (err) {
|
|
1140
1330
|
// The ONE place the error keeps its structure: flattened here, rebuilt by the
|
|
@@ -1148,7 +1338,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1148
1338
|
/** K-42: the session, resolved coordinator-side. See `invoke` above. */
|
|
1149
1339
|
impersonation,
|
|
1150
1340
|
/** #1672: a capability session's hash. See `invoke` above. */
|
|
1151
|
-
capabilitySession
|
|
1341
|
+
capabilitySession,
|
|
1342
|
+
/** #1706: the calling vertical, as the platform named it. See `invoke` above. */
|
|
1343
|
+
verticalCaller) {
|
|
1152
1344
|
await this.ensureMigrations();
|
|
1153
1345
|
const handler = this.operations.get(operation);
|
|
1154
1346
|
// `not_found`, not a bare throw (#113): every vertical hand-matched this message
|
|
@@ -1241,6 +1433,14 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1241
1433
|
capabilityId = resolveCapabilitySession(doSpineSql(this.sql), capabilitySession, instant.parse(new Date().toISOString()), operation);
|
|
1242
1434
|
idempotencySubjectRef = { kind: 'capability', id: capabilityId };
|
|
1243
1435
|
}
|
|
1436
|
+
// #1706: the peer door's admission, on the capability door's terms — every call,
|
|
1437
|
+
// here, before anything opens. An undeclared peer, a switched-off one and an
|
|
1438
|
+
// operation off its allowlist are refused `forbidden`; none is a K-35 denial.
|
|
1439
|
+
let peerSubject;
|
|
1440
|
+
if (verticalCaller !== undefined) {
|
|
1441
|
+
peerSubject = admitPeer(this.switchSql(), this.peers, verticalCaller, operation);
|
|
1442
|
+
idempotencySubjectRef = peerSubject;
|
|
1443
|
+
}
|
|
1244
1444
|
// #1672: the secrets this call mints — withheld from its idempotency recording, and
|
|
1245
1445
|
// what the tripwire on its writes looks for.
|
|
1246
1446
|
const minted = [];
|
|
@@ -1286,7 +1486,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1286
1486
|
// emitted events back as one — verified across `await` in workerd.
|
|
1287
1487
|
try {
|
|
1288
1488
|
await this.ctx.storage.transaction(async () => {
|
|
1289
|
-
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation, operation, capabilityId, minted);
|
|
1489
|
+
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject);
|
|
1290
1490
|
// #116: a retry is answered from the recording, and nothing else runs
|
|
1291
1491
|
// — not the guards, not the handler, not the permission check inside
|
|
1292
1492
|
// it. Keyed by SUBJECT, so a caller only ever reaches its own
|
|
@@ -1353,6 +1553,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1353
1553
|
platformRequests: 0,
|
|
1354
1554
|
impersonation: { honoured: true },
|
|
1355
1555
|
...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
|
|
1556
|
+
...(verticalCaller !== undefined ? { vertical: { honoured: true } } : {}),
|
|
1356
1557
|
...(idempotencyKey !== undefined
|
|
1357
1558
|
? { idempotency: { keyHonoured: true, replayed } }
|
|
1358
1559
|
: {}),
|
|
@@ -1399,6 +1600,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1399
1600
|
...(impersonation ? { impersonation: { honoured: true } } : {}),
|
|
1400
1601
|
// #1672: the acknowledgement the coordinator's skew check reads — see `invoke`.
|
|
1401
1602
|
...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
|
|
1603
|
+
// #1706: the same, for the peer door.
|
|
1604
|
+
...(verticalCaller !== undefined ? { vertical: { honoured: true } } : {}),
|
|
1402
1605
|
// The acknowledgement the coordinator's skew check reads (#116), on the
|
|
1403
1606
|
// same reasoning as `ifMatchChecked` below and with a sharper failure: a
|
|
1404
1607
|
// DO too old to know about keys would EXECUTE THE OPERATION AGAIN and
|
|
@@ -1878,6 +2081,89 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1878
2081
|
return record;
|
|
1879
2082
|
});
|
|
1880
2083
|
}
|
|
2084
|
+
// -- attachments through a capability (#1686) -------------------------------
|
|
2085
|
+
// The coordinator hashed the session token; only the hash arrives. Each verb resolves it
|
|
2086
|
+
// INSIDE the queue, as `invoke` does, so nothing can revoke between the resolution and
|
|
2087
|
+
// the check — and a revoke or an expiry refuses the very next call. Reads are the
|
|
2088
|
+
// ordinary checker's, as `{ capability }`: its keys, its subtree, its minter's authority
|
|
2089
|
+
// now. Writes are refused whatever the keys say. No verb here takes a use.
|
|
2090
|
+
/**
|
|
2091
|
+
* Resolve the session and run `fn` as the capability; a refused check lands in the
|
|
2092
|
+
* denial log against `{ capability }`. A dead session throws before `fn` (no K-35 row).
|
|
2093
|
+
*
|
|
2094
|
+
* THE ORDER, for every verb: the session (here), then the target lookup, then the
|
|
2095
|
+
* check. So `attachmentGate` is only ever called inside `fn`: a dead link is told
|
|
2096
|
+
* `unauthenticated` and learns nothing about which entity types take attachments.
|
|
2097
|
+
*
|
|
2098
|
+
* The answer is an ENVELOPE, `invoke`'s: a failure travels as a value (`toWireFailure`)
|
|
2099
|
+
* because a throw across this boundary keeps only its message, and the caller has to
|
|
2100
|
+
* tell `permission_denied` from `unauthenticated` from `forbidden` — a 403, a 401 and a
|
|
2101
|
+
* 403 of a different kind to whoever is downloading.
|
|
2102
|
+
*/
|
|
2103
|
+
asCapability(sessionHash, tenantId, scopeId, operation, fn) {
|
|
2104
|
+
return this.queue.enqueue(async () => {
|
|
2105
|
+
let capability;
|
|
2106
|
+
try {
|
|
2107
|
+
capability = resolveCapabilitySession(doSpineSql(this.sql), sessionHash, instant.parse(new Date().toISOString()), operation);
|
|
2108
|
+
}
|
|
2109
|
+
catch (err) {
|
|
2110
|
+
return { failure: toWireFailure(err) };
|
|
2111
|
+
}
|
|
2112
|
+
try {
|
|
2113
|
+
const ctx = this.operationContext(
|
|
2114
|
+
// A placeholder the subject never reads, as on the invoke path: `capabilityId`
|
|
2115
|
+
// is what the context acts as.
|
|
2116
|
+
capability, tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, operation, capability);
|
|
2117
|
+
return { value: await fn(ctx, capability) };
|
|
2118
|
+
}
|
|
2119
|
+
catch (err) {
|
|
2120
|
+
if (err instanceof PermissionDenied) {
|
|
2121
|
+
this.recordDenial({ kind: 'capability', id: capability }, tenantId, operation, err, null);
|
|
2122
|
+
}
|
|
2123
|
+
return { failure: toWireFailure(err) };
|
|
2124
|
+
}
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
async capabilityAttachmentList(entity, sessionHash, tenantId, scopeId) {
|
|
2128
|
+
await this.ensureMigrations();
|
|
2129
|
+
return this.asCapability(sessionHash, tenantId, scopeId, 'attachments.list', async (ctx) => {
|
|
2130
|
+
const gate = this.attachmentGate(entity.entityType);
|
|
2131
|
+
assertAllowed(await ctx.check(gate.read, entity));
|
|
2132
|
+
const rows = this.sql
|
|
2133
|
+
.exec(`SELECT id FROM _substrat_attachments WHERE entity_type = ? AND entity_id = ?
|
|
2134
|
+
ORDER BY id DESC`, entity.entityType, entity.entityId)
|
|
2135
|
+
.toArray();
|
|
2136
|
+
return rows
|
|
2137
|
+
.map((r) => this.attachmentRow(r.id))
|
|
2138
|
+
.filter((r) => r !== null);
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
/** The record of an attachment the capability may read, or null for an unknown id. */
|
|
2142
|
+
async capabilityAttachmentOpen(attachmentId, sessionHash, tenantId, scopeId) {
|
|
2143
|
+
await this.ensureMigrations();
|
|
2144
|
+
return this.asCapability(sessionHash, tenantId, scopeId, 'attachments.open', async (ctx) => {
|
|
2145
|
+
const record = this.attachmentRow(attachmentId);
|
|
2146
|
+
if (!record)
|
|
2147
|
+
return null;
|
|
2148
|
+
const gate = this.attachmentGate(record.entity.entityType);
|
|
2149
|
+
assertAllowed(await ctx.check(gate.read, record.entity));
|
|
2150
|
+
return record;
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Refuse an upload or a remove through a capability, and record it. Throws for an
|
|
2155
|
+
* unknown attachment id too — the same answer either way — but only a known target has
|
|
2156
|
+
* a write key for the denial log to record.
|
|
2157
|
+
*/
|
|
2158
|
+
async capabilityAttachmentRefuseWrite(sessionHash, tenantId, scopeId, operation, target) {
|
|
2159
|
+
await this.ensureMigrations();
|
|
2160
|
+
return this.asCapability(sessionHash, tenantId, scopeId, operation, async (_ctx, capability) => {
|
|
2161
|
+
const entityType = 'entityType' in target
|
|
2162
|
+
? target.entityType
|
|
2163
|
+
: this.attachmentRow(target.attachmentId)?.entity.entityType;
|
|
2164
|
+
throw capabilityAttachmentWriteRefused(capability, operation, { tenantId, scopeId }, entityType === undefined ? undefined : this.attachmentGate(entityType).write);
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
1881
2167
|
/** The kernel's schedule-switch SQL (#1666), over this DO's storage. */
|
|
1882
2168
|
switchSql() {
|
|
1883
2169
|
return {
|
|
@@ -1929,6 +2215,44 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1929
2215
|
async switchSystemSchedules(moduleId, scopeId, to, at) {
|
|
1930
2216
|
return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at })));
|
|
1931
2217
|
}
|
|
2218
|
+
/**
|
|
2219
|
+
* Move one PEER's kill switch on this scope (#1706) — the kernel's `switchPeer`, which is
|
|
2220
|
+
* the schedule switch's statement with a `vertical:` subject. Queued, one transaction.
|
|
2221
|
+
*/
|
|
2222
|
+
async switchPeer(vertical, scopeId, to, at) {
|
|
2223
|
+
return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchPeer(this.switchSql(), { vertical, scopeId, to, at })));
|
|
2224
|
+
}
|
|
2225
|
+
/**
|
|
2226
|
+
* Does peer `vertical` hold each key at this scope's node now (#1706) — the checker's own
|
|
2227
|
+
* `covers`, queued so no invoke's open transaction is read half-done. The subject's `scope`
|
|
2228
|
+
* is the target scope only to satisfy the type: `covers` records nothing, and the tuple ref
|
|
2229
|
+
* it walks is the slug alone.
|
|
2230
|
+
*/
|
|
2231
|
+
async peerCovers(tenantId, scopeId, vertical, permissions) {
|
|
2232
|
+
await this.ensureMigrations();
|
|
2233
|
+
return this.queue.enqueue(async () => {
|
|
2234
|
+
const coverage = await this.checker.covers({ kind: 'vertical', id: vertical, scope: scopeId }, permissions, { tenantId, scopeId });
|
|
2235
|
+
const missing = new Set(coverage.covered ? [] : coverage.missing);
|
|
2236
|
+
return permissions.map((permission) => ({ permission, held: !missing.has(permission) }));
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2239
|
+
/**
|
|
2240
|
+
* Every module this scope holds or has held system authority for, and where each
|
|
2241
|
+
* stands (#1674) — the kernel's `systemGrantsStatus`, over this DO's own storage. A
|
|
2242
|
+
* plain read like `systemScheduleState` above, not queued: nothing here decides a
|
|
2243
|
+
* write.
|
|
2244
|
+
*/
|
|
2245
|
+
async systemGrantsStatus() {
|
|
2246
|
+
return systemGrantsStatus(this.switchSql(), new Date().toISOString());
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* Where every peer this scope holds or has held grants for stands (#1706) — the
|
|
2250
|
+
* kernel's `peerGrantsStatus`, over this DO's own storage, and the same plain
|
|
2251
|
+
* unqueued read as the line above: nothing here decides a write.
|
|
2252
|
+
*/
|
|
2253
|
+
async peerGrantsStatus() {
|
|
2254
|
+
return peerGrantsStatus(this.switchSql(), new Date().toISOString());
|
|
2255
|
+
}
|
|
1932
2256
|
/**
|
|
1933
2257
|
* The exchange (#1672) — a capability's secret traded for a session, or for the
|
|
1934
2258
|
* principal a `become` capability yields. Runs the kernel's `exchangeCapability`, the
|
|
@@ -3170,7 +3494,13 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3170
3494
|
* the instant its row write used, so the `capability.exercised` event's `occurredAt`
|
|
3171
3495
|
* and the row's `last_used_at` are the same value — a second read could disagree.
|
|
3172
3496
|
*/
|
|
3173
|
-
instantOverride
|
|
3497
|
+
instantOverride,
|
|
3498
|
+
/**
|
|
3499
|
+
* #1706: set when the caller is a PEER vertical — the subject `admitPeer` returned inside
|
|
3500
|
+
* the queued body. Mutually exclusive with the other non-person callers; `principal` is
|
|
3501
|
+
* then a placeholder that the subject below never reads.
|
|
3502
|
+
*/
|
|
3503
|
+
peerSubject) {
|
|
3174
3504
|
const checker = this.checker;
|
|
3175
3505
|
const relations = this.relations;
|
|
3176
3506
|
const searchPlans = this.searchPlans;
|
|
@@ -3188,13 +3518,15 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
3188
3518
|
// The permission subject and the derived event actor for a NON-override caller
|
|
3189
3519
|
// (#383/#97): a scheduled module, a connection, or a person. `systemActor` (the
|
|
3190
3520
|
// override, used only by consumer dispatch) stays a separate bypass path below.
|
|
3191
|
-
const subject =
|
|
3192
|
-
?
|
|
3193
|
-
:
|
|
3194
|
-
? { kind: '
|
|
3195
|
-
:
|
|
3196
|
-
? { kind: '
|
|
3197
|
-
:
|
|
3521
|
+
const subject = peerSubject
|
|
3522
|
+
? peerSubject
|
|
3523
|
+
: capabilityId
|
|
3524
|
+
? { kind: 'capability', id: capabilityId }
|
|
3525
|
+
: systemModuleId
|
|
3526
|
+
? { kind: 'system', id: systemModuleId }
|
|
3527
|
+
: connectionId
|
|
3528
|
+
? { kind: 'connection', id: connectionId }
|
|
3529
|
+
: { kind: 'principal', id: principal };
|
|
3198
3530
|
const derivedActor = actorOf(subject);
|
|
3199
3531
|
// #304: entitlement reads pick the same local-vs-RPC reader the permission checker
|
|
3200
3532
|
// uses (projected scope → local table; console-managed → CP over RPC), resolved per
|