@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 CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
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 = (await admin.listVersions(actor, target)).find((v) => v.id === serving.versionId);
898
+ const targetV = await admin.getVersion(actor, serving.versionId, target);
839
899
  const currentV = scope.verticalVersionId
840
- ? (await admin.listVersions(actor, scope.vertical)).find((v) => v.id === scope.verticalVersionId)
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
- return store.put({ vertical: scope.vertical, dump: { ...dump, tables } });
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
- return c.json(dump);
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
- await host.restoreScope(actor, tenantId, scopeId, dump);
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, dump.tables);
1525
- return c.json({ restored: scopeId, tables: dump.tables.length });
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 versions = await admin.listVersions(actor, scope.vertical);
1690
- const current = versions.find((v) => v.id === scope.verticalVersionId);
1691
- const incoming = versions.find((v) => v.id === versionId);
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 = (await admin.listVersions(c.get('actor'), slug)).find((v) => v.id === input.id);
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((await admin.listVersions(c.get('actor'), slug)).find((v) => v.id === id));
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((await admin.listVersions(c.get('actor'), slug)).find((v) => v.id === id));
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 = (await admin.listVersions(actor, slug)).find((v) => v.id === versionId);
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
- return upstream !== undefined && upstream >= 400 && upstream < 500
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
- const bound = await admin.listHostnames(c.get('actor'), {});
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 (h.verticalSlug === slug && !declared.has(h.surface)) {
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 = (await admin.listVersions(c.get('actor'), slug)).find((v) => v.id === id);
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 = (await admin.listVersions(actor, slug)).find((v) => v.id === opts.versionId);
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