@substrat-run/adapter-cloudflare 0.13.0 → 0.16.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/host.js CHANGED
@@ -1,4 +1,4 @@
1
- import { accessLogEntry, adminLogEntry, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, hostnameBinding, publishVersionInput, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, connection, connectionGrant, connectionSecret, subjectRef, createConnectionInput, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, } from '@substrat-run/contracts';
1
+ import { accessLogEntry, adminLogEntry, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, connection, connectionGrant, connectionSecret, subjectRef, createConnectionInput, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, } from '@substrat-run/contracts';
2
2
  import { normalizeHostname, toRouteTarget } from './route-resolver.js';
3
3
  import { resolveScopeRecord, ulid, backoffAt, resolveRetryPolicy, unconfiguredSecretBox, } from '@substrat-run/kernel';
4
4
  /** DO row → contract shape. Never reads the secrets table — that is the split. */
@@ -235,6 +235,57 @@ export class CloudflareScopeHost {
235
235
  await this.migrateAndRecord(scopeId);
236
236
  return this.scopeStub(scopeId).executorDeadLetters();
237
237
  }
238
+ /**
239
+ * Read-only introspection of a scope's OWN database, reaching the scope DO directly
240
+ * (kernel-design §5.4's admin-query RPC). Unlike `admin.listScopeTables`, this does
241
+ * NOT consult the control-plane directory — so it works in a **CP-less vertical**, the
242
+ * deployment that actually holds the scope's data (its ScopeDO runs the modules). The
243
+ * vertical's platform-gated `/internal/tables` route calls it; authorization is that
244
+ * gate (the caller is the control plane, which did the K-3 check + audit on its side).
245
+ */
246
+ async introspectScopeTables(scopeId) {
247
+ return this.scopeStub(scopeId).introspectTables();
248
+ }
249
+ async introspectScopeTable(scopeId, input) {
250
+ return this.scopeStub(scopeId).introspectTable(input.table, input.limit, input.offset);
251
+ }
252
+ /** The SQL console's CP-less path (#219) — same trust line as the pair above. */
253
+ async introspectScopeQuery(scopeId, input) {
254
+ return this.scopeStub(scopeId).introspectQuery(input.sql);
255
+ }
256
+ /**
257
+ * Copy one scope's data into a fresh scope DO, entirely within THIS deployment —
258
+ * the data half of an orchestrated snapshot (preview-and-snapshots.md §9). Like the
259
+ * introspection pair above it consults no control plane: the vertical's platform-
260
+ * gated `/internal/snapshot` route calls it, and the directory half (provenance row,
261
+ * activation, version bind) stays on the control plane's side. Because source and
262
+ * destination sit in the same SCOPE namespace, no scope bytes ever leave the
263
+ * deployment — the §9 property the trust line rests on.
264
+ */
265
+ async snapshotScopeLocal(sourceScopeId, destScopeId) {
266
+ const tables = await this.scopeStub(sourceScopeId).exportDump();
267
+ await this.scopeStub(destScopeId).importDump(tables);
268
+ return { tables: tables.length };
269
+ }
270
+ /**
271
+ * Wipe one scope DO's storage in THIS deployment — the reap half of an orchestrated
272
+ * deleteSnapshot (§9). The fork-only refusal and the directory cleanup live on the
273
+ * control plane, which calls the vertical's `/internal/delete-scope` before deleting
274
+ * the row; this end just destroys its own bytes.
275
+ */
276
+ async deleteScopeLocal(scopeId) {
277
+ await this.scopeStub(scopeId).destroyStorage();
278
+ }
279
+ /**
280
+ * Dump one scope's tables from THIS deployment — the data half of a governed
281
+ * `scope pull` (preview-and-snapshots.md §8/§9). Unlike the snapshot verb, this one
282
+ * DOES move scope bytes across the boundary — that is its purpose, and why the
283
+ * control-plane route in front of it is the gated, audited, masked-by-default
284
+ * path (§6). The vertical's platform-gated `/internal/export` route calls it.
285
+ */
286
+ async exportScopeLocal(scopeId) {
287
+ return this.scopeStub(scopeId).exportDump();
288
+ }
238
289
  registerModule(registration) {
239
290
  const manifest = moduleManifest.parse(registration.manifest);
240
291
  if (this.moduleIds.has(manifest.id)) {
@@ -304,6 +355,64 @@ export class CloudflareScopeHost {
304
355
  await this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
305
356
  }
306
357
  }
358
+ async importScope(actor, input, dump) {
359
+ // Create the destination scope (directory row + DO + lazy migrate); the DO then
360
+ // replaces its provisioned schema with the dump wholesale (drop-then-replay), so
361
+ // the end state is the dump, at the source's frontier. Provenance is stamped from
362
+ // the dump unless the caller set it (§3: a fork always records its origin).
363
+ await this.provisionScope(actor, {
364
+ ...input,
365
+ forkedFrom: input.forkedFrom ?? dump.scopeId,
366
+ forkedAt: input.forkedAt ?? dump.capturedAt,
367
+ });
368
+ await this.scopeStub(input.scopeId).importDump(dump.tables);
369
+ await this.admin.activateScope(actor, input.tenantId, input.scopeId);
370
+ await this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
371
+ }
372
+ async snapshotScope(actor, tenantId, scopeId, opts) {
373
+ const source = await this.admin.getScopeRecord(actor, tenantId, scopeId);
374
+ if (!source)
375
+ throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
376
+ const dump = await this.admin.exportScope(actor, tenantId, scopeId);
377
+ const snapshotId = ulid();
378
+ await this.importScope(actor, {
379
+ tenantId,
380
+ scopeId: snapshotId,
381
+ kind: opts?.kind ?? 'archive',
382
+ vertical: source.vertical,
383
+ jurisdiction: source.jurisdiction,
384
+ expiresAt: opts?.expiresAt,
385
+ // forkedFrom/forkedAt are stamped from the dump by importScope.
386
+ }, dump);
387
+ // Bind the snapshot to the source's current version so it is a runnable copy at the
388
+ // same frontier (a fresh bind, so it never re-triggers the snapshot path).
389
+ if (source.verticalVersionId) {
390
+ await this.admin.bindScopeVersion(actor, tenantId, snapshotId, source.verticalVersionId);
391
+ }
392
+ return snapshotId;
393
+ }
394
+ async deleteSnapshot(actor, tenantId, scopeId) {
395
+ // The refusal that keeps this narrow: only a FORK may be hard-deleted. Everything
396
+ // else keeps the platform's tombstone-only rule.
397
+ const rec = await this.admin.getScopeRecord(actor, tenantId, scopeId);
398
+ if (!rec)
399
+ throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
400
+ if (!rec.forkedFrom) {
401
+ throw new Error(`scope ${scopeId} is not a fork (forkedFrom is null) — only snapshots may be deleted; ` +
402
+ `archive a primary scope instead`);
403
+ }
404
+ // Storage BEFORE the directory row: a crash between the two leaves a visible row
405
+ // over empty storage — re-running deleteSnapshot converges — never orphaned bytes
406
+ // with no record (the §9 hazard). Hostname rows go with the directory delete.
407
+ await this.scopeStub(scopeId).destroyStorage();
408
+ await this.cp.deleteScopeDirectory(scopeId);
409
+ await this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
410
+ forkedFrom: rec.forkedFrom,
411
+ forkedAt: rec.forkedAt,
412
+ expiresAt: rec.expiresAt,
413
+ kind: rec.kind,
414
+ });
415
+ }
307
416
  /**
308
417
  * Migrate a scope and project its resulting migration count into the directory
309
418
  * (§5.4: fleet questions never fan out). The ScopeDO reports null when nothing
@@ -422,7 +531,18 @@ export class CloudflareScopeHost {
422
531
  canonical: r.canonical === 1,
423
532
  createdAt: r.created_at,
424
533
  });
425
- const mapVertical = (r) => verticalSchema.parse({ slug: r.slug, name: r.name, source: r.source, ownerTenant: r.owner_tenant, createdAt: r.created_at });
534
+ const mapVertical = (r) => verticalSchema.parse({
535
+ slug: r.slug,
536
+ name: r.name,
537
+ source: r.source,
538
+ ownerTenant: r.owner_tenant,
539
+ ...(r.env_spec ? { envSpec: JSON.parse(r.env_spec) } : {}),
540
+ ...(r.install_spec ? JSON.parse(r.install_spec) : {}),
541
+ listed: !!r.listed,
542
+ ...(r.publish_requested_at ? { publishRequestedAt: r.publish_requested_at } : {}),
543
+ installsBlocked: !!r.installs_blocked,
544
+ createdAt: r.created_at,
545
+ });
426
546
  const mapVersion = (r) => verticalVersion.parse({
427
547
  id: r.id,
428
548
  verticalSlug: r.vertical_slug,
@@ -475,6 +595,9 @@ export class CloudflareScopeHost {
475
595
  lastAttemptAt: r.migration_last_attempt_at,
476
596
  }
477
597
  : null,
598
+ forkedFrom: r.forked_from,
599
+ forkedAt: r.forked_at,
600
+ expiresAt: r.expires_at,
478
601
  createdAt: r.created_at,
479
602
  });
480
603
  const transitionScope = async (actor, action, tenantId, scopeId, from, to) => {
@@ -636,6 +759,14 @@ export class CloudflareScopeHost {
636
759
  await this.cp.setHostnameStatus(hostname, status, note ?? null);
637
760
  await this.recordAdmin(actor, 'setHostnameStatus', { tenantId: row.tenant_id, scopeId: row.scope_id }, { status: row.status }, { status, note: note ?? null });
638
761
  },
762
+ unbindHostname: async (actor, raw) => {
763
+ const hostname = raw.toLowerCase(); // DNS is case-insensitive; the map is normalized
764
+ const row = await this.cp.readHostname(hostname);
765
+ if (!row)
766
+ return; // idempotent, and a no-op is not audited
767
+ await this.cp.deleteHostname(hostname);
768
+ await this.recordAdmin(actor, 'unbindHostname', { tenantId: row.tenant_id, scopeId: row.scope_id }, { hostname, status: row.status }, null);
769
+ },
639
770
  listHostnames: async (actor, filter) => {
640
771
  const rows = await this.cp.listHostnames({
641
772
  tenantId: filter?.tenantId,
@@ -651,20 +782,40 @@ export class CloudflareScopeHost {
651
782
  toRouteTarget(await this.cp.readHostname(normalizeHostname(raw))),
652
783
  registerVertical: async (actor, input) => {
653
784
  const parsed = registerVerticalInput.parse(input);
785
+ const envSpecJson = parsed.envSpec ? JSON.stringify(parsed.envSpec) : null;
786
+ // The four registry-driven-install fields ride as one JSON blob (marketplace-publish.md §3).
787
+ const installSpec = {};
788
+ if (parsed.entitlements)
789
+ installSpec.entitlements = parsed.entitlements;
790
+ if (parsed.ownerGrants)
791
+ installSpec.ownerGrants = parsed.ownerGrants;
792
+ if (parsed.provides)
793
+ installSpec.provides = parsed.provides;
794
+ if (parsed.requires)
795
+ installSpec.requires = parsed.requires;
796
+ const installSpecJson = Object.keys(installSpec).length ? JSON.stringify(installSpec) : null;
654
797
  const existing = await this.cp.readVertical(parsed.slug);
655
798
  if (existing) {
656
799
  // Idempotent on an identical registration; a changed source OR owner conflicts —
657
800
  // claim-on-first-push (builder-plane.md): a slug's owner is fixed at first push.
658
801
  if (existing.source === parsed.source &&
659
802
  existing.name === parsed.name &&
660
- existing.owner_tenant === parsed.ownerTenant)
803
+ existing.owner_tenant === parsed.ownerTenant) {
804
+ // The env-spec evolves with the manifest — refresh it on an otherwise-identical
805
+ // re-registration so a declared config change propagates without a conflict.
806
+ // For BUILTIN verticals `listed` is seed metadata too (derived from the catalog's
807
+ // `connected` flag), so it refreshes alongside — without this, rows registered
808
+ // before they were listable stay unlisted forever (the empty-marketplace bug).
809
+ // A pushed vertical's `listed` is the staff publish decision — never touched.
810
+ await this.cp.updateVerticalManifestMeta(parsed.slug, envSpecJson, installSpecJson, parsed.source === 'builtin' ? (parsed.listed ? 1 : 0) : null);
661
811
  return;
812
+ }
662
813
  if (existing.owner_tenant !== parsed.ownerTenant) {
663
814
  throw new Error(`vertical '${parsed.slug}' is owned by ${existing.owner_tenant ?? 'the platform'}, not ${parsed.ownerTenant ?? 'the platform'}`);
664
815
  }
665
816
  throw new Error(`vertical '${parsed.slug}' is already registered as ${existing.source}`);
666
817
  }
667
- await this.cp.insertVertical(parsed.slug, parsed.name, parsed.source, parsed.ownerTenant, new Date().toISOString());
818
+ await this.cp.insertVertical(parsed.slug, parsed.name, parsed.source, parsed.ownerTenant, envSpecJson, installSpecJson, parsed.listed ? 1 : 0, new Date().toISOString());
668
819
  await this.recordAdmin(actor, 'registerVertical', { tenantId: null }, null, parsed);
669
820
  },
670
821
  listVerticals: async (actor) => {
@@ -674,24 +825,90 @@ export class CloudflareScopeHost {
674
825
  },
675
826
  publishVersion: async (actor, input) => {
676
827
  const parsed = publishVersionInput.parse(input);
677
- if (!(await this.cp.readVertical(parsed.verticalSlug))) {
828
+ const owning = await this.cp.readVertical(parsed.verticalSlug);
829
+ if (!owning) {
678
830
  throw new Error(`unknown vertical '${parsed.verticalSlug}'`);
679
831
  }
680
- // Lands PENDING — a push is not a deploy.
681
- await this.cp.insertVersion({ ...parsed, createdAt: new Date().toISOString() });
682
- await this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, parsed);
832
+ // Lands PENDING — a push is not a deploy — except for a PRIVATE vertical
833
+ // (tenant-owned, not listed), whose blast radius is its own tenant: there the
834
+ // sandbox contract is the gate and the version self-admits, noted so the
835
+ // publish seam can tell a staff vouch from this shortcut.
836
+ const selfAdmits = owning.owner_tenant !== null && !owning.listed;
837
+ await this.cp.insertVersion({
838
+ ...parsed,
839
+ admission: selfAdmits ? 'admitted' : 'pending',
840
+ admissionNote: selfAdmits ? AUTO_ADMISSION_NOTE : null,
841
+ createdAt: new Date().toISOString(),
842
+ });
843
+ await this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, {
844
+ ...parsed,
845
+ admission: selfAdmits ? 'admitted' : 'pending',
846
+ });
683
847
  },
684
848
  listVersions: async (actor, verticalSlug) => {
685
849
  const rows = await this.cp.listVersions(verticalSlug);
686
850
  await this.recordAccess(actor, 'listVersions', {}, { verticalSlug }, rows.length);
687
851
  return rows.map(mapVersion);
688
852
  },
853
+ setVerticalListed: async (actor, slug, listed) => {
854
+ const existing = await this.cp.readVertical(slug);
855
+ if (!existing)
856
+ throw new Error(`unknown vertical '${slug}'`);
857
+ // Listing is the moment other tenants start trusting this code, so the
858
+ // version they would install must carry a real staff vouch — an auto-admitted
859
+ // prod version has never been read by anyone but its author.
860
+ if (listed) {
861
+ const prod = await this.cp.readChannel(slug, 'prod');
862
+ const prodVersion = prod ? await this.cp.readVersion(prod.version_id) : undefined;
863
+ if (prodVersion?.admission_note === AUTO_ADMISSION_NOTE) {
864
+ throw new Error(`vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
865
+ `a staff admit must vouch for it before listing`);
866
+ }
867
+ }
868
+ await this.cp.updateVerticalListed(slug, listed ? 1 : 0); // also resolves any pending request
869
+ await this.recordAdmin(actor, 'setVerticalListed', { tenantId: null }, { listed: !!existing.listed }, { listed });
870
+ },
871
+ requestPublish: async (actor, slug) => {
872
+ const existing = await this.cp.readVertical(slug);
873
+ if (!existing)
874
+ throw new Error(`unknown vertical '${slug}'`);
875
+ await this.cp.updateVerticalPublishRequest(slug, new Date().toISOString());
876
+ await this.recordAdmin(actor, 'requestPublish', { tenantId: null }, null, { slug });
877
+ },
878
+ setVerticalInstallsBlocked: async (actor, slug, blocked) => {
879
+ const existing = await this.cp.readVertical(slug);
880
+ if (!existing)
881
+ throw new Error(`unknown vertical '${slug}'`);
882
+ await this.cp.updateVerticalInstallsBlocked(slug, blocked ? 1 : 0);
883
+ await this.recordAdmin(actor, 'setVerticalInstallsBlocked', { tenantId: null }, { installsBlocked: !!existing.installs_blocked }, { installsBlocked: blocked });
884
+ },
885
+ deleteVertical: async (actor, slug) => {
886
+ const existing = await this.cp.readVertical(slug);
887
+ if (!existing)
888
+ throw new Error(`unknown vertical '${slug}'`);
889
+ // Refuse while any scope is bound: a deleted registry row would strand those
890
+ // scopes' version pins and routing. Deployed dispatch scripts are NOT reaped
891
+ // here — they become orphans for the cleanup script (#248).
892
+ const bound = await this.cp.countScopesForVertical(slug);
893
+ if (bound > 0) {
894
+ throw new Error(`vertical '${slug}' still backs ${bound} scope(s) — delete or rebind them first`);
895
+ }
896
+ await this.cp.deleteVertical(slug);
897
+ await this.recordAdmin(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.owner_tenant }, null);
898
+ },
689
899
  admitVersion: async (actor, versionId) => {
690
900
  const v = await this.cp.readVersion(versionId);
691
901
  if (!v)
692
902
  throw new Error(`unknown version ${versionId}`);
693
- if (v.admission === 'admitted')
903
+ if (v.admission === 'admitted') {
904
+ // Idempotent — except an AUTO-admitted version, which this upgrades to a
905
+ // manual vouch by clearing the note (what the publish seam requires).
906
+ if (v.admission_note !== AUTO_ADMISSION_NOTE)
907
+ return;
908
+ await this.cp.setAdmission(versionId, 'admitted', null);
909
+ await this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission, note: v.admission_note }, { admission: 'admitted', note: null });
694
910
  return;
911
+ }
695
912
  if (v.admission === 'rejected') {
696
913
  throw new Error(`version ${versionId} was rejected — publish a new one`);
697
914
  }
@@ -735,7 +952,42 @@ export class CloudflareScopeHost {
735
952
  `${incoming.migration_digest}) — acknowledge it explicitly to promote`);
736
953
  }
737
954
  }
738
- await this.cp.setChannel(verticalSlug, channel, versionId, new Date().toISOString());
955
+ const promotedAt = new Date().toISOString();
956
+ await this.cp.setChannel(verticalSlug, channel, versionId, promotedAt);
957
+ // The timeline row: what makes rollback a choice among recorded moments, and
958
+ // `at` the PITR anchor a data rollback would rewind to.
959
+ await this.cp.insertChannelHistory({
960
+ id: ulid(),
961
+ vertical_slug: verticalSlug,
962
+ channel,
963
+ version_id: versionId,
964
+ from_version_id: outgoing?.id ?? null,
965
+ actor,
966
+ at: promotedAt,
967
+ });
968
+ // For a PRIVATE vertical, prod IS what the owner's apps run: re-point the
969
+ // owning tenant's live scopes in the same act, so merge-to-main (push +
970
+ // promote) is a complete deploy and a rollback promote reaches the running
971
+ // app. D-30's lockstep concern is a SHARED vertical's many tenants, which a
972
+ // private vertical cannot have — this fires for no one else. Snapshots and
973
+ // forks (forked_from set) keep their frontier untouched, and a rebind that
974
+ // crosses a migration digest snapshots first (fork-before-promote, §4).
975
+ if (channel === 'prod') {
976
+ const owning = await this.cp.readVertical(verticalSlug);
977
+ if (owning && owning.owner_tenant !== null && !owning.listed) {
978
+ const bound = (await this.cp.listScopes({ tenantId: owning.owner_tenant, vertical: verticalSlug, status: ['active'] })).filter((s) => !s.forked_from);
979
+ for (const s of bound) {
980
+ if (s.vertical_version_id === versionId)
981
+ continue;
982
+ const prev = s.vertical_version_id ? await this.cp.readVersion(s.vertical_version_id) : undefined;
983
+ if (prev && prev.migration_digest !== incoming.migration_digest) {
984
+ await this.snapshotScope(actor, s.tenant_id, s.scope_id);
985
+ }
986
+ await this.cp.bindScopeVersion(s.scope_id, versionId, verticalSlug);
987
+ await this.recordAdmin(actor, 'bindScopeVersion', { tenantId: s.tenant_id, scopeId: s.scope_id }, prev ? { versionId: prev.id, version: prev.version } : null, { versionId, vertical: verticalSlug, version: incoming.version, via: 'promoteVersion' });
988
+ }
989
+ }
990
+ }
739
991
  await this.recordAdmin(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack });
740
992
  },
741
993
  listChannels: async (actor, verticalSlug) => {
@@ -748,7 +1000,20 @@ export class CloudflareScopeHost {
748
1000
  updatedAt: r.updated_at,
749
1001
  }));
750
1002
  },
751
- bindScopeVersion: async (actor, tenantId, scopeId, versionId) => {
1003
+ listChannelHistory: async (actor, verticalSlug, channel) => {
1004
+ const rows = await this.cp.listChannelHistory(verticalSlug, channel);
1005
+ await this.recordAccess(actor, 'listChannelHistory', {}, { verticalSlug, channel }, rows.length);
1006
+ return rows.map((r) => channelHistoryEntry.parse({
1007
+ id: r.id,
1008
+ verticalSlug: r.vertical_slug,
1009
+ channel: r.channel,
1010
+ versionId: r.version_id,
1011
+ fromVersionId: r.from_version_id,
1012
+ actor: r.actor,
1013
+ at: r.at,
1014
+ }));
1015
+ },
1016
+ bindScopeVersion: async (actor, tenantId, scopeId, versionId, opts) => {
752
1017
  const v = await this.cp.readVersion(versionId);
753
1018
  if (!v)
754
1019
  throw new Error(`unknown version ${versionId}`);
@@ -759,6 +1024,14 @@ export class CloudflareScopeHost {
759
1024
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
760
1025
  if (!scope)
761
1026
  throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
1027
+ // Fork-before-promote (§4): snapshot the pre-migration data if this rebind
1028
+ // crosses a migration boundary. Gated on a real digest change and on opt-in.
1029
+ if (opts?.snapshot && scope.vertical_version_id) {
1030
+ const outgoing = await this.cp.readVersion(scope.vertical_version_id);
1031
+ if (outgoing && outgoing.migration_digest !== v.migration_digest) {
1032
+ await this.snapshotScope(actor, tenantId, scopeId);
1033
+ }
1034
+ }
762
1035
  await this.cp.bindScopeVersion(scopeId, versionId, v.vertical_slug);
763
1036
  await this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, null, {
764
1037
  versionId, vertical: v.vertical_slug, version: v.version,
@@ -848,6 +1121,46 @@ export class CloudflareScopeHost {
848
1121
  await this.recordAccess(actor, 'getScopeRecord', { tenantId, scopeId }, null, row ? 1 : 0);
849
1122
  return row ? mapScope(row) : undefined;
850
1123
  },
1124
+ listScopeTables: async (actor, tenantId, scopeId) => {
1125
+ // K-3 cross-check on the shared directory BEFORE reaching the scope DO: a pair
1126
+ // that does not resolve is unreachable, never another tenant's database.
1127
+ const row = await this.cp.getScopeRecord(tenantId, scopeId);
1128
+ if (!row)
1129
+ throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
1130
+ const tables = await this.scopeStub(scopeId).introspectTables();
1131
+ await this.recordAccess(actor, 'listScopeTables', { tenantId, scopeId }, null, tables.length);
1132
+ return tables;
1133
+ },
1134
+ readScopeTable: async (actor, tenantId, scopeId, input) => {
1135
+ const row = await this.cp.getScopeRecord(tenantId, scopeId);
1136
+ if (!row)
1137
+ throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
1138
+ const page = await this.scopeStub(scopeId).introspectTable(input.table, input.limit, input.offset);
1139
+ await this.recordAccess(actor, 'readScopeTable', { tenantId, scopeId }, { table: input.table, limit: page.limit, offset: page.offset }, page.rows.length);
1140
+ return page;
1141
+ },
1142
+ queryScope: async (actor, tenantId, scopeId, input) => {
1143
+ const row = await this.cp.getScopeRecord(tenantId, scopeId);
1144
+ if (!row)
1145
+ throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
1146
+ const result = await this.scopeStub(scopeId).introspectQuery(input.sql);
1147
+ // The statement is the logged argument: the access log is the evidence trail,
1148
+ // and for a console read the SQL is the whole story.
1149
+ await this.recordAccess(actor, 'queryScope', { tenantId, scopeId }, { sql: input.sql }, result.rows.length);
1150
+ return result;
1151
+ },
1152
+ exportScope: async (actor, tenantId, scopeId) => {
1153
+ // K-3 cross-check on the shared directory BEFORE reaching the scope DO, exactly
1154
+ // as the introspection reads: an unresolved pair is unreachable, never another
1155
+ // tenant's database. The DO returns the tables; the coordinator, which knows the
1156
+ // scope's identity, stamps the dump.
1157
+ const row = await this.cp.getScopeRecord(tenantId, scopeId);
1158
+ if (!row)
1159
+ throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
1160
+ const tables = await this.scopeStub(scopeId).exportDump();
1161
+ await this.recordAccess(actor, 'exportScope', { tenantId, scopeId }, null, tables.length);
1162
+ return { tenantId, scopeId, capturedAt: new Date().toISOString(), tables };
1163
+ },
851
1164
  activateScope: async (actor, tenantId, scopeId) => {
852
1165
  // Idempotent on `active`, unaudited because nothing changed. Provisioning is
853
1166
  // a two-phase creation that the reconciliation sweep re-runs (K-31), so a