@substrat-run/adapter-cloudflare 0.14.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/README.md +1 -1
- package/dist/control-plane-do.d.ts +41 -2
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +82 -9
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +36 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +250 -9
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts +4 -3
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +112 -9
- package/dist/scope-do.js.map +1 -1
- package/package.json +5 -5
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. */
|
|
@@ -249,6 +249,43 @@ export class CloudflareScopeHost {
|
|
|
249
249
|
async introspectScopeTable(scopeId, input) {
|
|
250
250
|
return this.scopeStub(scopeId).introspectTable(input.table, input.limit, input.offset);
|
|
251
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
|
+
}
|
|
252
289
|
registerModule(registration) {
|
|
253
290
|
const manifest = moduleManifest.parse(registration.manifest);
|
|
254
291
|
if (this.moduleIds.has(manifest.id)) {
|
|
@@ -318,6 +355,64 @@ export class CloudflareScopeHost {
|
|
|
318
355
|
await this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
|
|
319
356
|
}
|
|
320
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
|
+
}
|
|
321
416
|
/**
|
|
322
417
|
* Migrate a scope and project its resulting migration count into the directory
|
|
323
418
|
* (§5.4: fleet questions never fan out). The ScopeDO reports null when nothing
|
|
@@ -445,6 +540,7 @@ export class CloudflareScopeHost {
|
|
|
445
540
|
...(r.install_spec ? JSON.parse(r.install_spec) : {}),
|
|
446
541
|
listed: !!r.listed,
|
|
447
542
|
...(r.publish_requested_at ? { publishRequestedAt: r.publish_requested_at } : {}),
|
|
543
|
+
installsBlocked: !!r.installs_blocked,
|
|
448
544
|
createdAt: r.created_at,
|
|
449
545
|
});
|
|
450
546
|
const mapVersion = (r) => verticalVersion.parse({
|
|
@@ -499,6 +595,9 @@ export class CloudflareScopeHost {
|
|
|
499
595
|
lastAttemptAt: r.migration_last_attempt_at,
|
|
500
596
|
}
|
|
501
597
|
: null,
|
|
598
|
+
forkedFrom: r.forked_from,
|
|
599
|
+
forkedAt: r.forked_at,
|
|
600
|
+
expiresAt: r.expires_at,
|
|
502
601
|
createdAt: r.created_at,
|
|
503
602
|
});
|
|
504
603
|
const transitionScope = async (actor, action, tenantId, scopeId, from, to) => {
|
|
@@ -660,6 +759,14 @@ export class CloudflareScopeHost {
|
|
|
660
759
|
await this.cp.setHostnameStatus(hostname, status, note ?? null);
|
|
661
760
|
await this.recordAdmin(actor, 'setHostnameStatus', { tenantId: row.tenant_id, scopeId: row.scope_id }, { status: row.status }, { status, note: note ?? null });
|
|
662
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
|
+
},
|
|
663
770
|
listHostnames: async (actor, filter) => {
|
|
664
771
|
const rows = await this.cp.listHostnames({
|
|
665
772
|
tenantId: filter?.tenantId,
|
|
@@ -696,7 +803,11 @@ export class CloudflareScopeHost {
|
|
|
696
803
|
existing.owner_tenant === parsed.ownerTenant) {
|
|
697
804
|
// The env-spec evolves with the manifest — refresh it on an otherwise-identical
|
|
698
805
|
// re-registration so a declared config change propagates without a conflict.
|
|
699
|
-
|
|
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);
|
|
700
811
|
return;
|
|
701
812
|
}
|
|
702
813
|
if (existing.owner_tenant !== parsed.ownerTenant) {
|
|
@@ -714,12 +825,25 @@ export class CloudflareScopeHost {
|
|
|
714
825
|
},
|
|
715
826
|
publishVersion: async (actor, input) => {
|
|
716
827
|
const parsed = publishVersionInput.parse(input);
|
|
717
|
-
|
|
828
|
+
const owning = await this.cp.readVertical(parsed.verticalSlug);
|
|
829
|
+
if (!owning) {
|
|
718
830
|
throw new Error(`unknown vertical '${parsed.verticalSlug}'`);
|
|
719
831
|
}
|
|
720
|
-
// Lands PENDING — a push is not a deploy
|
|
721
|
-
|
|
722
|
-
|
|
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
|
+
});
|
|
723
847
|
},
|
|
724
848
|
listVersions: async (actor, verticalSlug) => {
|
|
725
849
|
const rows = await this.cp.listVersions(verticalSlug);
|
|
@@ -730,6 +854,17 @@ export class CloudflareScopeHost {
|
|
|
730
854
|
const existing = await this.cp.readVertical(slug);
|
|
731
855
|
if (!existing)
|
|
732
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
|
+
}
|
|
733
868
|
await this.cp.updateVerticalListed(slug, listed ? 1 : 0); // also resolves any pending request
|
|
734
869
|
await this.recordAdmin(actor, 'setVerticalListed', { tenantId: null }, { listed: !!existing.listed }, { listed });
|
|
735
870
|
},
|
|
@@ -740,12 +875,40 @@ export class CloudflareScopeHost {
|
|
|
740
875
|
await this.cp.updateVerticalPublishRequest(slug, new Date().toISOString());
|
|
741
876
|
await this.recordAdmin(actor, 'requestPublish', { tenantId: null }, null, { slug });
|
|
742
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
|
+
},
|
|
743
899
|
admitVersion: async (actor, versionId) => {
|
|
744
900
|
const v = await this.cp.readVersion(versionId);
|
|
745
901
|
if (!v)
|
|
746
902
|
throw new Error(`unknown version ${versionId}`);
|
|
747
|
-
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 });
|
|
748
910
|
return;
|
|
911
|
+
}
|
|
749
912
|
if (v.admission === 'rejected') {
|
|
750
913
|
throw new Error(`version ${versionId} was rejected — publish a new one`);
|
|
751
914
|
}
|
|
@@ -789,7 +952,42 @@ export class CloudflareScopeHost {
|
|
|
789
952
|
`${incoming.migration_digest}) — acknowledge it explicitly to promote`);
|
|
790
953
|
}
|
|
791
954
|
}
|
|
792
|
-
|
|
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
|
+
}
|
|
793
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 });
|
|
794
992
|
},
|
|
795
993
|
listChannels: async (actor, verticalSlug) => {
|
|
@@ -802,7 +1000,20 @@ export class CloudflareScopeHost {
|
|
|
802
1000
|
updatedAt: r.updated_at,
|
|
803
1001
|
}));
|
|
804
1002
|
},
|
|
805
|
-
|
|
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) => {
|
|
806
1017
|
const v = await this.cp.readVersion(versionId);
|
|
807
1018
|
if (!v)
|
|
808
1019
|
throw new Error(`unknown version ${versionId}`);
|
|
@@ -813,6 +1024,14 @@ export class CloudflareScopeHost {
|
|
|
813
1024
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
814
1025
|
if (!scope)
|
|
815
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
|
+
}
|
|
816
1035
|
await this.cp.bindScopeVersion(scopeId, versionId, v.vertical_slug);
|
|
817
1036
|
await this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, null, {
|
|
818
1037
|
versionId, vertical: v.vertical_slug, version: v.version,
|
|
@@ -920,6 +1139,28 @@ export class CloudflareScopeHost {
|
|
|
920
1139
|
await this.recordAccess(actor, 'readScopeTable', { tenantId, scopeId }, { table: input.table, limit: page.limit, offset: page.offset }, page.rows.length);
|
|
921
1140
|
return page;
|
|
922
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
|
+
},
|
|
923
1164
|
activateScope: async (actor, tenantId, scopeId) => {
|
|
924
1165
|
// Idempotent on `active`, unaudited because nothing changed. Provisioning is
|
|
925
1166
|
// a two-phase creation that the reconciliation sweep re-runs (K-31), so a
|