@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/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
- await this.cp.updateVerticalManifestMeta(parsed.slug, envSpecJson, installSpecJson);
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
- if (!(await this.cp.readVertical(parsed.verticalSlug))) {
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
- await this.cp.insertVersion({ ...parsed, createdAt: new Date().toISOString() });
722
- 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
+ });
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
- 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
+ }
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
- 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) => {
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