@substrat-run/control-plane-api 0.54.0 → 0.56.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/api.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { PlatformActorId } from '@substrat-run/contracts';
3
3
  import type { ScopeHost } from '@substrat-run/kernel';
4
4
  import type { PlatformActorAuth, BuilderAuth, Principal } from './auth.js';
5
5
  import type { VerticalClient } from './vertical-client.js';
6
- import type { DeployVerticalFn, FetchVerticalModulesFn } from './deploy.js';
6
+ import type { DeployVerticalFn, FetchVerticalAssetFn, FetchVerticalModulesFn } from './deploy.js';
7
7
  import type { PatchScriptBindingsFn } from './wfp.js';
8
8
  import type { ObservabilityReader } from './observability.js';
9
9
  import type { PlatformRuntime } from './platform-runtime.js';
@@ -63,6 +63,15 @@ export interface ControlPlaneApiOptions {
63
63
  * place (the pre-#286 behavior: scopes stay on per-version dispatch).
64
64
  */
65
65
  fetchVerticalModules?: FetchVerticalModulesFn;
66
+ /**
67
+ * Reads one static file back from a script's runtime-served assets (#578) — how a
68
+ * serving upload recovers asset bytes the stable script's upload session reports
69
+ * missing (the asset store dedups per script, so the push's upload to the archive
70
+ * script does not cover a re-serve). Host-injected like `fetchVerticalModules`.
71
+ * Absent ⇒ a re-serve of an asset-carrying version refuses when the runtime wants
72
+ * bytes (the pre-#578 behavior).
73
+ */
74
+ fetchVerticalAsset?: FetchVerticalAssetFn;
66
75
  /**
67
76
  * Ensures per-tenant store D1 bindings exist on a dispatch script without a redeploy
68
77
  * (#301) — the attach step that makes a freshly-minted tenant store reachable in the
package/dist/api.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAgC5B,OAAO,KAAK,EAGV,eAAe,EAMhB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAmB,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAGvE,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAgB3D,OAAO,KAAK,EAAe,gBAAgB,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACzF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAQtD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAuB,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACjF,OAAO,KAAK,EAEV,oBAAoB,EAEpB,gBAAgB,EACjB,MAAM,cAAc,CAAC;AAEtB,OAAO,EAGL,KAAK,yBAAyB,EAC/B,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC3C;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IAChG;;;;;;;;;OASG;IACH,sBAAsB,CAAC,EAAE,CACvB,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,KACnB,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACzC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACpF;;;;;OAKG;IACH,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,sBAAsB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,qBAAqB,CAAC;IAC5C;;;;;;;;OAQG;IACH,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;;OAIG;IACH,YAAY,EAAE,iBAAiB,CAAC;IAChC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,WAAW,CAAC;IAClC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC;;;;;;;;;OASG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC;;;;;;;;;;;;;OAaG;IACH,YAAY,CAAC,EAAE,gBAAgB,CAAC;IAChC;;;;;;;;;OASG;IACH,gBAAgB,CAAC,EAAE,oBAAoB,CAAC;IACxC;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,yBAAyB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;CAChC;AAKD,KAAK,IAAI,GAAG;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AA2P7D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CA2qGhG"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
package/dist/api.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Hono } from 'hono';
2
- import { adminAction, ASSET_PART_PREFIX, assetHash, channelName, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, listPageQuery, pageOf, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, registerVerticalInput, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, z, } from '@substrat-run/contracts';
2
+ import { adminAction, ASSET_PART_PREFIX, assetHash, channelName, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, listPageQuery, pageOf, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, registerVerticalInput, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, } from '@substrat-run/contracts';
3
3
  import { migrationProgress, ulid } from '@substrat-run/kernel';
4
4
  import { TENANT_HEADER } from './auth.js';
5
5
  import { ControlPlaneError } from './client.js';
@@ -346,6 +346,10 @@ export function createControlPlaneApi(options) {
346
346
  { method: 'POST', re: /\/verticals\/[^/]+\/previews$/ },
347
347
  { method: 'GET', re: /\/verticals\/[^/]+\/previews$/ },
348
348
  { method: 'DELETE', re: /\/verticals\/[^/]+\/previews\/[^/]+$/ },
349
+ // The builder's slice of the ops-failure record (#559 step 5): why did MY deploy /
350
+ // preview / provision fail. Tenant-narrowed in the handler (the forced-filter
351
+ // pattern, like GET /scopes); the allowlist alone is not authz.
352
+ { method: 'GET', re: /\/ops-failures$/ },
349
353
  ];
350
354
  app.use('*', async (c, next) => {
351
355
  if (c.get('principal').kind === 'builder') {
@@ -367,6 +371,31 @@ export function createControlPlaneApi(options) {
367
371
  })
368
372
  .catch(() => undefined);
369
373
  };
374
+ // Rides out a transient downstream window: the install path's binding-attach →
375
+ // script-settings propagation race (#424 case 2), and a one-shot DO storage blip
376
+ // during an export→restore or snapshot copy (#559 (2)). Retrying is cheap at THESE
377
+ // call sites specifically — the dump is already in memory and the far end is
378
+ // drop-then-replay idempotent — unlike CI's retry, which burns a pushed version per
379
+ // attempt. Honest refusals (4xx, and 501 = not implemented) surface immediately:
380
+ // retrying a refusal only delays the real message. ~3s worst case on the default
381
+ // delays, well inside a Worker request budget; a persistent fault still exhausts
382
+ // and surfaces (and lands an ops-failure row via the paths that record).
383
+ const PROVISION_RETRY_DELAYS_MS = [750, 2500];
384
+ const retryTransient = async (fn) => {
385
+ const delays = options.provisionRetryDelaysMs ?? PROVISION_RETRY_DELAYS_MS;
386
+ for (let attempt = 0;; attempt++) {
387
+ try {
388
+ return await fn();
389
+ }
390
+ catch (e) {
391
+ const transient = e instanceof ControlPlaneError && (e.status === 0 || (e.status >= 500 && e.status !== 501));
392
+ const delay = delays[attempt];
393
+ if (!transient || delay === undefined)
394
+ throw e;
395
+ await new Promise((resolve) => setTimeout(resolve, delay));
396
+ }
397
+ }
398
+ };
370
399
  // One error boundary for every route: adapters throw plain Errors, and each
371
400
  // one is a fail-closed refusal that must reach the caller as a status, not a
372
401
  // stack trace.
@@ -804,7 +833,7 @@ export function createControlPlaneApi(options) {
804
833
  throw new ControlPlaneError(501, 'adopt-serving needs dispatch resolution for both ends');
805
834
  }
806
835
  const dump = await source.exportScope(scopeId);
807
- const restored = await dest.restoreScope(tenantId, scopeId, dump);
836
+ const restored = await retryTransient(() => dest.restoreScope(tenantId, scopeId, dump));
808
837
  // Data landed — only now flip routing and move the version pointer.
809
838
  await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
810
839
  await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
@@ -910,7 +939,7 @@ export function createControlPlaneApi(options) {
910
939
  throw new ControlPlaneError(501, 'rebind-vertical needs dispatch resolution for both ends');
911
940
  }
912
941
  const dump = await source.exportScope(scopeId);
913
- const restored = await dest.restoreScope(tenantId, scopeId, dump);
942
+ const restored = await retryTransient(() => dest.restoreScope(tenantId, scopeId, dump));
914
943
  // Data landed on the target script — only now flip routing and cross the pointer.
915
944
  // `bindScopeVersion` rewrites `scopes.vertical` from the version row, audited. No
916
945
  // extra snapshot here (adopt-serving's precedent): the source script's copy is the
@@ -1110,7 +1139,7 @@ export function createControlPlaneApi(options) {
1110
1139
  forkedAt: new Date().toISOString(),
1111
1140
  expiresAt: opts.expiresAt,
1112
1141
  });
1113
- await vertical.snapshotScope({ sourceScopeId: scope.id, newScopeId: snapId });
1142
+ await retryTransient(() => vertical.snapshotScope({ sourceScopeId: scope.id, newScopeId: snapId }));
1114
1143
  await admin.activateScope(actor, tenantId, snapId);
1115
1144
  // Bound to the SOURCE's current version: source and fork share a deployment, so
1116
1145
  // the fork resolves to the DO namespace its bytes actually live in.
@@ -1633,7 +1662,7 @@ export function createControlPlaneApi(options) {
1633
1662
  await host.restoreScope(actor, tenantId, scopeId, landing);
1634
1663
  const vertical = await verticalForScope(c, scope);
1635
1664
  if (vertical)
1636
- await vertical.restoreScope(tenantId, scopeId, tables);
1665
+ await retryTransient(() => vertical.restoreScope(tenantId, scopeId, tables));
1637
1666
  return c.json({ restored: scopeId, tables: tables.length });
1638
1667
  }
1639
1668
  catch (e) {
@@ -1826,10 +1855,6 @@ export function createControlPlaneApi(options) {
1826
1855
  // orphaned scope nobody can see, rather than a directory row promising a scope
1827
1856
  // that does not exist. `scopeStatus` has a `provisioning` state for expressing the
1828
1857
  // in-between properly, and it is still unused — see the PR.
1829
- // Rides out the binding-attach → script-settings propagation window (see the
1830
- // `provisionRetryDelaysMs` option); the same shape as the dashboard's #391
1831
- // configure retry. ~3s worst case, well inside a Worker request budget.
1832
- const PROVISION_RETRY_DELAYS_MS = [750, 2500];
1833
1858
  app.post('/verticals/:slug/instances', async (c) => {
1834
1859
  const slug = c.req.param('slug');
1835
1860
  // The install kill-switch: a blocked vertical takes no NEW instances, for anyone
@@ -1883,29 +1908,13 @@ export function createControlPlaneApi(options) {
1883
1908
  // #424 case 2: the binding attach above races Cloudflare script-settings
1884
1909
  // propagation, so the vertical's FIRST answer can be a transient 5xx that a
1885
1910
  // retry moments later heals. `provisionInstance` is idempotent at the far end
1886
- // (K-31), so ride the window out on a short backoff. Honest refusals (4xx, and
1887
- // 501 = not implemented) surface immediately — retrying a refusal only delays
1888
- // the real message.
1889
- const delays = options.provisionRetryDelaysMs ?? PROVISION_RETRY_DELAYS_MS;
1890
- let instance;
1891
- for (let attempt = 0;; attempt++) {
1892
- try {
1893
- instance = await vertical.provisionInstance({
1894
- ...input,
1895
- entitlements,
1896
- identityLinks,
1897
- ...(tenantStores.length ? { tenantStores } : {}),
1898
- });
1899
- break;
1900
- }
1901
- catch (e) {
1902
- const transient = e instanceof ControlPlaneError && (e.status === 0 || (e.status >= 500 && e.status !== 501));
1903
- const delay = delays[attempt];
1904
- if (!transient || delay === undefined)
1905
- throw e;
1906
- await new Promise((resolve) => setTimeout(resolve, delay));
1907
- }
1908
- }
1911
+ // (K-31), so ride the window out on a short backoff.
1912
+ const instance = await retryTransient(() => vertical.provisionInstance({
1913
+ ...input,
1914
+ entitlements,
1915
+ identityLinks,
1916
+ ...(tenantStores.length ? { tenantStores } : {}),
1917
+ }));
1909
1918
  return c.json(instance, 201);
1910
1919
  }
1911
1920
  catch (e) {
@@ -2227,12 +2236,26 @@ export function createControlPlaneApi(options) {
2227
2236
  doClasses: manifest.doClasses,
2228
2237
  bindings: [...manifest.bindings, ...storeBindings],
2229
2238
  // #340: the version's static files travel with it onto the serving script — from
2230
- // the RETAINED manifest, with no bytes. An asset upload session is driven by
2231
- // content addresses, and the runtime's asset store is namespace-wide and deduped,
2232
- // so re-declaring the same hashes re-attaches the same files. This is why the
2233
- // manifest is retained rather than the bytes: the archive script gives back the
2234
- // modules (#286), and the asset store gives back the assets.
2235
- ...(manifest.assets ? { assets: manifest.assets } : {}),
2239
+ // the RETAINED manifest, with no bytes up front. The asset store dedups PER
2240
+ // SCRIPT (#578), so hashes the push uploaded to the archive script are still
2241
+ // missing for the stable script each file therefore carries a `fetchContent`
2242
+ // that reads the bytes back from the archive script's runtime-served assets,
2243
+ // invoked (and hash-verified) only for hashes the upload session reports
2244
+ // missing. The archive script gives back the modules (#286) AND the assets:
2245
+ // nothing but the manifest is retained.
2246
+ ...(manifest.assets
2247
+ ? {
2248
+ assets: {
2249
+ ...manifest.assets,
2250
+ files: manifest.assets.files.map((f) => ({
2251
+ ...f,
2252
+ ...(options.fetchVerticalAsset
2253
+ ? { fetchContent: () => options.fetchVerticalAsset(version.deploymentRef, f.path) }
2254
+ : {}),
2255
+ })),
2256
+ },
2257
+ }
2258
+ : {}),
2236
2259
  }, serving
2237
2260
  ? { priorDoClasses: serving.doClasses, priorMigrationTag: serving.migrationTag }
2238
2261
  : undefined);
@@ -2341,6 +2364,21 @@ export function createControlPlaneApi(options) {
2341
2364
  // silently dropped. An old CLI that sends no pin keeps today's behavior on all paths.
2342
2365
  const pinField = form.get('tenant');
2343
2366
  const pin = typeof pinField === 'string' && pinField.length > 0 ? pinField : null;
2367
+ // The push's self-reported provenance (git CI vs a terminal) — a label the dashboard
2368
+ // shows, never authority, so a missing or malformed field must not fail the push
2369
+ // (an old CLI sends none). Lenient by construction: safeParse, drop on mismatch.
2370
+ const originField = form.get('origin');
2371
+ const origin = (() => {
2372
+ if (typeof originField !== 'string')
2373
+ return undefined;
2374
+ try {
2375
+ const parsed = versionOrigin.safeParse(JSON.parse(originField));
2376
+ return parsed.success ? parsed.data : undefined;
2377
+ }
2378
+ catch {
2379
+ return undefined;
2380
+ }
2381
+ })();
2344
2382
  // Resolve the registry id + owner this push acts on. Checked BEFORE the upload so a
2345
2383
  // refused push never leaves an orphaned namespace script.
2346
2384
  const bare = c.req.param('slug');
@@ -2577,6 +2615,7 @@ export function createControlPlaneApi(options) {
2577
2615
  permissionDigest: manifest.digests.permission,
2578
2616
  migrationDigest: manifest.digests.migration,
2579
2617
  deploymentRef,
2618
+ ...(origin ? { origin } : {}),
2580
2619
  // Retained for the serving upload (#286): the archive script keeps the module
2581
2620
  // bytes, this keeps their shape (entry, compat, doClasses, bindings).
2582
2621
  manifestJson: JSON.stringify(manifest),
@@ -3014,6 +3053,30 @@ export function createControlPlaneApi(options) {
3014
3053
  if (existing && stale)
3015
3054
  await reapPreview(c, existing);
3016
3055
  const previewId = scopeIdSchema.parse(ulid());
3056
+ // The founding #559 case lands its durable row HERE, not in onError: the previews
3057
+ // route answers a ControlPlaneError directly (its own catch, never the app-level
3058
+ // recorder), and only this frame knows the preview's scopeId — the key that lets
3059
+ // the console explain the stranded `provisioning` row this throw leaves behind.
3060
+ const restoreOrRecord = async (fn) => {
3061
+ try {
3062
+ await retryTransient(fn);
3063
+ }
3064
+ catch (e) {
3065
+ if (e instanceof ControlPlaneError && e.status >= 500 && e.status !== 501) {
3066
+ recordFailure({
3067
+ actor,
3068
+ operation: 'preview.create',
3069
+ stage: 'restore',
3070
+ tenantId,
3071
+ scopeId: previewId,
3072
+ vertical: slug,
3073
+ status: e.status,
3074
+ message: e.message,
3075
+ });
3076
+ }
3077
+ throw e;
3078
+ }
3079
+ };
3017
3080
  if (source) {
3018
3081
  // A fresh fork. Export from where the prod data lives TODAY. The canonical
3019
3082
  // `admin.exportScope` first — it writes the K-24 audit entry (and the co-located
@@ -3041,8 +3104,10 @@ export function createControlPlaneApi(options) {
3041
3104
  expiresAt: expiresAt ?? undefined,
3042
3105
  });
3043
3106
  // Load the fork into the PR version's deployment (materializes the preview scope DO
3044
- // there; restore re-projects the vertical's roles from the dump's tuples).
3045
- await target.restoreScope(tenantId, previewId, tables);
3107
+ // there; restore re-projects the vertical's roles from the dump's tuples). A one-shot
3108
+ // DO storage blip heals on the in-request retry WITHOUT burning a CI attempt (which
3109
+ // pushes a fresh version per try) — #559 (2).
3110
+ await restoreOrRecord(() => target.restoreScope(tenantId, previewId, tables));
3046
3111
  }
3047
3112
  else {
3048
3113
  // A clean-room preview (#509 (b)): an EMPTY scope, no source to export. No `forkedFrom`
@@ -3061,7 +3126,7 @@ export function createControlPlaneApi(options) {
3061
3126
  expiresAt: expiresAt ?? undefined,
3062
3127
  });
3063
3128
  if (target)
3064
- await target.restoreScope(tenantId, previewId, []);
3129
+ await restoreOrRecord(() => target.restoreScope(tenantId, previewId, []));
3065
3130
  }
3066
3131
  await admin.activateScope(actor, tenantId, previewId);
3067
3132
  // Bind the PR version. A private vertical's push self-admitted, so this is accepted; a
@@ -3215,13 +3280,14 @@ export function createControlPlaneApi(options) {
3215
3280
  return c.json(pageOf(entries, filter.limit, (e) => e.id));
3216
3281
  });
3217
3282
  // The recorded operational failures (#559) — the console's failures view, and the
3218
- // "what does this `reference = <id>` belong to" lookup. Staff-only by omission from
3219
- // BUILDER_ROUTES (a builder-scoped slice is step 5's concern, deliberately not
3220
- // pre-opened here). Newest first by default, unlike /admin-log: an operator asks
3221
- // "what broke lately".
3283
+ // "what does this `reference = <id>` belong to" lookup. Newest first by default,
3284
+ // unlike /admin-log: an operator asks "what broke lately". A builder reads only its
3285
+ // OWN tenant's rows the filter is forced, not trusted from the query (step 5: a
3286
+ // red CI run is explainable from the dashboard without staff involvement).
3222
3287
  app.get('/ops-failures', async (c) => {
3288
+ const p = c.get('principal');
3223
3289
  const filter = opsFailuresQuery.parse({
3224
- tenantId: c.req.query('tenantId'),
3290
+ tenantId: p.kind === 'builder' ? p.tenantId : c.req.query('tenantId'),
3225
3291
  scopeId: c.req.query('scopeId'),
3226
3292
  vertical: c.req.query('vertical'),
3227
3293
  operation: c.req.query('operation'),