@substrat-run/control-plane-api 0.53.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":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAgC5B,OAAO,KAAK,EAGV,eAAe,EAMhB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAmB,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAGvE,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAgB3D,OAAO,KAAK,EAAe,gBAAgB,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACzF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAQtD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAuB,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACjF,OAAO,KAAK,EAEV,oBAAoB,EAEpB,gBAAgB,EACjB,MAAM,cAAc,CAAC;AAEtB,OAAO,EAGL,KAAK,yBAAyB,EAC/B,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC3C;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IAChG;;;;;;;;;OASG;IACH,sBAAsB,CAAC,EAAE,CACvB,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,KACnB,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACzC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACpF;;;;;OAKG;IACH,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,sBAAsB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,qBAAqB,CAAC;IAC5C;;;;;;;;OAQG;IACH,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;;OAIG;IACH,YAAY,EAAE,iBAAiB,CAAC;IAChC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,WAAW,CAAC;IAClC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC;;;;;;;;;OASG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC;;;;;;;;;;;;;OAaG;IACH,YAAY,CAAC,EAAE,gBAAgB,CAAC;IAChC;;;;;;;;;OASG;IACH,gBAAgB,CAAC,EAAE,oBAAoB,CAAC;IACxC;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,yBAAyB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;CAChC;AAKD,KAAK,IAAI,GAAG;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AA2P7D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CA2qGhG"}
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;
@@ -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) {
@@ -1802,6 +1914,19 @@ export function createControlPlaneApi(options) {
1802
1914
  // must act on, and indistinguishable from "the vertical is broken" once it
1803
1915
  // has been flattened.
1804
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
+ }
1805
1930
  return c.json({ error: e.message }, e.status);
1806
1931
  }
1807
1932
  throw e;
@@ -2396,7 +2521,20 @@ export function createControlPlaneApi(options) {
2396
2521
  const detail = e instanceof Error ? e.message : String(e);
2397
2522
  const upstream = upstreamStatusOf(e);
2398
2523
  console.error('deploy.upload.failed', { slug, deploymentRef, detail, upstream });
2399
- 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
2400
2538
  ? c.json({ error: 'deploy rejected', detail }, 422)
2401
2539
  : c.json({ error: 'deploy upload failed', detail }, 502);
2402
2540
  }
@@ -3076,6 +3214,27 @@ export function createControlPlaneApi(options) {
3076
3214
  // page carries its own continuation and the console never assembles one.
3077
3215
  return c.json(pageOf(entries, filter.limit, (e) => e.id));
3078
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
+ });
3079
3238
  return app;
3080
3239
  }
3081
3240
  //# sourceMappingURL=api.js.map