@substrat-run/control-plane-api 0.52.0 → 0.54.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 +185 -20
- 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 +68 -0
- package/dist/r2-backups.d.ts.map +1 -1
- package/dist/r2-backups.js +150 -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
|
*
|
|
@@ -339,6 +355,18 @@ export function createControlPlaneApi(options) {
|
|
|
339
355
|
}
|
|
340
356
|
await next();
|
|
341
357
|
});
|
|
358
|
+
// Fire-and-forget ops-failure write (#559): a recorder that throws must never
|
|
359
|
+
// mask the failure it is recording, so every path through this swallows its own
|
|
360
|
+
// errors. The upstream reference is extracted here — one place — so a caller
|
|
361
|
+
// that only has the message still lands a searchable row.
|
|
362
|
+
const recordFailure = (entry) => {
|
|
363
|
+
void admin
|
|
364
|
+
.recordOpsFailure({
|
|
365
|
+
...entry,
|
|
366
|
+
reference: entry.reference ?? UPSTREAM_REFERENCE.exec(entry.message)?.[1] ?? null,
|
|
367
|
+
})
|
|
368
|
+
.catch(() => undefined);
|
|
369
|
+
};
|
|
342
370
|
// One error boundary for every route: adapters throw plain Errors, and each
|
|
343
371
|
// one is a fail-closed refusal that must reach the caller as a status, not a
|
|
344
372
|
// stack trace.
|
|
@@ -347,6 +375,25 @@ export function createControlPlaneApi(options) {
|
|
|
347
375
|
return c.json({ error: 'invalid request', issues: err.issues }, 400);
|
|
348
376
|
}
|
|
349
377
|
const { status, body } = mapError(err);
|
|
378
|
+
// A 5xx is the PLATFORM failing — an unmapped throw, or a downstream vertical's
|
|
379
|
+
// own 5xx passing through (a DO storage fault during a preview restore is the
|
|
380
|
+
// founding case, #559) — so it lands a durable ops-failure row the console can
|
|
381
|
+
// list and a `reference = <id>` search can find. 501 stays out: an honest
|
|
382
|
+
// not-implemented is a capability statement, not a failure. 4xx stay out too:
|
|
383
|
+
// they are refusals the caller can already read. Actor is unset only when the
|
|
384
|
+
// throw happened before authentication — nothing worth recording refuses there.
|
|
385
|
+
const actor = c.get('actor');
|
|
386
|
+
if (status >= 500 && status !== 501 && actor) {
|
|
387
|
+
recordFailure({
|
|
388
|
+
actor,
|
|
389
|
+
operation: `${c.req.method} ${c.req.routePath}`,
|
|
390
|
+
vertical: c.req.param('slug') ? decodeURIComponent(c.req.param('slug')) : null,
|
|
391
|
+
tenantId: c.req.param('tenantId') ?? null,
|
|
392
|
+
scopeId: c.req.param('scopeId') ?? null,
|
|
393
|
+
status,
|
|
394
|
+
message: err instanceof Error ? err.message : String(err),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
350
397
|
// A 500 is, by definition, a throw whose message `mapError` did not recognise — so the
|
|
351
398
|
// client gets a GENERIC body that discloses nothing, and until now nothing recorded WHAT
|
|
352
399
|
// threw either. That left every unmapped failure (e.g. a raw SQLite constraint from a
|
|
@@ -640,6 +687,19 @@ export function createControlPlaneApi(options) {
|
|
|
640
687
|
// installed app lags prod. So we prefer bound-version resolution, then fall back to
|
|
641
688
|
// prod-channel/static (a scope with no bound version), then to reading this host's own
|
|
642
689
|
// scope DB directly (a co-located host, or the contract tests — data is right here).
|
|
690
|
+
/**
|
|
691
|
+
* The per-subject key operations bound to one scope and one actor (#37) — what
|
|
692
|
+
* `sealDump`/`openDump` need and nothing more.
|
|
693
|
+
*
|
|
694
|
+
* Narrowed on purpose: the seal path gets exactly two capabilities, so a future edit
|
|
695
|
+
* cannot quietly widen a backup routine into a general admin caller. Every call lands in
|
|
696
|
+
* the access log under the acting staff member, because reading or minting the keys that
|
|
697
|
+
* protect a subject's data is itself a thing an incident asks about.
|
|
698
|
+
*/
|
|
699
|
+
const sealerFor = (c, tenantId, scopeId) => ({
|
|
700
|
+
seal: (items) => admin.sealSubjectPayloads(c.get('actor'), tenantId, scopeId, items),
|
|
701
|
+
open: (items) => admin.openSubjectPayloads(c.get('actor'), tenantId, scopeId, items),
|
|
702
|
+
});
|
|
643
703
|
const verticalForScope = async (c, scope) => {
|
|
644
704
|
if (!scope.vertical)
|
|
645
705
|
return undefined;
|
|
@@ -835,9 +895,9 @@ export function createControlPlaneApi(options) {
|
|
|
835
895
|
// brought — the crossing is then no riskier than an ordinary version bind. Anything
|
|
836
896
|
// else (differing digests, or a scope with no bound version to compare) needs the
|
|
837
897
|
// operator's explicit acknowledgement.
|
|
838
|
-
const targetV =
|
|
898
|
+
const targetV = await admin.getVersion(actor, serving.versionId, target);
|
|
839
899
|
const currentV = scope.verticalVersionId
|
|
840
|
-
?
|
|
900
|
+
? await admin.getVersion(actor, scope.verticalVersionId, scope.vertical)
|
|
841
901
|
: undefined;
|
|
842
902
|
const digestsMatch = Boolean(currentV && targetV && currentV.migrationDigest === targetV.migrationDigest);
|
|
843
903
|
if (!digestsMatch && !opts.ackMigrations) {
|
|
@@ -1143,7 +1203,14 @@ export function createControlPlaneApi(options) {
|
|
|
1143
1203
|
const dump = await admin.exportScope(c.get('actor'), tenantId, scope.id);
|
|
1144
1204
|
const vertical = await verticalForScope(c, scope);
|
|
1145
1205
|
const tables = vertical ? await vertical.exportScope(scope.id) : dump.tables;
|
|
1146
|
-
|
|
1206
|
+
// Seal classified payloads per subject on the way in (#37). This copy is full-fidelity
|
|
1207
|
+
// and the platform keeps it, which is precisely why an erasure could never reach it: a
|
|
1208
|
+
// redaction fixes the live scope and does nothing to the object already in R2. Sealed
|
|
1209
|
+
// here, destroying one subject's key reaches backwards into every copy already taken.
|
|
1210
|
+
// Restores open it again (`restoreFromBackup`) — except for subjects shredded in the
|
|
1211
|
+
// meantime, which is the mechanism working rather than the copy being damaged.
|
|
1212
|
+
const sealed = await sealDump(tables, sealerFor(c, tenantId, scope.id));
|
|
1213
|
+
return store.put({ vertical: scope.vertical, dump: { ...dump, tables: sealed } });
|
|
1147
1214
|
};
|
|
1148
1215
|
// The copies held for one scope — metadata only, so listing a reaped scope's backups
|
|
1149
1216
|
// is cheap and hands out no bytes. Readable AFTER the reap (that is the point): the
|
|
@@ -1176,7 +1243,13 @@ export function createControlPlaneApi(options) {
|
|
|
1176
1243
|
const dump = await options.scopeBackups.get({ tenantId, scopeId, capturedAt });
|
|
1177
1244
|
if (!dump)
|
|
1178
1245
|
return c.json({ error: `no backup for scope ${scopeId} at ${capturedAt}` }, 404);
|
|
1179
|
-
|
|
1246
|
+
// Unseal on the way out (#37). The bytes in the store stay sealed — this is the
|
|
1247
|
+
// authorized read opening them with the keys the platform holds. A subject shredded
|
|
1248
|
+
// since the copy was taken opens to a null payload, which is exactly the erasure
|
|
1249
|
+
// working: the ciphertext never left the object, and nothing turns it back into a
|
|
1250
|
+
// person. A dump taken before sealing existed passes through untouched.
|
|
1251
|
+
const opened = await openDump(dump.tables, sealerFor(c, tenantId, scopeId));
|
|
1252
|
+
return c.json({ ...dump, tables: opened });
|
|
1180
1253
|
});
|
|
1181
1254
|
// Take a backup WITHOUT reaping — the standalone copy (a pre-migration checkpoint, an
|
|
1182
1255
|
// export-to-keep). The reap route below takes its own; this is the same act made
|
|
@@ -1197,6 +1270,32 @@ export function createControlPlaneApi(options) {
|
|
|
1197
1270
|
throw e;
|
|
1198
1271
|
}
|
|
1199
1272
|
});
|
|
1273
|
+
// -- subject erasure (#37) --------------------------------------------------
|
|
1274
|
+
// Erase one data subject from one scope: redact the spine payloads keyed to them, then
|
|
1275
|
+
// destroy the key that seals every platform-retained copy of those payloads.
|
|
1276
|
+
//
|
|
1277
|
+
// Staff-only, and NOT in BUILDER_ROUTES. That placement is the shared-responsibility line
|
|
1278
|
+
// drawn where hosting-and-certification.md §3 already draws it — "we provide extraction,
|
|
1279
|
+
// they define scope". A builder forwards the request; the platform executes it and hands
|
|
1280
|
+
// back a receipt they can answer their data subject with. Making it self-service would
|
|
1281
|
+
// hand a vertical the ability to destroy evidence about a person on its own authority,
|
|
1282
|
+
// which is a different decision than this one and belongs in its own issue.
|
|
1283
|
+
//
|
|
1284
|
+
// Idempotent by construction: a re-run redacts nothing (the payloads are already null),
|
|
1285
|
+
// reports `keyDestroyed: false` (the key is already gone) and still returns
|
|
1286
|
+
// `tombstoned: true`. Safe to retry, which matters because the audited half and the
|
|
1287
|
+
// cryptographic half are two writes.
|
|
1288
|
+
app.post('/tenants/:tenantId/scopes/:scopeId/subjects/:subjectId/shred', async (c) => {
|
|
1289
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1290
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1291
|
+
// A ULID, like every other subject id the spine carries — parsed rather than trusted,
|
|
1292
|
+
// so a wildcard or an injection attempt is a 400 and never reaches the UPDATE.
|
|
1293
|
+
const subjectId = dataSubjectIdSchema.parse(c.req.param('subjectId'));
|
|
1294
|
+
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
1295
|
+
if (!scope)
|
|
1296
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1297
|
+
return c.json(await admin.shredSubject(c.get('actor'), tenantId, scopeId, subjectId));
|
|
1298
|
+
});
|
|
1200
1299
|
// -- directory backups (#40) -----------------------------------------------
|
|
1201
1300
|
// The platform's own disaster recovery, on the same store posture as the scope
|
|
1202
1301
|
// backups above and deliberately on a different axis from them: a scope has ~30-day
|
|
@@ -1518,11 +1617,24 @@ export function createControlPlaneApi(options) {
|
|
|
1518
1617
|
return c.json({ error: 'restore refused: the backup has no tables — not a scope dump' }, 422);
|
|
1519
1618
|
}
|
|
1520
1619
|
try {
|
|
1521
|
-
|
|
1620
|
+
// Belt and braces for #37: a caller who fetched the object bytes by some other route
|
|
1621
|
+
// may POST a still-sealed dump, and a scope restored full of ciphertext is a silent
|
|
1622
|
+
// data-loss bug. `openDump` skips cells that are not sealed, so a plaintext dump —
|
|
1623
|
+
// the normal case, since the GET above already opened it — costs one pass and
|
|
1624
|
+
// changes nothing.
|
|
1625
|
+
//
|
|
1626
|
+
// Opened against the dump's OWN provenance, not the destination. Subject keys are
|
|
1627
|
+
// keyed by (scope, subject), so a copy of scope A landing in scope B — a backout onto
|
|
1628
|
+
// a different scope, a world loaded sideways — must be opened with A's keys. Using
|
|
1629
|
+
// the destination's would find no key, null every payload, and call it a restore.
|
|
1630
|
+
const origin = { tenantId: tenantIdSchema.parse(dump.tenantId), scopeId: scopeIdSchema.parse(dump.scopeId) };
|
|
1631
|
+
const tables = await openDump(dump.tables, sealerFor(c, origin.tenantId, origin.scopeId));
|
|
1632
|
+
const landing = { ...dump, tables };
|
|
1633
|
+
await host.restoreScope(actor, tenantId, scopeId, landing);
|
|
1522
1634
|
const vertical = await verticalForScope(c, scope);
|
|
1523
1635
|
if (vertical)
|
|
1524
|
-
await vertical.restoreScope(tenantId, scopeId,
|
|
1525
|
-
return c.json({ restored: scopeId, tables:
|
|
1636
|
+
await vertical.restoreScope(tenantId, scopeId, tables);
|
|
1637
|
+
return c.json({ restored: scopeId, tables: tables.length });
|
|
1526
1638
|
}
|
|
1527
1639
|
catch (e) {
|
|
1528
1640
|
if (e instanceof ControlPlaneError) {
|
|
@@ -1686,9 +1798,10 @@ export function createControlPlaneApi(options) {
|
|
|
1686
1798
|
// Delegated path: the digest compare lives here (the in-process path does
|
|
1687
1799
|
// it below the seam). Snapshot only a migration-crossing bind.
|
|
1688
1800
|
if (scope.vertical && scope.verticalVersionId) {
|
|
1689
|
-
const
|
|
1690
|
-
|
|
1691
|
-
|
|
1801
|
+
const [current, incoming] = await Promise.all([
|
|
1802
|
+
admin.getVersion(actor, scope.verticalVersionId, scope.vertical),
|
|
1803
|
+
admin.getVersion(actor, versionId, scope.vertical),
|
|
1804
|
+
]);
|
|
1692
1805
|
if (current && incoming && current.migrationDigest !== incoming.migrationDigest) {
|
|
1693
1806
|
await orchestratedSnapshot(c, tenantId, scope, {});
|
|
1694
1807
|
}
|
|
@@ -1801,6 +1914,19 @@ export function createControlPlaneApi(options) {
|
|
|
1801
1914
|
// must act on, and indistinguishable from "the vertical is broken" once it
|
|
1802
1915
|
// has been flattened.
|
|
1803
1916
|
if (e instanceof ControlPlaneError) {
|
|
1917
|
+
// The retry above already rode out the transient window, so a 5xx here is a
|
|
1918
|
+
// provision the platform could not complete — record it (#559). Refusals
|
|
1919
|
+
// (4xx, and 501 = not implemented) stay unrecorded: the caller can read them.
|
|
1920
|
+
if (e.status >= 500 && e.status !== 501) {
|
|
1921
|
+
recordFailure({
|
|
1922
|
+
actor: c.get('actor'),
|
|
1923
|
+
operation: 'install.provision',
|
|
1924
|
+
vertical: slug,
|
|
1925
|
+
tenantId: input.tenantId,
|
|
1926
|
+
status: e.status,
|
|
1927
|
+
message: e.message,
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1804
1930
|
return c.json({ error: e.message }, e.status);
|
|
1805
1931
|
}
|
|
1806
1932
|
throw e;
|
|
@@ -1918,7 +2044,7 @@ export function createControlPlaneApi(options) {
|
|
|
1918
2044
|
}
|
|
1919
2045
|
input.verticalSlug = slug;
|
|
1920
2046
|
await admin.publishVersion(c.get('actor'), input);
|
|
1921
|
-
const version =
|
|
2047
|
+
const version = await admin.getVersion(c.get('actor'), input.id, slug);
|
|
1922
2048
|
return c.json(version, 201);
|
|
1923
2049
|
});
|
|
1924
2050
|
// The declared permission registry (D-39, #336) that ships inside one version's manifest:
|
|
@@ -1957,14 +2083,14 @@ export function createControlPlaneApi(options) {
|
|
|
1957
2083
|
const slug = c.req.param('slug');
|
|
1958
2084
|
const id = c.req.param('id');
|
|
1959
2085
|
await admin.admitVersion(c.get('actor'), id);
|
|
1960
|
-
return c.json(
|
|
2086
|
+
return c.json(await admin.getVersion(c.get('actor'), id, slug));
|
|
1961
2087
|
});
|
|
1962
2088
|
app.post('/verticals/:slug/versions/:id/reject', async (c) => {
|
|
1963
2089
|
const slug = c.req.param('slug');
|
|
1964
2090
|
const id = c.req.param('id');
|
|
1965
2091
|
const { note } = rejectVersionBody.parse(await c.req.json());
|
|
1966
2092
|
await admin.rejectVersion(c.get('actor'), id, note);
|
|
1967
|
-
return c.json(
|
|
2093
|
+
return c.json(await admin.getVersion(c.get('actor'), id, slug));
|
|
1968
2094
|
});
|
|
1969
2095
|
// A builder REQUESTS publication of a vertical it owns (marketplace-publish.md §5) — any owner
|
|
1970
2096
|
// may ask; the staff `listing` flip below is the review gate. Owner-checked (like promote).
|
|
@@ -2066,7 +2192,7 @@ export function createControlPlaneApi(options) {
|
|
|
2066
2192
|
const serveVersionInPlace = async (actor, slug, versionId) => {
|
|
2067
2193
|
if (!options.deployVertical || !options.fetchVerticalModules)
|
|
2068
2194
|
return; // not configured: pre-#286 behavior
|
|
2069
|
-
const version =
|
|
2195
|
+
const version = await admin.getVersion(actor, versionId, slug);
|
|
2070
2196
|
if (!version?.deploymentRef) {
|
|
2071
2197
|
throw new ControlPlaneError(502, `version ${versionId} has no archive script to serve from`);
|
|
2072
2198
|
}
|
|
@@ -2395,7 +2521,20 @@ export function createControlPlaneApi(options) {
|
|
|
2395
2521
|
const detail = e instanceof Error ? e.message : String(e);
|
|
2396
2522
|
const upstream = upstreamStatusOf(e);
|
|
2397
2523
|
console.error('deploy.upload.failed', { slug, deploymentRef, detail, upstream });
|
|
2398
|
-
|
|
2524
|
+
const rejected = upstream !== undefined && upstream >= 400 && upstream < 500;
|
|
2525
|
+
// Both outcomes land an ops-failure row (#559): the 502 is the platform's to
|
|
2526
|
+
// explain, and the 422 is what the builder-facing failure view (step 5) will
|
|
2527
|
+
// list — a red push should be explainable from a durable record either way.
|
|
2528
|
+
recordFailure({
|
|
2529
|
+
actor: c.get('actor'),
|
|
2530
|
+
operation: 'deploy.upload',
|
|
2531
|
+
stage: 'wfp-upload',
|
|
2532
|
+
vertical: slug,
|
|
2533
|
+
tenantId: ownerTenant ?? null,
|
|
2534
|
+
status: rejected ? 422 : 502,
|
|
2535
|
+
message: detail,
|
|
2536
|
+
});
|
|
2537
|
+
return rejected
|
|
2399
2538
|
? c.json({ error: 'deploy rejected', detail }, 422)
|
|
2400
2539
|
: c.json({ error: 'deploy upload failed', detail }, 502);
|
|
2401
2540
|
}
|
|
@@ -2450,14 +2589,19 @@ export function createControlPlaneApi(options) {
|
|
|
2450
2589
|
const warnings = [];
|
|
2451
2590
|
if (manifest.surfaces?.length) {
|
|
2452
2591
|
const declared = new Set(manifest.surfaces.map((s) => s.name));
|
|
2453
|
-
|
|
2592
|
+
// Narrowed to THIS vertical's bindings in the query. It used to read every
|
|
2593
|
+
// hostname on the platform and filter in JS, which made an advisory warning about
|
|
2594
|
+
// one push depend on every other tenant's routing rows parsing cleanly — a
|
|
2595
|
+
// malformed cert-validation blob on an unrelated domain took the whole deploy down
|
|
2596
|
+
// with a blank 500, after the version had already been published.
|
|
2597
|
+
const bound = await admin.listHostnames(c.get('actor'), { verticalSlug: slug });
|
|
2454
2598
|
for (const h of bound) {
|
|
2455
|
-
if (
|
|
2599
|
+
if (!declared.has(h.surface)) {
|
|
2456
2600
|
warnings.push(`hostname '${h.hostname}' is bound to surface '${h.surface}', which this version no longer declares`);
|
|
2457
2601
|
}
|
|
2458
2602
|
}
|
|
2459
2603
|
}
|
|
2460
|
-
const version =
|
|
2604
|
+
const version = await admin.getVersion(c.get('actor'), id, slug);
|
|
2461
2605
|
return c.json({ ...version, ...(warnings.length ? { warnings } : {}) }, 201);
|
|
2462
2606
|
});
|
|
2463
2607
|
// -- where this platform runs (ops ergonomics) -----------------------------
|
|
@@ -2801,7 +2945,7 @@ export function createControlPlaneApi(options) {
|
|
|
2801
2945
|
// prod build) while we report success. That is worse than a failure: it sends a reviewer
|
|
2802
2946
|
// to redo correct work. So refuse to return success for a URL that would serve other code.
|
|
2803
2947
|
const assertServesBoundVersion = async (previewId) => {
|
|
2804
|
-
const bound =
|
|
2948
|
+
const bound = await admin.getVersion(actor, opts.versionId, slug);
|
|
2805
2949
|
// A co-located / embedded vertical has no per-version dispatch script (deploymentRef
|
|
2806
2950
|
// null) and routes via the static fallback — nothing to compare, so nothing to guard.
|
|
2807
2951
|
if (!bound?.deploymentRef)
|
|
@@ -3070,6 +3214,27 @@ export function createControlPlaneApi(options) {
|
|
|
3070
3214
|
// page carries its own continuation and the console never assembles one.
|
|
3071
3215
|
return c.json(pageOf(entries, filter.limit, (e) => e.id));
|
|
3072
3216
|
});
|
|
3217
|
+
// 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".
|
|
3222
|
+
app.get('/ops-failures', async (c) => {
|
|
3223
|
+
const filter = opsFailuresQuery.parse({
|
|
3224
|
+
tenantId: c.req.query('tenantId'),
|
|
3225
|
+
scopeId: c.req.query('scopeId'),
|
|
3226
|
+
vertical: c.req.query('vertical'),
|
|
3227
|
+
operation: c.req.query('operation'),
|
|
3228
|
+
reference: c.req.query('reference'),
|
|
3229
|
+
since: c.req.query('since'),
|
|
3230
|
+
until: c.req.query('until'),
|
|
3231
|
+
limit: c.req.query('limit'),
|
|
3232
|
+
cursor: c.req.query('cursor'),
|
|
3233
|
+
order: c.req.query('order'),
|
|
3234
|
+
});
|
|
3235
|
+
const entries = await admin.listOpsFailures(c.get('actor'), filter);
|
|
3236
|
+
return c.json(pageOf(entries, filter.limit, (e) => e.id));
|
|
3237
|
+
});
|
|
3073
3238
|
return app;
|
|
3074
3239
|
}
|
|
3075
3240
|
//# sourceMappingURL=api.js.map
|