@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/README.md +1 -1
- package/dist/control-plane-do.d.ts +55 -1
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +112 -9
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +46 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +324 -11
- 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 +158 -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. */
|
|
@@ -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({
|
|
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
|
-
|
|
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
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|