@substrat-run/control-plane-api 0.53.0 → 0.55.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.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;AA+B5B,OAAO,KAAK,EAGV,eAAe,EAMhB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAGtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAe3D,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;AA0O7D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CAkhGhG"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
package/dist/api.js CHANGED
@@ -1,11 +1,12 @@
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, 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, 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';
6
6
  import { provisionSiblingScope } from './platform-drain.js';
7
7
  import { mapError } from './errors.js';
8
8
  import { maskDump, maskRecords } from './mask.js';
9
+ import { openDump, sealDump } from './seal.js';
9
10
  import { assertSandboxContract, deployManifest, storedDeployManifest, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
10
11
  import { blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, tenantStoreBindings, } from './tenant-stores.js';
11
12
  import { mintPushToken, pushActorFor } from './push-token.js';
@@ -218,6 +219,21 @@ const auditLogQuery = z.object({
218
219
  // everything) — only the HTTP egress vectors are bounded by default.
219
220
  ...listPageQuery.shape,
220
221
  });
222
+ const opsFailuresQuery = z.object({
223
+ tenantId: tenantIdSchema.optional(),
224
+ scopeId: scopeIdSchema.optional(),
225
+ vertical: z.string().optional(),
226
+ operation: z.string().optional(),
227
+ // Exact match — the lookup a CI log's `reference = <id>` line lands on.
228
+ reference: z.string().optional(),
229
+ since: z.string().optional(),
230
+ until: z.string().optional(),
231
+ // Bounded by default exactly as /admin-log, and for the same reason.
232
+ ...listPageQuery.shape,
233
+ });
234
+ /** The upstream provider's trace handle, when a failure message carries one —
235
+ * Cloudflare's `internal error; reference = <id>` shape (#559). */
236
+ const UPSTREAM_REFERENCE = /\breference\s*=\s*([a-z0-9]+)/i;
221
237
  /**
222
238
  * The audited HTTP surface over `HostAdmin` (control-plane.md §4.5).
223
239
  *
@@ -330,6 +346,10 @@ export function createControlPlaneApi(options) {
330
346
  { method: 'POST', re: /\/verticals\/[^/]+\/previews$/ },
331
347
  { method: 'GET', re: /\/verticals\/[^/]+\/previews$/ },
332
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$/ },
333
353
  ];
334
354
  app.use('*', async (c, next) => {
335
355
  if (c.get('principal').kind === 'builder') {
@@ -339,6 +359,43 @@ export function createControlPlaneApi(options) {
339
359
  }
340
360
  await next();
341
361
  });
362
+ // Fire-and-forget ops-failure write (#559): a recorder that throws must never
363
+ // mask the failure it is recording, so every path through this swallows its own
364
+ // errors. The upstream reference is extracted here — one place — so a caller
365
+ // that only has the message still lands a searchable row.
366
+ const recordFailure = (entry) => {
367
+ void admin
368
+ .recordOpsFailure({
369
+ ...entry,
370
+ reference: entry.reference ?? UPSTREAM_REFERENCE.exec(entry.message)?.[1] ?? null,
371
+ })
372
+ .catch(() => undefined);
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
+ };
342
399
  // One error boundary for every route: adapters throw plain Errors, and each
343
400
  // one is a fail-closed refusal that must reach the caller as a status, not a
344
401
  // stack trace.
@@ -347,6 +404,25 @@ export function createControlPlaneApi(options) {
347
404
  return c.json({ error: 'invalid request', issues: err.issues }, 400);
348
405
  }
349
406
  const { status, body } = mapError(err);
407
+ // A 5xx is the PLATFORM failing — an unmapped throw, or a downstream vertical's
408
+ // own 5xx passing through (a DO storage fault during a preview restore is the
409
+ // founding case, #559) — so it lands a durable ops-failure row the console can
410
+ // list and a `reference = <id>` search can find. 501 stays out: an honest
411
+ // not-implemented is a capability statement, not a failure. 4xx stay out too:
412
+ // they are refusals the caller can already read. Actor is unset only when the
413
+ // throw happened before authentication — nothing worth recording refuses there.
414
+ const actor = c.get('actor');
415
+ if (status >= 500 && status !== 501 && actor) {
416
+ recordFailure({
417
+ actor,
418
+ operation: `${c.req.method} ${c.req.routePath}`,
419
+ vertical: c.req.param('slug') ? decodeURIComponent(c.req.param('slug')) : null,
420
+ tenantId: c.req.param('tenantId') ?? null,
421
+ scopeId: c.req.param('scopeId') ?? null,
422
+ status,
423
+ message: err instanceof Error ? err.message : String(err),
424
+ });
425
+ }
350
426
  // A 500 is, by definition, a throw whose message `mapError` did not recognise — so the
351
427
  // client gets a GENERIC body that discloses nothing, and until now nothing recorded WHAT
352
428
  // threw either. That left every unmapped failure (e.g. a raw SQLite constraint from a
@@ -640,6 +716,19 @@ export function createControlPlaneApi(options) {
640
716
  // installed app lags prod. So we prefer bound-version resolution, then fall back to
641
717
  // prod-channel/static (a scope with no bound version), then to reading this host's own
642
718
  // scope DB directly (a co-located host, or the contract tests — data is right here).
719
+ /**
720
+ * The per-subject key operations bound to one scope and one actor (#37) — what
721
+ * `sealDump`/`openDump` need and nothing more.
722
+ *
723
+ * Narrowed on purpose: the seal path gets exactly two capabilities, so a future edit
724
+ * cannot quietly widen a backup routine into a general admin caller. Every call lands in
725
+ * the access log under the acting staff member, because reading or minting the keys that
726
+ * protect a subject's data is itself a thing an incident asks about.
727
+ */
728
+ const sealerFor = (c, tenantId, scopeId) => ({
729
+ seal: (items) => admin.sealSubjectPayloads(c.get('actor'), tenantId, scopeId, items),
730
+ open: (items) => admin.openSubjectPayloads(c.get('actor'), tenantId, scopeId, items),
731
+ });
643
732
  const verticalForScope = async (c, scope) => {
644
733
  if (!scope.vertical)
645
734
  return undefined;
@@ -744,7 +833,7 @@ export function createControlPlaneApi(options) {
744
833
  throw new ControlPlaneError(501, 'adopt-serving needs dispatch resolution for both ends');
745
834
  }
746
835
  const dump = await source.exportScope(scopeId);
747
- const restored = await dest.restoreScope(tenantId, scopeId, dump);
836
+ const restored = await retryTransient(() => dest.restoreScope(tenantId, scopeId, dump));
748
837
  // Data landed — only now flip routing and move the version pointer.
749
838
  await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
750
839
  await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
@@ -850,7 +939,7 @@ export function createControlPlaneApi(options) {
850
939
  throw new ControlPlaneError(501, 'rebind-vertical needs dispatch resolution for both ends');
851
940
  }
852
941
  const dump = await source.exportScope(scopeId);
853
- const restored = await dest.restoreScope(tenantId, scopeId, dump);
942
+ const restored = await retryTransient(() => dest.restoreScope(tenantId, scopeId, dump));
854
943
  // Data landed on the target script — only now flip routing and cross the pointer.
855
944
  // `bindScopeVersion` rewrites `scopes.vertical` from the version row, audited. No
856
945
  // extra snapshot here (adopt-serving's precedent): the source script's copy is the
@@ -1050,7 +1139,7 @@ export function createControlPlaneApi(options) {
1050
1139
  forkedAt: new Date().toISOString(),
1051
1140
  expiresAt: opts.expiresAt,
1052
1141
  });
1053
- await vertical.snapshotScope({ sourceScopeId: scope.id, newScopeId: snapId });
1142
+ await retryTransient(() => vertical.snapshotScope({ sourceScopeId: scope.id, newScopeId: snapId }));
1054
1143
  await admin.activateScope(actor, tenantId, snapId);
1055
1144
  // Bound to the SOURCE's current version: source and fork share a deployment, so
1056
1145
  // the fork resolves to the DO namespace its bytes actually live in.
@@ -1143,7 +1232,14 @@ export function createControlPlaneApi(options) {
1143
1232
  const dump = await admin.exportScope(c.get('actor'), tenantId, scope.id);
1144
1233
  const vertical = await verticalForScope(c, scope);
1145
1234
  const tables = vertical ? await vertical.exportScope(scope.id) : dump.tables;
1146
- return store.put({ vertical: scope.vertical, dump: { ...dump, tables } });
1235
+ // Seal classified payloads per subject on the way in (#37). This copy is full-fidelity
1236
+ // and the platform keeps it, which is precisely why an erasure could never reach it: a
1237
+ // redaction fixes the live scope and does nothing to the object already in R2. Sealed
1238
+ // here, destroying one subject's key reaches backwards into every copy already taken.
1239
+ // Restores open it again (`restoreFromBackup`) — except for subjects shredded in the
1240
+ // meantime, which is the mechanism working rather than the copy being damaged.
1241
+ const sealed = await sealDump(tables, sealerFor(c, tenantId, scope.id));
1242
+ return store.put({ vertical: scope.vertical, dump: { ...dump, tables: sealed } });
1147
1243
  };
1148
1244
  // The copies held for one scope — metadata only, so listing a reaped scope's backups
1149
1245
  // is cheap and hands out no bytes. Readable AFTER the reap (that is the point): the
@@ -1176,7 +1272,13 @@ export function createControlPlaneApi(options) {
1176
1272
  const dump = await options.scopeBackups.get({ tenantId, scopeId, capturedAt });
1177
1273
  if (!dump)
1178
1274
  return c.json({ error: `no backup for scope ${scopeId} at ${capturedAt}` }, 404);
1179
- return c.json(dump);
1275
+ // Unseal on the way out (#37). The bytes in the store stay sealed — this is the
1276
+ // authorized read opening them with the keys the platform holds. A subject shredded
1277
+ // since the copy was taken opens to a null payload, which is exactly the erasure
1278
+ // working: the ciphertext never left the object, and nothing turns it back into a
1279
+ // person. A dump taken before sealing existed passes through untouched.
1280
+ const opened = await openDump(dump.tables, sealerFor(c, tenantId, scopeId));
1281
+ return c.json({ ...dump, tables: opened });
1180
1282
  });
1181
1283
  // Take a backup WITHOUT reaping — the standalone copy (a pre-migration checkpoint, an
1182
1284
  // export-to-keep). The reap route below takes its own; this is the same act made
@@ -1197,6 +1299,32 @@ export function createControlPlaneApi(options) {
1197
1299
  throw e;
1198
1300
  }
1199
1301
  });
1302
+ // -- subject erasure (#37) --------------------------------------------------
1303
+ // Erase one data subject from one scope: redact the spine payloads keyed to them, then
1304
+ // destroy the key that seals every platform-retained copy of those payloads.
1305
+ //
1306
+ // Staff-only, and NOT in BUILDER_ROUTES. That placement is the shared-responsibility line
1307
+ // drawn where hosting-and-certification.md §3 already draws it — "we provide extraction,
1308
+ // they define scope". A builder forwards the request; the platform executes it and hands
1309
+ // back a receipt they can answer their data subject with. Making it self-service would
1310
+ // hand a vertical the ability to destroy evidence about a person on its own authority,
1311
+ // which is a different decision than this one and belongs in its own issue.
1312
+ //
1313
+ // Idempotent by construction: a re-run redacts nothing (the payloads are already null),
1314
+ // reports `keyDestroyed: false` (the key is already gone) and still returns
1315
+ // `tombstoned: true`. Safe to retry, which matters because the audited half and the
1316
+ // cryptographic half are two writes.
1317
+ app.post('/tenants/:tenantId/scopes/:scopeId/subjects/:subjectId/shred', async (c) => {
1318
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1319
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1320
+ // A ULID, like every other subject id the spine carries — parsed rather than trusted,
1321
+ // so a wildcard or an injection attempt is a 400 and never reaches the UPDATE.
1322
+ const subjectId = dataSubjectIdSchema.parse(c.req.param('subjectId'));
1323
+ const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1324
+ if (!scope)
1325
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1326
+ return c.json(await admin.shredSubject(c.get('actor'), tenantId, scopeId, subjectId));
1327
+ });
1200
1328
  // -- directory backups (#40) -----------------------------------------------
1201
1329
  // The platform's own disaster recovery, on the same store posture as the scope
1202
1330
  // backups above and deliberately on a different axis from them: a scope has ~30-day
@@ -1518,11 +1646,24 @@ export function createControlPlaneApi(options) {
1518
1646
  return c.json({ error: 'restore refused: the backup has no tables — not a scope dump' }, 422);
1519
1647
  }
1520
1648
  try {
1521
- await host.restoreScope(actor, tenantId, scopeId, dump);
1649
+ // Belt and braces for #37: a caller who fetched the object bytes by some other route
1650
+ // may POST a still-sealed dump, and a scope restored full of ciphertext is a silent
1651
+ // data-loss bug. `openDump` skips cells that are not sealed, so a plaintext dump —
1652
+ // the normal case, since the GET above already opened it — costs one pass and
1653
+ // changes nothing.
1654
+ //
1655
+ // Opened against the dump's OWN provenance, not the destination. Subject keys are
1656
+ // keyed by (scope, subject), so a copy of scope A landing in scope B — a backout onto
1657
+ // a different scope, a world loaded sideways — must be opened with A's keys. Using
1658
+ // the destination's would find no key, null every payload, and call it a restore.
1659
+ const origin = { tenantId: tenantIdSchema.parse(dump.tenantId), scopeId: scopeIdSchema.parse(dump.scopeId) };
1660
+ const tables = await openDump(dump.tables, sealerFor(c, origin.tenantId, origin.scopeId));
1661
+ const landing = { ...dump, tables };
1662
+ await host.restoreScope(actor, tenantId, scopeId, landing);
1522
1663
  const vertical = await verticalForScope(c, scope);
1523
1664
  if (vertical)
1524
- await vertical.restoreScope(tenantId, scopeId, dump.tables);
1525
- return c.json({ restored: scopeId, tables: dump.tables.length });
1665
+ await retryTransient(() => vertical.restoreScope(tenantId, scopeId, tables));
1666
+ return c.json({ restored: scopeId, tables: tables.length });
1526
1667
  }
1527
1668
  catch (e) {
1528
1669
  if (e instanceof ControlPlaneError) {
@@ -1714,10 +1855,6 @@ export function createControlPlaneApi(options) {
1714
1855
  // orphaned scope nobody can see, rather than a directory row promising a scope
1715
1856
  // that does not exist. `scopeStatus` has a `provisioning` state for expressing the
1716
1857
  // in-between properly, and it is still unused — see the PR.
1717
- // Rides out the binding-attach → script-settings propagation window (see the
1718
- // `provisionRetryDelaysMs` option); the same shape as the dashboard's #391
1719
- // configure retry. ~3s worst case, well inside a Worker request budget.
1720
- const PROVISION_RETRY_DELAYS_MS = [750, 2500];
1721
1858
  app.post('/verticals/:slug/instances', async (c) => {
1722
1859
  const slug = c.req.param('slug');
1723
1860
  // The install kill-switch: a blocked vertical takes no NEW instances, for anyone
@@ -1771,29 +1908,13 @@ export function createControlPlaneApi(options) {
1771
1908
  // #424 case 2: the binding attach above races Cloudflare script-settings
1772
1909
  // propagation, so the vertical's FIRST answer can be a transient 5xx that a
1773
1910
  // retry moments later heals. `provisionInstance` is idempotent at the far end
1774
- // (K-31), so ride the window out on a short backoff. Honest refusals (4xx, and
1775
- // 501 = not implemented) surface immediately — retrying a refusal only delays
1776
- // the real message.
1777
- const delays = options.provisionRetryDelaysMs ?? PROVISION_RETRY_DELAYS_MS;
1778
- let instance;
1779
- for (let attempt = 0;; attempt++) {
1780
- try {
1781
- instance = await vertical.provisionInstance({
1782
- ...input,
1783
- entitlements,
1784
- identityLinks,
1785
- ...(tenantStores.length ? { tenantStores } : {}),
1786
- });
1787
- break;
1788
- }
1789
- catch (e) {
1790
- const transient = e instanceof ControlPlaneError && (e.status === 0 || (e.status >= 500 && e.status !== 501));
1791
- const delay = delays[attempt];
1792
- if (!transient || delay === undefined)
1793
- throw e;
1794
- await new Promise((resolve) => setTimeout(resolve, delay));
1795
- }
1796
- }
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
+ }));
1797
1918
  return c.json(instance, 201);
1798
1919
  }
1799
1920
  catch (e) {
@@ -1802,6 +1923,19 @@ export function createControlPlaneApi(options) {
1802
1923
  // must act on, and indistinguishable from "the vertical is broken" once it
1803
1924
  // has been flattened.
1804
1925
  if (e instanceof ControlPlaneError) {
1926
+ // The retry above already rode out the transient window, so a 5xx here is a
1927
+ // provision the platform could not complete — record it (#559). Refusals
1928
+ // (4xx, and 501 = not implemented) stay unrecorded: the caller can read them.
1929
+ if (e.status >= 500 && e.status !== 501) {
1930
+ recordFailure({
1931
+ actor: c.get('actor'),
1932
+ operation: 'install.provision',
1933
+ vertical: slug,
1934
+ tenantId: input.tenantId,
1935
+ status: e.status,
1936
+ message: e.message,
1937
+ });
1938
+ }
1805
1939
  return c.json({ error: e.message }, e.status);
1806
1940
  }
1807
1941
  throw e;
@@ -2396,7 +2530,20 @@ export function createControlPlaneApi(options) {
2396
2530
  const detail = e instanceof Error ? e.message : String(e);
2397
2531
  const upstream = upstreamStatusOf(e);
2398
2532
  console.error('deploy.upload.failed', { slug, deploymentRef, detail, upstream });
2399
- return upstream !== undefined && upstream >= 400 && upstream < 500
2533
+ const rejected = upstream !== undefined && upstream >= 400 && upstream < 500;
2534
+ // Both outcomes land an ops-failure row (#559): the 502 is the platform's to
2535
+ // explain, and the 422 is what the builder-facing failure view (step 5) will
2536
+ // list — a red push should be explainable from a durable record either way.
2537
+ recordFailure({
2538
+ actor: c.get('actor'),
2539
+ operation: 'deploy.upload',
2540
+ stage: 'wfp-upload',
2541
+ vertical: slug,
2542
+ tenantId: ownerTenant ?? null,
2543
+ status: rejected ? 422 : 502,
2544
+ message: detail,
2545
+ });
2546
+ return rejected
2400
2547
  ? c.json({ error: 'deploy rejected', detail }, 422)
2401
2548
  : c.json({ error: 'deploy upload failed', detail }, 502);
2402
2549
  }
@@ -2876,6 +3023,30 @@ export function createControlPlaneApi(options) {
2876
3023
  if (existing && stale)
2877
3024
  await reapPreview(c, existing);
2878
3025
  const previewId = scopeIdSchema.parse(ulid());
3026
+ // The founding #559 case lands its durable row HERE, not in onError: the previews
3027
+ // route answers a ControlPlaneError directly (its own catch, never the app-level
3028
+ // recorder), and only this frame knows the preview's scopeId — the key that lets
3029
+ // the console explain the stranded `provisioning` row this throw leaves behind.
3030
+ const restoreOrRecord = async (fn) => {
3031
+ try {
3032
+ await retryTransient(fn);
3033
+ }
3034
+ catch (e) {
3035
+ if (e instanceof ControlPlaneError && e.status >= 500 && e.status !== 501) {
3036
+ recordFailure({
3037
+ actor,
3038
+ operation: 'preview.create',
3039
+ stage: 'restore',
3040
+ tenantId,
3041
+ scopeId: previewId,
3042
+ vertical: slug,
3043
+ status: e.status,
3044
+ message: e.message,
3045
+ });
3046
+ }
3047
+ throw e;
3048
+ }
3049
+ };
2879
3050
  if (source) {
2880
3051
  // A fresh fork. Export from where the prod data lives TODAY. The canonical
2881
3052
  // `admin.exportScope` first — it writes the K-24 audit entry (and the co-located
@@ -2903,8 +3074,10 @@ export function createControlPlaneApi(options) {
2903
3074
  expiresAt: expiresAt ?? undefined,
2904
3075
  });
2905
3076
  // Load the fork into the PR version's deployment (materializes the preview scope DO
2906
- // there; restore re-projects the vertical's roles from the dump's tuples).
2907
- await target.restoreScope(tenantId, previewId, tables);
3077
+ // there; restore re-projects the vertical's roles from the dump's tuples). A one-shot
3078
+ // DO storage blip heals on the in-request retry WITHOUT burning a CI attempt (which
3079
+ // pushes a fresh version per try) — #559 (2).
3080
+ await restoreOrRecord(() => target.restoreScope(tenantId, previewId, tables));
2908
3081
  }
2909
3082
  else {
2910
3083
  // A clean-room preview (#509 (b)): an EMPTY scope, no source to export. No `forkedFrom`
@@ -2923,7 +3096,7 @@ export function createControlPlaneApi(options) {
2923
3096
  expiresAt: expiresAt ?? undefined,
2924
3097
  });
2925
3098
  if (target)
2926
- await target.restoreScope(tenantId, previewId, []);
3099
+ await restoreOrRecord(() => target.restoreScope(tenantId, previewId, []));
2927
3100
  }
2928
3101
  await admin.activateScope(actor, tenantId, previewId);
2929
3102
  // Bind the PR version. A private vertical's push self-admitted, so this is accepted; a
@@ -3076,6 +3249,28 @@ export function createControlPlaneApi(options) {
3076
3249
  // page carries its own continuation and the console never assembles one.
3077
3250
  return c.json(pageOf(entries, filter.limit, (e) => e.id));
3078
3251
  });
3252
+ // The recorded operational failures (#559) — the console's failures view, and the
3253
+ // "what does this `reference = <id>` belong to" lookup. Newest first by default,
3254
+ // unlike /admin-log: an operator asks "what broke lately". A builder reads only its
3255
+ // OWN tenant's rows — the filter is forced, not trusted from the query (step 5: a
3256
+ // red CI run is explainable from the dashboard without staff involvement).
3257
+ app.get('/ops-failures', async (c) => {
3258
+ const p = c.get('principal');
3259
+ const filter = opsFailuresQuery.parse({
3260
+ tenantId: p.kind === 'builder' ? p.tenantId : c.req.query('tenantId'),
3261
+ scopeId: c.req.query('scopeId'),
3262
+ vertical: c.req.query('vertical'),
3263
+ operation: c.req.query('operation'),
3264
+ reference: c.req.query('reference'),
3265
+ since: c.req.query('since'),
3266
+ until: c.req.query('until'),
3267
+ limit: c.req.query('limit'),
3268
+ cursor: c.req.query('cursor'),
3269
+ order: c.req.query('order'),
3270
+ });
3271
+ const entries = await admin.listOpsFailures(c.get('actor'), filter);
3272
+ return c.json(pageOf(entries, filter.limit, (e) => e.id));
3273
+ });
3079
3274
  return app;
3080
3275
  }
3081
3276
  //# sourceMappingURL=api.js.map