@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/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 = 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 };
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