@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 +1 -1
- package/dist/api.js +235 -40
- package/dist/api.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/r2-backups.d.ts +52 -0
- package/dist/r2-backups.d.ts.map +1 -1
- package/dist/r2-backups.js +92 -0
- package/dist/r2-backups.js.map +1 -1
- package/dist/seal.d.ts +62 -0
- package/dist/seal.d.ts.map +1 -0
- package/dist/seal.js +132 -0
- package/dist/seal.js.map +1 -0
- package/package.json +4 -4
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1525
|
-
return c.json({ restored: scopeId, tables:
|
|
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.
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|