@substrat-run/control-plane-api 0.116.0 → 0.118.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.
Files changed (45) hide show
  1. package/dist/api.d.ts +31 -1
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +526 -104
  4. package/dist/api.js.map +1 -1
  5. package/dist/auth.d.ts +35 -3
  6. package/dist/auth.d.ts.map +1 -1
  7. package/dist/auth.js +11 -0
  8. package/dist/auth.js.map +1 -1
  9. package/dist/cf-observability.d.ts.map +1 -1
  10. package/dist/cf-observability.js +26 -2
  11. package/dist/cf-observability.js.map +1 -1
  12. package/dist/index.d.ts +8 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +5 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist/observability.d.ts +6 -0
  17. package/dist/observability.d.ts.map +1 -1
  18. package/dist/platform-drain.d.ts +6 -3
  19. package/dist/platform-drain.d.ts.map +1 -1
  20. package/dist/platform-drain.js +22 -4
  21. package/dist/platform-drain.js.map +1 -1
  22. package/dist/push-token.d.ts.map +1 -1
  23. package/dist/push-token.js +9 -23
  24. package/dist/push-token.js.map +1 -1
  25. package/dist/scope-deployment.d.ts +34 -0
  26. package/dist/scope-deployment.d.ts.map +1 -0
  27. package/dist/scope-deployment.js +19 -0
  28. package/dist/scope-deployment.js.map +1 -0
  29. package/dist/storage-meter.d.ts +32 -0
  30. package/dist/storage-meter.d.ts.map +1 -0
  31. package/dist/storage-meter.js +69 -0
  32. package/dist/storage-meter.js.map +1 -0
  33. package/dist/tenant-token.d.ts +41 -0
  34. package/dist/tenant-token.d.ts.map +1 -0
  35. package/dist/tenant-token.js +108 -0
  36. package/dist/tenant-token.js.map +1 -0
  37. package/dist/token-codec.d.ts +30 -0
  38. package/dist/token-codec.d.ts.map +1 -0
  39. package/dist/token-codec.js +63 -0
  40. package/dist/token-codec.js.map +1 -0
  41. package/dist/vertical-client.d.ts +34 -3
  42. package/dist/vertical-client.d.ts.map +1 -1
  43. package/dist/vertical-client.js +66 -3
  44. package/dist/vertical-client.js.map +1 -1
  45. package/package.json +5 -5
package/dist/api.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { Hono } from 'hono';
2
- import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, connectionFilter, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
2
+ import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, connectionFilter, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
3
3
  import { attributeFailure } from './failure-attribution.js';
4
4
  import { migrationProgress, ulid } from '@substrat-run/kernel';
5
- import { TENANT_HEADER } from './auth.js';
5
+ import { TENANT_HEADER, confinedTenant } from './auth.js';
6
+ import { mintTenantToken } from './tenant-token.js';
6
7
  import { connectionGrantsForScope } from './vertical-client.js';
8
+ import { versionReachedAt } from './scope-deployment.js';
7
9
  import { reconcileConnectionGrants } from './connection-grants.js';
8
10
  import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
9
11
  import { ControlPlaneError } from './client.js';
@@ -31,6 +33,7 @@ import { mintPushToken, pushActorFor } from './push-token.js';
31
33
  import { TENANT_SERIES_SCOPE_CAP } from './observability.js';
32
34
  import { namespacesForScript } from './do-namespaces.js';
33
35
  import { backupDirectoryIfDue } from './directory-backup.js';
36
+ import { STORAGE_PAGE_DEFAULT, STORAGE_PAGE_MAX, readStoragePage } from './storage-meter.js';
34
37
  import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
35
38
  /**
36
39
  * Response headers carrying the CLI version advisory (`ControlPlaneApiOptions.cliAdvisory`).
@@ -39,6 +42,18 @@ import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.
39
42
  */
40
43
  export const CLI_MIN_VERSION_HEADER = 'x-substrat-cli-min-version';
41
44
  export const CLI_LATEST_VERSION_HEADER = 'x-substrat-cli-latest-version';
45
+ /**
46
+ * "This principal is confined to a tenant, and this is not that tenant."
47
+ *
48
+ * The question every `/tenants/:tenantId/…` route asks before it answers. Spelled in
49
+ * terms of CONFINEMENT rather than of a principal kind, because a route that asks
50
+ * `kind === 'builder'` answers fleet-wide for every other confined credential — which
51
+ * is the shape #977 found the dashboard's in.
52
+ */
53
+ function outsideTenant(p, tenantId) {
54
+ const pin = confinedTenant(p);
55
+ return pin !== null && pin !== tenantId;
56
+ }
42
57
  // -- request schemas ---------------------------------------------------------
43
58
  // Parse, don't trust: every input crosses Zod at the boundary. The ids stay
44
59
  // CALLER-SUPPLIED rather than minted here, exactly as the contract has them —
@@ -322,6 +337,12 @@ const tenantRoleAssignmentBody = z
322
337
  roleKey: roleKeySchema,
323
338
  })
324
339
  .strict();
340
+ /**
341
+ * The schedule kill switch's body (#1666) — the module and the reason. The node comes
342
+ * from the PATH, after the K-3 cross-check, for the reason `tenantRoleAssignmentBody`
343
+ * spells out; `.strict()` so a body that tried to name one is refused, not stripped.
344
+ */
345
+ const systemSwitchBody = systemSwitch.pick({ moduleId: true, reason: true }).strict();
325
346
  /** A new org under an addressed tenant (#1343) — `tenantId` comes from the path. */
326
347
  const tenantOrgBody = createOrgInput.omit({ tenantId: true });
327
348
  const sweepRunsQuery = z.object({
@@ -356,6 +377,105 @@ export const DEFAULT_MODEL_MARGIN_PERCENT = 20;
356
377
  /** The upstream provider's trace handle, when a failure message carries one —
357
378
  * Cloudflare's `internal error; reference = <id>` shape (#559). */
358
379
  const UPSTREAM_REFERENCE = /\breference\s*=\s*([a-z0-9]+)/i;
380
+ const TENANT_ROUTES = [
381
+ // Everything under one tenant's own path. One entry rather than sixty, because
382
+ // the confinement is carried by the URL shape and not by a list anybody has to
383
+ // keep current: `/tenants/<pin>/…` cannot address another tenant's row whatever
384
+ // the sub-route does. `/tenants` itself (the fleet list) does not match.
385
+ { method: 'GET', re: /\/tenants\/[^/]+(\/.*)?$/, pin: 'path' },
386
+ { method: 'POST', re: /\/tenants\/[^/]+(\/.*)?$/, pin: 'path' },
387
+ { method: 'PUT', re: /\/tenants\/[^/]+(\/.*)?$/, pin: 'path' },
388
+ { method: 'PATCH', re: /\/tenants\/[^/]+(\/.*)?$/, pin: 'path' },
389
+ { method: 'DELETE', re: /\/tenants\/[^/]+(\/.*)?$/, pin: 'path' },
390
+ // Create the tenant's own directory row (idempotent) — `id` is the pin.
391
+ { method: 'POST', re: /\/tenants$/, pin: 'body' },
392
+ // Provision an app + materialize its instance.
393
+ { method: 'POST', re: /\/scopes$/, pin: 'body' },
394
+ { method: 'POST', re: /\/verticals\/[^/]+\/instances$/, pin: 'body' },
395
+ // Mint the tenant's CI push token. Note what is NOT here: `/tenant-tokens`. A
396
+ // tenant credential cannot mint another, for itself or for anyone — the mint
397
+ // stays staff-only, so this credential can never widen its own reach.
398
+ { method: 'POST', re: /\/push-tokens$/, pin: 'body' },
399
+ // The forced-filter reads: the route refuses without a tenantId and answers only
400
+ // that tenant's rows.
401
+ //
402
+ // `GET /scopes` is the one the dashboard leans on hardest — the Data tab's scope
403
+ // switcher and the whole Move/Retire flow (#1593) read it — and it is the one a
404
+ // path-shaped reading of this list would miss, because it names its tenant in a
405
+ // query while every other scope route names it in the path.
406
+ { method: 'GET', re: /\/scopes$/, pin: 'query' },
407
+ { method: 'GET', re: /\/admin-log$/, pin: 'query' },
408
+ { method: 'GET', re: /\/ops-failures$/, pin: 'query' },
409
+ { method: 'GET', re: /\/sweep-runs$/, pin: 'query' },
410
+ { method: 'GET', re: /\/service-refs$/, pin: 'query' },
411
+ { method: 'GET', re: /\/observability\/tenant-metrics$/, pin: 'query' },
412
+ { method: 'GET', re: /\/observability\/tenant-metrics-series$/, pin: 'query' },
413
+ { method: 'GET', re: /\/observability\/tenant-logs$/, pin: 'query' },
414
+ { method: 'GET', re: /\/hostnames$/, pin: 'query' },
415
+ { method: 'POST', re: /\/hostnames$/, pin: 'body' },
416
+ // The registry + install catalog.
417
+ { method: 'GET', re: /\/verticals$/, pin: 'catalog' },
418
+ // Ownership-narrowed: the handler resolves who owns the slug / the hostname.
419
+ // Not listed, because the dashboard does not call them and an unused route on an
420
+ // allowlist is reach nobody asked for: POST /verticals (register), POST
421
+ // /verticals/:slug/versions (publish), .../deploy, .../publish-request. A push is
422
+ // the CLI's act, over a push token, and stays one.
423
+ { method: 'DELETE', re: /\/verticals\/[^/]+$/, pin: 'owner' },
424
+ { method: 'GET', re: /\/verticals\/[^/]+\/versions$/, pin: 'owner' },
425
+ { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/registry$/, pin: 'owner' },
426
+ { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/, pin: 'owner' },
427
+ { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/flow$/, pin: 'owner' },
428
+ { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/assets$/, pin: 'owner' },
429
+ { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/schedules$/, pin: 'owner' },
430
+ { method: 'GET', re: /\/verticals\/[^/]+\/channels$/, pin: 'owner' },
431
+ { method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/history$/, pin: 'owner' },
432
+ { method: 'POST', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote$/, pin: 'owner' },
433
+ { method: 'GET', re: /\/verticals\/[^/]+\/previews$/, pin: 'owner' },
434
+ { method: 'POST', re: /\/verticals\/[^/]+\/previews$/, pin: 'owner' },
435
+ { method: 'DELETE', re: /\/verticals\/[^/]+\/previews\/[^/]+$/, pin: 'owner' },
436
+ { method: 'POST', re: /\/hostnames\/[^/]+\/verify$/, pin: 'owner' },
437
+ { method: 'PATCH', re: /\/hostnames\/[^/]+\/status$/, pin: 'owner' },
438
+ { method: 'DELETE', re: /\/hostnames\/[^/]+$/, pin: 'owner' },
439
+ // Script-grain observability, narrowed in the handler to the services this
440
+ // tenant's own verticals deploy (see `ownedServices`). Without that narrowing
441
+ // these are fleet reads, which is how the dashboard used them over the staff
442
+ // token — filtering the fleet's answer in the browser worker's memory.
443
+ { method: 'GET', re: /\/observability\/metrics$/, pin: 'owner' },
444
+ { method: 'GET', re: /\/observability\/metrics-series$/, pin: 'owner' },
445
+ { method: 'GET', re: /\/observability\/logs$/, pin: 'owner' },
446
+ ];
447
+ /**
448
+ * How a tenant-scoped credential's pin is checked on this route, or `undefined` when
449
+ * the route is off its allowlist entirely (default-deny).
450
+ *
451
+ * Exported so the DASHBOARD's own suite can assert the other direction: that every
452
+ * route its authority seam can issue is one this credential reaches. Nothing else can
453
+ * check that — the allowlist lives here and the call sites live there — and the gap
454
+ * between the two is a 403 in production and a green test on both sides. The first
455
+ * pass of this list was written by reading `authority.ts`, and reading it missed
456
+ * `GET /scopes`, which is the route the Data tab and Move/Retire lean on hardest.
457
+ */
458
+ export function tenantCredentialPin(method, path) {
459
+ return TENANT_ROUTES.find((r) => r.method === method && r.re.test(path))?.pin;
460
+ }
461
+ /** The `/tenants/<id>/…` segment a path names, if it names one. */
462
+ const pathTenant = (path) => /\/tenants\/([^/]+)(?:\/|$)/.exec(path)?.[1];
463
+ /**
464
+ * Every tenant this request names where the middleware can see it — the path
465
+ * segment, the tenant-ish query params, and the `x-substrat-tenant` pin header.
466
+ * The BODY is not swept here: it is read only for a `'body'` route, because parsing
467
+ * it costs a read on every request and only those routes are decided by it.
468
+ */
469
+ const namedTenants = (c) => {
470
+ const named = [
471
+ pathTenant(c.req.path),
472
+ c.req.query('tenantId'),
473
+ c.req.query('ownerTenant'),
474
+ c.req.query('visibleTo'),
475
+ c.req.header(TENANT_HEADER)?.trim() || undefined,
476
+ ];
477
+ return named.filter((v) => typeof v === 'string' && v.length > 0);
478
+ };
359
479
  /**
360
480
  * The audited HTTP surface over `HostAdmin` (control-plane.md §4.5).
361
481
  *
@@ -384,7 +504,7 @@ const UPSTREAM_REFERENCE = /\breference\s*=\s*([a-z0-9]+)/i;
384
504
  * adapter's read path, not an admin surface.
385
505
  */
386
506
  export function createControlPlaneApi(options) {
387
- const { host, authenticate, authenticateBuilder } = options;
507
+ const { host, authenticate, authenticateBuilder, authenticateTenantService } = options;
388
508
  const admin = host.admin;
389
509
  const app = new Hono();
390
510
  // The CLI version advisory (#971), stamped on EVERY response — including the 401 the
@@ -401,11 +521,28 @@ export function createControlPlaneApi(options) {
401
521
  c.header(CLI_LATEST_VERSION_HEADER, advisory.latestVersion);
402
522
  });
403
523
  }
404
- // Fail closed, before any route runs: no principal, no reach. Staff/service first
405
- // (unchanged, a superset); a builder session only when staff declines.
524
+ // Fail closed, before any route runs: no principal, no reach.
525
+ //
526
+ // The TENANT-SCOPED credential is read FIRST (#977), ahead of staff. Not for
527
+ // precedence between two things that could both match — an `stt1.` value is not a
528
+ // platform service token and never will be — but because order is the only thing
529
+ // that makes "a tenant token is never staff" hold no matter how `authenticate` is
530
+ // configured. A deployment that pasted a tenant token into `SERVICE_TOKEN` would,
531
+ // with the readers the other way round, silently hand it the whole fleet.
532
+ //
533
+ // Staff/service next (unchanged, a superset); a builder session only when staff
534
+ // declines.
406
535
  app.use('*', async (c, next) => {
407
- const staff = await authenticate(c.req.raw);
408
- let principal = staff ? { kind: 'staff', actor: staff } : null;
536
+ let principal = null;
537
+ if (authenticateTenantService) {
538
+ const tenant = await authenticateTenantService(c.req.raw);
539
+ if (tenant) {
540
+ principal = { kind: 'tenant', actor: tenant.actor, tenantId: tenant.tenantId };
541
+ }
542
+ }
543
+ const staff = principal ? null : await authenticate(c.req.raw);
544
+ if (!principal && staff)
545
+ principal = { kind: 'staff', actor: staff };
409
546
  if (!principal && authenticateBuilder) {
410
547
  const builder = await authenticateBuilder(c.req.raw);
411
548
  if (builder) {
@@ -518,6 +655,45 @@ export function createControlPlaneApi(options) {
518
655
  }
519
656
  await next();
520
657
  });
658
+ app.use('*', async (c, next) => {
659
+ const p = c.get('principal');
660
+ if (p.kind !== 'tenant')
661
+ return next();
662
+ const pin = tenantCredentialPin(c.req.method, c.req.path);
663
+ if (!pin)
664
+ return c.json({ error: 'forbidden' }, 403);
665
+ // The sweep: anything naming a tenant must name THIS one. `x-substrat-tenant` is
666
+ // included, which is what stops the pre-#977 shape — a fleet credential plus a
667
+ // caller-asserted tenant header — from surviving as a way to ask for another.
668
+ if (namedTenants(c).some((t) => t !== p.tenantId)) {
669
+ return c.json({ error: 'forbidden: outside this credential’s tenant' }, 403);
670
+ }
671
+ // The route's own pin must be PRESENT, not merely consistent. A missing filter is
672
+ // what makes these routes answer fleet-wide, so absence is the refusal.
673
+ if (pin === 'path' && pathTenant(c.req.path) !== p.tenantId) {
674
+ return c.json({ error: 'forbidden: outside this credential’s tenant' }, 403);
675
+ }
676
+ if (pin === 'query' && c.req.query('tenantId') !== p.tenantId) {
677
+ return c.json({ error: 'forbidden: this credential must name its own tenant' }, 403);
678
+ }
679
+ if (pin === 'catalog') {
680
+ const named = c.req.query('ownerTenant') ?? c.req.query('visibleTo');
681
+ if (named !== p.tenantId) {
682
+ return c.json({ error: 'forbidden: this credential must name its own tenant' }, 403);
683
+ }
684
+ }
685
+ if (pin === 'body') {
686
+ // Hono caches a parsed body, so the handler's own `c.req.json()` still works.
687
+ // An unparseable body names no tenant, which is a refusal here rather than the
688
+ // handler's 400 — a request this middleware cannot read is one it cannot clear.
689
+ const body = (await c.req.json().catch(() => null));
690
+ const named = body && typeof body === 'object' ? (body['tenantId'] ?? body['id']) : undefined;
691
+ if (named !== p.tenantId) {
692
+ return c.json({ error: 'forbidden: this credential must name its own tenant' }, 403);
693
+ }
694
+ }
695
+ await next();
696
+ });
521
697
  // Fire-and-forget ops-failure write (#559): a recorder that throws must never
522
698
  // mask the failure it is recording, so every path through this swallows its own
523
699
  // errors. The upstream reference is extracted here — one place — so a caller
@@ -742,6 +918,50 @@ export function createControlPlaneApi(options) {
742
918
  const tenantId = raw ? tenantIdSchema.parse(raw) : undefined;
743
919
  return c.json(await admin.readMeters(c.get('actor'), tenantId ? { tenantId } : undefined));
744
920
  });
921
+ // Storage, read on demand (#1524): one tenant's scope-database sizes and their sum.
922
+ // Staff-only like `/meters` (on neither allowlist, so a builder or a tenant token is
923
+ // refused). `tenantId` is REQUIRED. A fleet-wide form would wake every DO in the fleet,
924
+ // the cost the decision rejected. Paged over the tenant's scopes, because every scope
925
+ // read is a DO wake. `readStoragePage` holds the page and concurrency bounds.
926
+ //
927
+ // A scope that names a vertical is read through that vertical's deployment, which
928
+ // holds its DO. When this plane delegates to verticals at all and none resolves, the
929
+ // read FAILS for that scope rather than falling back to the co-located host. On the
930
+ // hosted plane that host's namespace is a module-less placeholder, and waking it would
931
+ // create an empty database and report its size as the scope's.
932
+ const delegatesToVerticals = Boolean(options.verticals || options.resolveVertical || options.resolveVerticalVersion || options.resolveVerticalRef);
933
+ app.get('/meters/storage', async (c) => {
934
+ const q = z
935
+ .object({
936
+ tenantId: tenantIdSchema,
937
+ cursor: scopeIdSchema.optional(),
938
+ limit: z.coerce.number().int().min(1).max(STORAGE_PAGE_MAX).default(STORAGE_PAGE_DEFAULT),
939
+ })
940
+ .parse({
941
+ tenantId: c.req.query('tenantId'),
942
+ cursor: c.req.query('cursor') || undefined,
943
+ limit: c.req.query('limit') || undefined,
944
+ });
945
+ const actor = c.get('actor');
946
+ const scopes = await admin.listScopes(actor, { tenantId: q.tenantId });
947
+ return c.json(await readStoragePage({
948
+ tenantId: q.tenantId,
949
+ readAt: new Date().toISOString(),
950
+ scopes: scopes.map((s) => ({ ...s, scopeId: s.id })),
951
+ cursor: q.cursor,
952
+ limit: q.limit,
953
+ read: async (scope) => {
954
+ if (!scope.vertical)
955
+ return admin.scopeDatabaseSize(actor, q.tenantId, scope.id);
956
+ const vertical = await verticalForScope(c, scope);
957
+ if (vertical)
958
+ return vertical.databaseSize(scope.id);
959
+ if (delegatesToVerticals)
960
+ throw new Error(`no deployment resolves for vertical ${scope.vertical}`);
961
+ return admin.scopeDatabaseSize(actor, q.tenantId, scope.id);
962
+ },
963
+ }));
964
+ });
745
965
  // -- per-tenant stores (#301, #473) ----------------------------------------
746
966
  // The two ledgers as INVENTORY — what `listTenantStores`/`listBlobStores` were always
747
967
  // meant to answer for a staff surface: which database and which bucket hold this
@@ -945,7 +1165,7 @@ export function createControlPlaneApi(options) {
945
1165
  const filter = listScopesQuery.parse({
946
1166
  // A builder reads only its OWN tenant's directory rows — the filter is forced,
947
1167
  // not trusted from the query (#424 CLI parity for `substrat installs`).
948
- tenantId: p.kind === 'builder' ? p.tenantId : c.req.query('tenantId'),
1168
+ tenantId: confinedTenant(p) ?? c.req.query('tenantId'),
949
1169
  status: c.req.queries('status'),
950
1170
  vertical: c.req.query('vertical'),
951
1171
  });
@@ -993,7 +1213,7 @@ export function createControlPlaneApi(options) {
993
1213
  const p = c.get('principal');
994
1214
  // A builder acts only within its own tenant; a foreign tenant is hidden as 404 (K-3),
995
1215
  // never distinguished from "no such scope".
996
- if (p.kind === 'builder' && p.tenantId !== tenantId) {
1216
+ if (outsideTenant(p, tenantId)) {
997
1217
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${input.parentScopeId})` }, 404);
998
1218
  }
999
1219
  // The provisioning sequence lives in one place — `provisionSiblingScope` — shared with the
@@ -1022,7 +1242,7 @@ export function createControlPlaneApi(options) {
1022
1242
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1023
1243
  // A builder reads only its own tenant; a foreign tenant is hidden as 404 (K-3).
1024
1244
  const p = c.get('principal');
1025
- if (p.kind === 'builder' && p.tenantId !== tenantId) {
1245
+ if (outsideTenant(p, tenantId)) {
1026
1246
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1027
1247
  }
1028
1248
  const record = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
@@ -1058,7 +1278,18 @@ export function createControlPlaneApi(options) {
1058
1278
  seal: (items) => admin.sealSubjectPayloads(c.get('actor'), tenantId, scopeId, items),
1059
1279
  open: (items) => admin.openSubjectPayloads(c.get('actor'), tenantId, scopeId, items),
1060
1280
  });
1061
- const verticalForScope = async (c, scope) => {
1281
+ const verticalForScope = async (c, scope) => (await deploymentForScope(c, scope))?.client;
1282
+ /**
1283
+ * `verticalForScope`, saying WHICH rung of the ladder chose the client (#1653).
1284
+ *
1285
+ * A reconcile's receipt must name the version whose hook actually ran, and that is a
1286
+ * fact about the client chosen here — not about the scope's serving pointer read on the
1287
+ * side. When the serving ref does not resolve, the ladder falls back to the bound
1288
+ * version's deployment (or a slug's), and a receipt derived separately would claim the
1289
+ * served version's hook ran when the bound one's did. `versionReachedAt(via, …)` turns
1290
+ * this answer into the version, so the choice and the receipt have one source.
1291
+ */
1292
+ const deploymentForScope = async (c, scope) => {
1062
1293
  if (!scope.vertical)
1063
1294
  return undefined;
1064
1295
  const actor = c.get('actor');
@@ -1067,15 +1298,16 @@ export function createControlPlaneApi(options) {
1067
1298
  if (scope.servingRef && options.resolveVerticalRef) {
1068
1299
  const serving = await options.resolveVerticalRef(scope.servingRef);
1069
1300
  if (serving)
1070
- return serving;
1301
+ return { client: serving, via: 'serving-script' };
1071
1302
  }
1072
1303
  const bySlug = async (slug) => {
1073
1304
  if (scope.verticalVersionId && options.resolveVerticalVersion) {
1074
1305
  const bound = await options.resolveVerticalVersion(slug, scope.verticalVersionId, actor);
1075
1306
  if (bound)
1076
- return bound;
1307
+ return { client: bound, via: 'bound-version' };
1077
1308
  }
1078
- return options.verticals?.[slug] ?? (await options.resolveVertical?.(slug, actor));
1309
+ const bySlugClient = options.verticals?.[slug] ?? (await options.resolveVertical?.(slug, actor));
1310
+ return bySlugClient ? { client: bySlugClient, via: 'slug' } : undefined;
1079
1311
  };
1080
1312
  const direct = await bySlug(scope.vertical);
1081
1313
  if (direct)
@@ -1311,7 +1543,7 @@ export function createControlPlaneApi(options) {
1311
1543
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1312
1544
  // A builder reads only its own tenant; a foreign tenant is hidden as 404 (K-3).
1313
1545
  const principal = c.get('principal');
1314
- if (principal.kind === 'builder' && principal.tenantId !== tenantId) {
1546
+ if (outsideTenant(principal, tenantId)) {
1315
1547
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1316
1548
  }
1317
1549
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
@@ -1363,7 +1595,7 @@ export function createControlPlaneApi(options) {
1363
1595
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1364
1596
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1365
1597
  const principal = c.get('principal');
1366
- if (principal.kind === 'builder' && principal.tenantId !== tenantId) {
1598
+ if (outsideTenant(principal, tenantId)) {
1367
1599
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1368
1600
  }
1369
1601
  const filter = platformRequestFilter.parse({
@@ -1396,14 +1628,16 @@ export function createControlPlaneApi(options) {
1396
1628
  if (!scope)
1397
1629
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1398
1630
  const p = c.get('principal');
1399
- if (p.kind === 'builder') {
1631
+ const scopePin = confinedTenant(p);
1632
+ if (scopePin) {
1400
1633
  const v = scope.vertical ? await verticalOf(actor, scope.vertical) : undefined;
1401
- if (!v || v.ownerTenant !== p.tenantId)
1634
+ if (!v || v.ownerTenant !== scopePin)
1402
1635
  return c.json({ error: 'forbidden' }, 403);
1403
1636
  }
1404
- const vertical = await verticalForScope(c, scope);
1405
- if (!vertical)
1637
+ const reached = await deploymentForScope(c, scope);
1638
+ if (!reached)
1406
1639
  return c.json({ error: await diagnoseUnboundScope(c.get('actor'), scope) }, 501);
1640
+ const vertical = reached.client;
1407
1641
  const entitlements = await admin.listEntitlements(actor, tenantId);
1408
1642
  // #406: re-gathered and re-delivered like entitlements, so a reconcile also repairs a
1409
1643
  // dropped identity-link delivery — and is the channel a link/unlink after provision rides.
@@ -1502,6 +1736,17 @@ export function createControlPlaneApi(options) {
1502
1736
  patchBindings: options.patchScriptBindings,
1503
1737
  }));
1504
1738
  }
1739
+ // #1653: WHICH version the hook is about to run as — the version of the deployment
1740
+ // `reached` IS, never one read on the side: a serving ref that did not resolve fell back
1741
+ // to the bound version's deployment, and it is that version's hook that runs. For the
1742
+ // serving script, the pointer is read BEFORE the call, as the sweep reads it, so a
1743
+ // promote landing mid-call cannot have its version recorded by a hook that ran as the
1744
+ // one before. Best-effort: without it the receipt falls back to the bound version, the
1745
+ // pre-#1653 answer, which costs at most one more reconcile.
1746
+ const serving = reached.via === 'serving-script' && scope.vertical
1747
+ ? await admin.verticalServing(actor, scope.vertical).catch(() => null)
1748
+ : null;
1749
+ const ranAs = versionReachedAt(reached.via, scope, serving);
1505
1750
  try {
1506
1751
  const result = await vertical.reconcileInstance({
1507
1752
  tenantId,
@@ -1515,15 +1760,16 @@ export function createControlPlaneApi(options) {
1515
1760
  // but unmigrated database fails as loudly as an absent one.
1516
1761
  ...(tenantStores.length ? { tenantStores } : {}),
1517
1762
  });
1518
- // The reconcile succeeded; a store that did not mint rides back as a diagnosis
1519
- // (`substrat scope provision` prints it, and `/health` keeps reporting the gap
1520
1763
  // #1172: the reconcile succeeded, so record WHICH version it ran against. Without
1521
1764
  // this the sweep would come back and do it again on the next pass — the console
1522
1765
  // button and the automatic phase have to write the same receipt, or pressing the
1523
- // button means nothing to the thing that watches.
1524
- if (scope.verticalVersionId) {
1525
- await admin.markScopeProvisioned(actor, tenantId, scopeId, scope.verticalVersionId);
1766
+ // button means nothing to the thing that watches. Both name it with
1767
+ // `versionReachedAt`, from the deployment each actually reached.
1768
+ if (ranAs) {
1769
+ await admin.markScopeProvisioned(actor, tenantId, scopeId, ranAs);
1526
1770
  }
1771
+ // The reconcile succeeded; a store that did not mint rides back as a diagnosis
1772
+ // (`substrat scope provision` prints it, and `/health` keeps reporting the gap
1527
1773
  // until it closes). Absent when nothing failed, so the response is unchanged for
1528
1774
  // every scope that has no declared store or already has them all.
1529
1775
  return c.json(storeErrors.length ? { ...result, storeError: storeErrors.join('; ') } : result);
@@ -1749,7 +1995,7 @@ export function createControlPlaneApi(options) {
1749
1995
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1750
1996
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1751
1997
  const principal = c.get('principal');
1752
- if (principal.kind === 'builder' && principal.tenantId !== tenantId) {
1998
+ if (outsideTenant(principal, tenantId)) {
1753
1999
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1754
2000
  }
1755
2001
  const filter = denialLogQuery.parse(c.req.query());
@@ -1765,7 +2011,7 @@ export function createControlPlaneApi(options) {
1765
2011
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1766
2012
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1767
2013
  const principal = c.get('principal');
1768
- if (principal.kind === 'builder' && principal.tenantId !== tenantId) {
2014
+ if (outsideTenant(principal, tenantId)) {
1769
2015
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1770
2016
  }
1771
2017
  const filter = denialLogQuery.parse(c.req.query());
@@ -1788,7 +2034,7 @@ export function createControlPlaneApi(options) {
1788
2034
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1789
2035
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1790
2036
  const principal = c.get('principal');
1791
- if (principal.kind === 'builder' && principal.tenantId !== tenantId) {
2037
+ if (outsideTenant(principal, tenantId)) {
1792
2038
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1793
2039
  }
1794
2040
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
@@ -1810,7 +2056,7 @@ export function createControlPlaneApi(options) {
1810
2056
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1811
2057
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1812
2058
  const principal = c.get('principal');
1813
- if (principal.kind === 'builder' && principal.tenantId !== tenantId) {
2059
+ if (outsideTenant(principal, tenantId)) {
1814
2060
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1815
2061
  }
1816
2062
  const actor = c.get('actor');
@@ -2666,9 +2912,10 @@ export function createControlPlaneApi(options) {
2666
2912
  const p = c.get('principal');
2667
2913
  const slug = await resolveVerticalId(c, c.req.param('slug'));
2668
2914
  const actor = c.get('actor');
2669
- if (p.kind === 'builder') {
2915
+ const adoptPin = confinedTenant(p);
2916
+ if (adoptPin) {
2670
2917
  const v = await verticalOf(actor, slug);
2671
- if (!v || v.ownerTenant !== p.tenantId)
2918
+ if (!v || v.ownerTenant !== adoptPin)
2672
2919
  return c.json({ error: 'forbidden' }, 403);
2673
2920
  }
2674
2921
  const v = await verticalOf(actor, slug);
@@ -2902,6 +3149,19 @@ export function createControlPlaneApi(options) {
2902
3149
  // vertical is PRIVATE (owned + not listed), which is what scopes a builder's prod
2903
3150
  // self-serve below.
2904
3151
  const verticalOf = async (actor, slug) => (await admin.listVerticals(actor)).find((x) => x.slug === slug);
3152
+ /**
3153
+ * "This principal is confined, and this vertical is not its tenant's." The one
3154
+ * predicate every slug-addressed route asks.
3155
+ *
3156
+ * It is `confinedTenant`, not `kind === 'builder'`, because the question is about
3157
+ * confinement and never about which KIND of credential is confined — the dashboard's
3158
+ * tenant token (#977) is confined too, and a route asking the narrower question
3159
+ * would hand it the fleet. Staff are unconfined and always pass.
3160
+ */
3161
+ const notOwned = async (p, slug) => {
3162
+ const pin = confinedTenant(p);
3163
+ return pin !== null && (await ownerOf(p.actor, slug)) !== pin;
3164
+ };
2905
3165
  // The vertical id a request actually addresses. For a BUILDER it is `<tenantSlug>/<name>`
2906
3166
  // (builder-plane.md §5): they send a bare `--slug`, the control plane forms the prefix
2907
3167
  // from their authenticated tenant — so two builders can each own a `helpdesk` with no
@@ -2970,33 +3230,22 @@ export function createControlPlaneApi(options) {
2970
3230
  // release-health and issues views) whether or not a telemetry backend is
2971
3231
  // configured. The dashboard's ownedServiceRefs() fetches this instead of
2972
3232
  // re-deriving it, so every consumer means the same thing by `version`.
2973
- app.get('/service-refs', async (c) => {
2974
- const p = c.get('principal');
2975
- // The /ops-failures forced-filter pattern: a builder's tenant comes from the
2976
- // principal, never the query. A staff/service caller must SAY whose view it
2977
- // wants — answering fleet-wide on a forgotten param would leak, so refuse.
2978
- const tenantId = p.kind === 'builder' ? p.tenantId : c.req.query('tenantId');
2979
- if (!tenantId)
2980
- throw new ControlPlaneError(400, 'tenantId is required');
2981
- const query = z
2982
- .object({ tenantId: tenantIdSchema, vertical: z.string().optional() })
2983
- .parse({ tenantId, vertical: c.req.query('vertical') });
2984
- const actor = c.get('actor');
2985
- const all = (await admin.listVerticals(actor)).filter((v) => v.ownerTenant === query.tenantId);
2986
- let considered = all;
2987
- if (query.vertical !== undefined) {
2988
- const slug = await resolveVerticalId(c, query.vertical);
2989
- considered = all.filter((v) => v.slug === slug);
2990
- // Non-ownership reads as absence (K-3), exactly like the per-slug registry reads.
2991
- if (considered.length === 0)
2992
- throw new ControlPlaneError(404, 'not found');
2993
- }
2994
- // Cap the per-vertical fan-out and REPORT the cut — truncating silently is the
2995
- // defect the egress route names (#859), and the same posture holds here.
2996
- const MAX_VERTICALS = 50;
2997
- const verticalsTruncated = considered.length > MAX_VERTICALS;
3233
+ // Cap the per-vertical fan-out and REPORT the cut — truncating silently is the
3234
+ // defect the egress route names (#859), and the same posture holds here.
3235
+ const MAX_SERVICE_REF_VERTICALS = 50;
3236
+ /**
3237
+ * Every Cloudflare service ref a tenant's own verticals deploy, mapped to the
3238
+ * signals dimensions it stamps. The ONE implementation of "what a service ref
3239
+ * means", and now also of "which service refs are this tenant's" — the two are the
3240
+ * same enumeration, and #977's observability narrowing must not be a second copy of
3241
+ * it that can drift when a third ref scheme appears.
3242
+ */
3243
+ const tenantServiceRefs = async (actor, tenantId, slug) => {
3244
+ const all = (await admin.listVerticals(actor)).filter((v) => v.ownerTenant === tenantId);
3245
+ const considered = slug === undefined ? all : all.filter((v) => v.slug === slug);
3246
+ const verticalsTruncated = considered.length > MAX_SERVICE_REF_VERTICALS;
2998
3247
  const entries = [];
2999
- for (const v of considered.slice(0, MAX_VERTICALS)) {
3248
+ for (const v of considered.slice(0, MAX_SERVICE_REF_VERTICALS)) {
3000
3249
  const versions = await admin.listVersions(actor, v.slug);
3001
3250
  const labelOf = new Map(versions.map((ver) => [ver.id, ver.version]));
3002
3251
  // The stable serving script — where real traffic lands. Its version is the
@@ -3022,6 +3271,44 @@ export function createControlPlaneApi(options) {
3022
3271
  }));
3023
3272
  }
3024
3273
  }
3274
+ return { entries, verticalsTruncated, matched: considered.length };
3275
+ };
3276
+ /**
3277
+ * The service names a CONFINED principal may see telemetry for — its tenant's own,
3278
+ * and nothing else (#977).
3279
+ *
3280
+ * `null` means unconfined (staff): no narrowing, the fleet view the console reads.
3281
+ * A truncated enumeration throws rather than narrowing to a partial set: a chart
3282
+ * missing a vertical reads as "that vertical had no traffic", and inventing silence
3283
+ * is the one answer an observability route must never give.
3284
+ */
3285
+ const ownedServices = async (p, actor) => {
3286
+ const pin = confinedTenant(p);
3287
+ if (pin === null)
3288
+ return null;
3289
+ const refs = await tenantServiceRefs(actor, pin);
3290
+ if (refs.verticalsTruncated) {
3291
+ throw new ControlPlaneError(503, 'service map truncated: too many verticals to resolve in one answer');
3292
+ }
3293
+ return new Set(refs.entries.map((e) => e.service));
3294
+ };
3295
+ app.get('/service-refs', async (c) => {
3296
+ const p = c.get('principal');
3297
+ // The /ops-failures forced-filter pattern: a confined principal's tenant comes from
3298
+ // the principal, never the query. A staff/service caller must SAY whose view it
3299
+ // wants — answering fleet-wide on a forgotten param would leak, so refuse.
3300
+ const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
3301
+ if (!tenantId)
3302
+ throw new ControlPlaneError(400, 'tenantId is required');
3303
+ const query = z
3304
+ .object({ tenantId: tenantIdSchema, vertical: z.string().optional() })
3305
+ .parse({ tenantId, vertical: c.req.query('vertical') });
3306
+ const actor = c.get('actor');
3307
+ const slug = query.vertical === undefined ? undefined : await resolveVerticalId(c, query.vertical);
3308
+ const { entries, verticalsTruncated, matched } = await tenantServiceRefs(actor, query.tenantId, slug);
3309
+ // Non-ownership reads as absence (K-3), exactly like the per-slug registry reads.
3310
+ if (slug !== undefined && matched === 0)
3311
+ throw new ControlPlaneError(404, 'not found');
3025
3312
  return c.json({ entries, verticalsTruncated });
3026
3313
  });
3027
3314
  app.post('/verticals', async (c) => {
@@ -3049,7 +3336,7 @@ export function createControlPlaneApi(options) {
3049
3336
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3050
3337
  // A builder reading a vertical it does not own gets 404 — indistinguishable from
3051
3338
  // absent, the same fail-closed reflex K-3 uses for a cross-tenant scope.
3052
- if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
3339
+ if (await notOwned(p, slug)) {
3053
3340
  return c.json({ error: 'not found' }, 404);
3054
3341
  }
3055
3342
  const page = pageParams(c);
@@ -3066,7 +3353,7 @@ export function createControlPlaneApi(options) {
3066
3353
  }
3067
3354
  const p = c.get('principal');
3068
3355
  const slug = await resolveVerticalId(c, input.verticalSlug);
3069
- if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
3356
+ if (await notOwned(p, slug)) {
3070
3357
  return c.json({ error: 'forbidden' }, 403);
3071
3358
  }
3072
3359
  input.verticalSlug = slug;
@@ -3083,7 +3370,7 @@ export function createControlPlaneApi(options) {
3083
3370
  app.get('/verticals/:slug/versions/:id/registry', async (c) => {
3084
3371
  const p = c.get('principal');
3085
3372
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3086
- if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
3373
+ if (await notOwned(p, slug)) {
3087
3374
  return c.json({ error: 'not found' }, 404);
3088
3375
  }
3089
3376
  const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
@@ -3100,7 +3387,7 @@ export function createControlPlaneApi(options) {
3100
3387
  app.get('/verticals/:slug/versions/:id/schedules', async (c) => {
3101
3388
  const p = c.get('principal');
3102
3389
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3103
- if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
3390
+ if (await notOwned(p, slug)) {
3104
3391
  return c.json({ error: 'not found' }, 404);
3105
3392
  }
3106
3393
  const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
@@ -3123,7 +3410,7 @@ export function createControlPlaneApi(options) {
3123
3410
  app.get('/verticals/:slug/versions/:id/flow', async (c) => {
3124
3411
  const p = c.get('principal');
3125
3412
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3126
- if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
3413
+ if (await notOwned(p, slug)) {
3127
3414
  return c.json({ error: 'not found' }, 404);
3128
3415
  }
3129
3416
  const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
@@ -3145,7 +3432,7 @@ export function createControlPlaneApi(options) {
3145
3432
  app.get('/verticals/:slug/versions/:id/model', async (c) => {
3146
3433
  const p = c.get('principal');
3147
3434
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3148
- if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
3435
+ if (await notOwned(p, slug)) {
3149
3436
  return c.json({ error: 'not found' }, 404);
3150
3437
  }
3151
3438
  const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
@@ -3164,7 +3451,7 @@ export function createControlPlaneApi(options) {
3164
3451
  app.get('/verticals/:slug/versions/:id/assets', async (c) => {
3165
3452
  const p = c.get('principal');
3166
3453
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3167
- if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
3454
+ if (await notOwned(p, slug)) {
3168
3455
  return c.json({ error: 'not found' }, 404);
3169
3456
  }
3170
3457
  const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
@@ -3189,7 +3476,7 @@ export function createControlPlaneApi(options) {
3189
3476
  app.post('/verticals/:slug/publish-request', async (c) => {
3190
3477
  const p = c.get('principal');
3191
3478
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3192
- if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
3479
+ if (await notOwned(p, slug)) {
3193
3480
  return c.json({ error: 'not found' }, 404);
3194
3481
  }
3195
3482
  await admin.requestPublish(c.get('actor'), slug);
@@ -3233,18 +3520,27 @@ export function createControlPlaneApi(options) {
3233
3520
  await admin.setVerticalEmailSender(c.get('actor'), slug, granted);
3234
3521
  return c.json({ slug, emailSender: granted });
3235
3522
  });
3236
- // Delete a vertical + its versions and channels (staff-only, same confinement).
3237
- // Refused below the seam while any scope is still bound — surfaces as a 4xx via
3238
- // mapError, naming the count. Dispatch scripts become orphans for cleanup (#248).
3523
+ // Delete a vertical + its versions and channels. Refused below the seam while any
3524
+ // scope is still bound — surfaces as a 4xx via mapError, naming the count. Dispatch
3525
+ // scripts become orphans for cleanup (#248).
3526
+ //
3527
+ // A CONFINED principal deletes only what its tenant owns (#977). This route had no
3528
+ // ownership check at all, because it was staff-only and the dashboard — which calls
3529
+ // it — checked ownership in its own worker before asking. That check was the whole
3530
+ // guard on a destructive registry write, and it lived in the caller.
3239
3531
  app.delete('/verticals/:slug', async (c) => {
3240
3532
  const slug = c.req.param('slug');
3533
+ // 404, not 403: a slug this tenant does not own reads as absent, the same
3534
+ // existence-hiding reflex the registry's other per-slug reads use (K-3).
3535
+ if (await notOwned(c.get('principal'), slug))
3536
+ return c.json({ error: 'not found' }, 404);
3241
3537
  await admin.deleteVertical(c.get('actor'), slug);
3242
3538
  return c.json({ slug, deleted: true });
3243
3539
  });
3244
3540
  app.get('/verticals/:slug/channels', async (c) => {
3245
3541
  const p = c.get('principal');
3246
3542
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3247
- if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
3543
+ if (await notOwned(p, slug)) {
3248
3544
  return c.json({ error: 'not found' }, 404);
3249
3545
  }
3250
3546
  const page = pageParams(c);
@@ -3262,7 +3558,7 @@ export function createControlPlaneApi(options) {
3262
3558
  return c.json({ error: `channel '${rawChannel}' is retired — only 'prod' has history` }, 400);
3263
3559
  }
3264
3560
  const channel = channelName.parse(rawChannel);
3265
- if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
3561
+ if (await notOwned(p, slug)) {
3266
3562
  return c.json({ error: 'not found' }, 404);
3267
3563
  }
3268
3564
  const page = pageParams(c);
@@ -3432,17 +3728,25 @@ export function createControlPlaneApi(options) {
3432
3728
  }, 400);
3433
3729
  }
3434
3730
  const channel = channelName.parse(rawChannel);
3435
- if (p.kind === 'builder') {
3436
- // A builder promotes only verticals it owns — and prod only while the vertical
3437
- // is PRIVATE (not listed). A private vertical's blast radius is the owning
3438
- // tenant itself, and dev/staging already run the same bundle in the same
3439
- // sandbox, so a staff prod gate there protected nothing; it returns the moment
3440
- // the audience widens (publish flips `listed`, and prod becomes staff-only
3441
- // again — the trust boundary marketplace-publish.md §2 draws).
3731
+ const promotePin = confinedTenant(p);
3732
+ if (promotePin) {
3733
+ // A confined principal promotes only verticals its tenant owns.
3442
3734
  const v = await verticalOf(p.actor, slug);
3443
- if (!v || v.ownerTenant !== p.tenantId)
3735
+ if (!v || v.ownerTenant !== promotePin)
3444
3736
  return c.json({ error: 'forbidden' }, 403);
3445
- if (channel === 'prod' && v.listed) {
3737
+ // …and a BUILDER promotes prod only while the vertical is PRIVATE (not listed).
3738
+ // A private vertical's blast radius is the owning tenant itself, and dev/staging
3739
+ // already run the same bundle in the same sandbox, so a staff prod gate there
3740
+ // protected nothing; it returns the moment the audience widens (publish flips
3741
+ // `listed`, and prod becomes staff-only again — the trust boundary
3742
+ // marketplace-publish.md §2 draws).
3743
+ //
3744
+ // Deliberately NOT widened to #977's tenant credential: promoting a vertical the
3745
+ // tenant has published is something the dashboard could already do over the
3746
+ // service token, and this issue narrows WHOSE data a credential reaches, not what
3747
+ // a tenant may do inside its own. Taking the gate away from them here would be a
3748
+ // capability change hidden in a security fix.
3749
+ if (p.kind === 'builder' && channel === 'prod' && v.listed) {
3446
3750
  return c.json({ error: 'promotion to prod is staff-only for a listed vertical' }, 403);
3447
3751
  }
3448
3752
  }
@@ -3491,7 +3795,8 @@ export function createControlPlaneApi(options) {
3491
3795
  // installed their vertical. The rest is a count, which tells them the fleet was covered
3492
3796
  // without naming who is in it.
3493
3797
  const p2 = c.get('principal');
3494
- const visible = p2.kind === 'builder' ? backfill.minted.filter((m) => m.tenantId === p2.tenantId) : backfill.minted;
3798
+ const reportPin = confinedTenant(p2);
3799
+ const visible = reportPin ? backfill.minted.filter((m) => m.tenantId === reportPin) : backfill.minted;
3495
3800
  const otherTenants = new Set(backfill.minted.filter((m) => !visible.includes(m)).map((m) => m.tenantId)).size;
3496
3801
  return c.json({
3497
3802
  ...promoted,
@@ -3837,10 +4142,18 @@ export function createControlPlaneApi(options) {
3837
4142
  return c.json(namespacesForScript(await options.doNamespaces.list(), script));
3838
4143
  });
3839
4144
  // -- observability (design/observability.md §4.1) --------------------------
3840
- // Proxied Cloudflare-native reads: the console's fleet view and (later, owner-
3841
- // narrowed) the dashboard's builder view. STAFF-ONLY — not in BUILDER_ROUTES; see
3842
- // the option's doc for why. Tier-3 numbers (master-plan §5.3): sampled, approximate,
4145
+ // Proxied Cloudflare-native reads at SCRIPT grain: one deployed unit, which serves
4146
+ // every tenant that installed the vertical. Not in BUILDER_ROUTES — a builder reads
4147
+ // the tenant grain below. Tier-3 numbers (master-plan §5.3): sampled, approximate,
3843
4148
  // never money.
4149
+ //
4150
+ // For a CONFINED principal (#977) the answer is narrowed to the services its own
4151
+ // tenant's verticals deploy, HERE. These three were the sharpest edge of this issue:
4152
+ // they are fleet reads by construction, `/observability/metrics` takes no filter at
4153
+ // all, and the dashboard read them over the staff token and dropped other tenants'
4154
+ // rows in its own worker. So every one of the fleet's numbers crossed the seam and
4155
+ // the narrowing was a `.filter()` in the caller — the exact shape the issue is about,
4156
+ // on the route where a mistake is least visible.
3844
4157
  app.get('/observability/metrics', async (c) => {
3845
4158
  if (!options.observability) {
3846
4159
  return c.json({ error: 'observability is not configured on this control plane' }, 501);
@@ -3848,7 +4161,12 @@ export function createControlPlaneApi(options) {
3848
4161
  const { hours } = z
3849
4162
  .object({ hours: z.coerce.number().int().min(1).max(72).default(24) })
3850
4163
  .parse({ hours: c.req.query('hours') });
3851
- return c.json(await options.observability.serviceMetrics({ hours }));
4164
+ const owned = await ownedServices(c.get('principal'), c.get('actor'));
4165
+ const rows = await options.observability.serviceMetrics({ hours });
4166
+ // The route takes no service filter, so the narrowing is on the way OUT. It is the
4167
+ // last step before the response either way: a row this tenant does not own must not
4168
+ // leave the plane, whatever the backend was asked.
4169
+ return c.json(owned ? rows.filter((r) => owned.has(r.service)) : rows);
3852
4170
  });
3853
4171
  // The bucketed twin (#1236): the series a chart plots, with the deploy markers
3854
4172
  // drawn on top by the caller (push instants are registry facts, not telemetry).
@@ -3863,7 +4181,19 @@ export function createControlPlaneApi(options) {
3863
4181
  // Repeats like /observability/logs' `service`: a caller narrows to the handful
3864
4182
  // it will plot, which is what keeps scripts × buckets bounded.
3865
4183
  const services = c.req.queries('service') ?? undefined;
3866
- return c.json(await options.observability.serviceMetricsSeries({ hours, services }));
4184
+ const owned = await ownedServices(c.get('principal'), c.get('actor'));
4185
+ if (!owned)
4186
+ return c.json(await options.observability.serviceMetricsSeries({ hours, services }));
4187
+ // A confined principal plots its own services: what it asked for, intersected —
4188
+ // and its whole set when it asked for none, since an unfiltered ask here means
4189
+ // "everything I may see", never "everything there is". Asking for nothing but
4190
+ // foreign refs is an empty series, indistinguishable from services with no
4191
+ // traffic (existence hiding, K-3).
4192
+ const narrowed = (services ?? [...owned]).filter((s) => owned.has(s));
4193
+ if (narrowed.length === 0)
4194
+ return c.json([]);
4195
+ const rows = await options.observability.serviceMetricsSeries({ hours, services: narrowed });
4196
+ return c.json(rows.filter((r) => owned.has(r.service)));
3867
4197
  });
3868
4198
  app.get('/observability/logs', async (c) => {
3869
4199
  if (!options.observability) {
@@ -3872,7 +4202,14 @@ export function createControlPlaneApi(options) {
3872
4202
  // `service` repeats: one deployed unit per param, so a caller can ask for a
3873
4203
  // vertical's whole set (the dashboard's "all versions") in one query and get one
3874
4204
  // merged stream back. Capped because each extra service is another backend query.
3875
- const services = (c.req.queries('service') ?? []).filter((s) => s.length > 0);
4205
+ const asked = (c.req.queries('service') ?? []).filter((s) => s.length > 0);
4206
+ const ownedLogServices = await ownedServices(c.get('principal'), c.get('actor'));
4207
+ // A confined principal reads only its own services' lines. An ask that names none
4208
+ // of them answers `[]` rather than the fleet's stream — and an ask naming NOTHING
4209
+ // is one such ask, because for this caller "no filter" cannot mean "everything".
4210
+ const services = ownedLogServices ? asked.filter((s) => ownedLogServices.has(s)) : asked;
4211
+ if (ownedLogServices && services.length === 0)
4212
+ return c.json([]);
3876
4213
  const input = z
3877
4214
  .object({
3878
4215
  services: z.array(z.string().min(1).max(200)).max(20).optional(),
@@ -3909,7 +4246,7 @@ export function createControlPlaneApi(options) {
3909
4246
  return c.json({ error: 'tenant-grain observability is not configured on this control plane' }, 501);
3910
4247
  }
3911
4248
  const p = c.get('principal');
3912
- const tenantId = p.kind === 'builder' ? p.tenantId : c.req.query('tenantId');
4249
+ const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
3913
4250
  if (!tenantId)
3914
4251
  throw new ControlPlaneError(400, 'tenantId is required');
3915
4252
  const input = z
@@ -3940,7 +4277,7 @@ export function createControlPlaneApi(options) {
3940
4277
  return c.json({ error: 'bucketed tenant-grain observability is not configured on this control plane' }, 501);
3941
4278
  }
3942
4279
  const p = c.get('principal');
3943
- const tenantId = p.kind === 'builder' ? p.tenantId : c.req.query('tenantId');
4280
+ const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
3944
4281
  if (!tenantId)
3945
4282
  throw new ControlPlaneError(400, 'tenantId is required');
3946
4283
  const scopeIds = (c.req.queries('scopeId') ?? []).filter((s) => s.length > 0);
@@ -3960,7 +4297,7 @@ export function createControlPlaneApi(options) {
3960
4297
  return c.json({ error: 'tenant-grain observability is not configured on this control plane' }, 501);
3961
4298
  }
3962
4299
  const p = c.get('principal');
3963
- const tenantId = p.kind === 'builder' ? p.tenantId : c.req.query('tenantId');
4300
+ const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
3964
4301
  if (!tenantId)
3965
4302
  throw new ControlPlaneError(400, 'tenantId is required');
3966
4303
  const input = z
@@ -3970,6 +4307,15 @@ export function createControlPlaneApi(options) {
3970
4307
  vertical: z.string().min(1).max(200).optional(),
3971
4308
  level: z.enum(['log', 'info', 'warn', 'error', 'debug']).optional(),
3972
4309
  search: z.string().min(1).max(200).optional(),
4310
+ // One call's lines (#1525): the id `invocationLog` stamps on its line and the
4311
+ // events carry. The shape is `ulid()`'s — the only thing that mints it — so
4312
+ // anything else is refused as a 400 rather than sent to the backend as a filter
4313
+ // that can only match nothing, which reads as "no logs for that call".
4314
+ // Tighter than the platform's id schemas (`ids.ts` accepts any Crockford
4315
+ // character first): a 48-bit timestamp encodes to 10 characters whose first is
4316
+ // 0–7, so `Z000…` is not a ULID however it looks. The contracts package exports
4317
+ // no generic ULID schema to reuse — each id brands its own private copy.
4318
+ invocationId: z.string().regex(/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/, 'invocationId must be a ULID').optional(),
3973
4319
  hours: z.coerce.number().int().min(1).max(72).default(24),
3974
4320
  // The chart's time cursor (#1447): a window that ENDS in the past, which `hours`
3975
4321
  // cannot spell — it always ends at now.
@@ -4007,6 +4353,9 @@ export function createControlPlaneApi(options) {
4007
4353
  vertical: c.req.query('vertical') || undefined,
4008
4354
  level: c.req.query('level') || undefined,
4009
4355
  search: c.req.query('search') || undefined,
4356
+ // Raw, not `|| undefined`: an EMPTY id is a caller bug, and folding it into
4357
+ // "absent" would answer it with the tenant's whole log.
4358
+ invocationId: c.req.query('invocationId'),
4010
4359
  hours: c.req.query('hours'),
4011
4360
  since: c.req.query('since') || undefined,
4012
4361
  until: c.req.query('until') || undefined,
@@ -4126,6 +4475,25 @@ export function createControlPlaneApi(options) {
4126
4475
  });
4127
4476
  return c.json({ token, tenantSlug: tenant.slug }, 201);
4128
4477
  });
4478
+ // -- tenant tokens (tenant-token.ts, #977) ---------------------------------
4479
+ // Mint the dashboard's tenant-scoped service credential. STAFF-ONLY, and this is
4480
+ // the load-bearing part of the whole design: it is on NEITHER allowlist, so a
4481
+ // tenant token can never mint another — not for a different tenant, not even for
4482
+ // its own. The power to mint one for any tenant stays with the platform service
4483
+ // credential, which is what a multi-tenant dashboard must hold and which is now the
4484
+ // ONLY fleet-wide thing it does. Everything else it does is spent per tenant.
4485
+ app.post('/tenant-tokens', async (c) => {
4486
+ if (!options.tenantTokenSecret) {
4487
+ return c.json({ error: 'tenant tokens are not configured on this control plane' }, 501);
4488
+ }
4489
+ const { tenantId } = z.object({ tenantId: tenantIdSchema }).parse(await c.req.json());
4490
+ // Unlike the push-token mint, this does NOT require the tenant to exist: a token
4491
+ // for a tenant with no directory row reaches nothing except creating that row
4492
+ // (`POST /tenants`, body-pinned to the same id), which is the sign-up bootstrap.
4493
+ // See the mint's docblock in tenant-token.ts.
4494
+ const token = await mintTenantToken(options.tenantTokenSecret, { tenantId });
4495
+ return c.json({ token }, 201);
4496
+ });
4129
4497
  // -- the hostname map (§4.7, K-26) -----------------------------------------
4130
4498
  // Staff actions, PLUS a tenant-narrowed builder view (multi-surface exposure —
4131
4499
  // binding an EKA-style second surface is self-serve for the scope's own tenant).
@@ -4139,7 +4507,8 @@ export function createControlPlaneApi(options) {
4139
4507
  // (which would confirm the name is taken by someone).
4140
4508
  const tenantHostname = async (c, name) => {
4141
4509
  const p = c.get('principal');
4142
- const filter = p.kind === 'builder' ? { tenantId: p.tenantId } : {};
4510
+ const pin = confinedTenant(p);
4511
+ const filter = pin ? { tenantId: pin } : {};
4143
4512
  return (await admin.listHostnames(c.get('actor'), filter)).find((h) => h.hostname === name.toLowerCase());
4144
4513
  };
4145
4514
  app.get('/hostnames', async (c) => {
@@ -4148,10 +4517,14 @@ export function createControlPlaneApi(options) {
4148
4517
  tenantId: c.req.query('tenantId'),
4149
4518
  scopeId: c.req.query('scopeId'),
4150
4519
  });
4151
- // A builder's list is ALWAYS its own tenant's — the query may narrow further
4152
- // (scopeId) but never widen; a foreign tenantId in the query loses silently.
4153
- if (p.kind === 'builder')
4154
- filter.tenantId = p.tenantId;
4520
+ // A CONFINED principal's list is ALWAYS its own tenant's — the query may narrow
4521
+ // further (scopeId) but never widen; a foreign tenantId in the query loses
4522
+ // silently. `confinedTenant`, not a kind check: the dashboard's tenant token is
4523
+ // confined too (#977), and reading this as "builders only" is what left it
4524
+ // paging the whole fleet's bindings.
4525
+ const hostnamePin = confinedTenant(p);
4526
+ if (hostnamePin)
4527
+ filter.tenantId = hostnamePin;
4155
4528
  const page = pageParams(c);
4156
4529
  const entries = await admin.listHostnames(c.get('actor'), { ...filter, ...page });
4157
4530
  return c.json(pageOf(entries, page.limit, (h) => h.hostname));
@@ -4223,12 +4596,19 @@ export function createControlPlaneApi(options) {
4223
4596
  app.post('/hostnames', async (c) => {
4224
4597
  const p = c.get('principal');
4225
4598
  const input = bindHostnameBody.parse(await c.req.json());
4226
- if (p.kind === 'builder') {
4227
- // The body names the tenant the binding lands under; a builder may only name
4228
- // its own (the adapter then verifies the scope belongs to it, K-3).
4229
- if (input.tenantId !== p.tenantId)
4599
+ const bindPin = confinedTenant(p);
4600
+ if (bindPin) {
4601
+ // The body names the tenant the binding lands under; a confined principal may
4602
+ // only name its own (the adapter then verifies the scope belongs to it, K-3).
4603
+ if (input.tenantId !== bindPin)
4230
4604
  return c.json({ error: 'forbidden' }, 403);
4605
+ }
4606
+ if (p.kind === 'builder') {
4231
4607
  // The region column is an EU-residency claim (K-30) — never builder-suppliable.
4608
+ // Left as a BUILDER rule rather than a confinement one: the dashboard binds the
4609
+ // platform's own hostnames for a tenant and has always been able to say where
4610
+ // the binding sits, so widening this to #977's credential would be a behaviour
4611
+ // change wearing a security fix's clothes.
4232
4612
  if (input.region !== null) {
4233
4613
  return c.json({ error: 'region is derived from the scope, not chosen on a binding' }, 403);
4234
4614
  }
@@ -4255,7 +4635,7 @@ export function createControlPlaneApi(options) {
4255
4635
  // re-affirm. Idempotent and safe to hammer — it just reflects Cloudflare's current state.
4256
4636
  app.post('/hostnames/:hostname/verify', async (c) => {
4257
4637
  const name = c.req.param('hostname');
4258
- if (c.get('principal').kind === 'builder' && !(await tenantHostname(c, name))) {
4638
+ if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
4259
4639
  return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
4260
4640
  }
4261
4641
  const row = await hostnameRow(c, name);
@@ -4269,7 +4649,7 @@ export function createControlPlaneApi(options) {
4269
4649
  // Not path-parsed through the schema: a hostname is the path segment here, and
4270
4650
  // `setHostnameStatus` normalizes and 404s an unknown one below the seam.
4271
4651
  const name = c.req.param('hostname');
4272
- if (c.get('principal').kind === 'builder' && !(await tenantHostname(c, name))) {
4652
+ if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
4273
4653
  return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
4274
4654
  }
4275
4655
  await admin.setHostnameStatus(c.get('actor'), name, status, note);
@@ -4283,7 +4663,7 @@ export function createControlPlaneApi(options) {
4283
4663
  // idempotency yields to existence hiding at the tenant boundary.
4284
4664
  app.delete('/hostnames/:hostname', async (c) => {
4285
4665
  const name = c.req.param('hostname');
4286
- if (c.get('principal').kind === 'builder' && !(await tenantHostname(c, name))) {
4666
+ if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
4287
4667
  return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
4288
4668
  }
4289
4669
  // Release the Cloudflare custom hostname before dropping the row, or the CF object
@@ -4556,8 +4936,9 @@ export function createControlPlaneApi(options) {
4556
4936
  const v = await verticalOf(c.get('actor'), slug);
4557
4937
  if (!v)
4558
4938
  return { error: `unknown vertical '${slug}'`, status: 404 };
4559
- if (p.kind === 'builder' && v.ownerTenant !== p.tenantId)
4939
+ if (confinedTenant(p) !== null && v.ownerTenant !== confinedTenant(p)) {
4560
4940
  return { error: 'forbidden', status: 403 };
4941
+ }
4561
4942
  if (v.ownerTenant === null)
4562
4943
  return { error: `vertical '${slug}' has no owner tenant`, status: 409 };
4563
4944
  return { tenantId: v.ownerTenant, slug };
@@ -4708,6 +5089,47 @@ export function createControlPlaneApi(options) {
4708
5089
  });
4709
5090
  return c.body(null, 204);
4710
5091
  });
5092
+ // -- the schedule kill switch (#1666) --------------------------------------
5093
+ //
5094
+ // One module's scheduled work on ONE scope, off and back on — `revokeFromSystem` and
5095
+ // `restoreToSystem`. Module-wide on the scope by design (the body schema says why a
5096
+ // per-permission switch is either inexpressible or noisy), `reason` required, and both
5097
+ // halves audited on the admin log — intent first, then outcome, on every attempt,
5098
+ // paired by the `operationId` the route answers with. Idempotent: a repeat answers
5099
+ // `changed: false`, and is audited all the same.
5100
+ //
5101
+ // **Restore is the lever; a grant is not.** While the switch is off, a `grantToSystem`
5102
+ // for the module is refused (409) and a reconcile seats none of its system grants, not
5103
+ // even a newly declared one — only the POST below turns it back on, and it gives back
5104
+ // exactly what the DELETE took. A switch that the next deploy, or a stray grant,
5105
+ // silently undid would not be a kill switch.
5106
+ //
5107
+ // For a HOSTED scope the grants live in the vertical's deployment, and the host's
5108
+ // `systemSwitchDelegation` moves the switch there; a deployment built before the far
5109
+ // end existed answers 501 "redeploy", nothing is switched, and the log shows the intent
5110
+ // and its failure.
5111
+ //
5112
+ // **Staff and the platform service token ONLY.** Builders are refused by BUILDER_ROUTES
5113
+ // (default-deny). A tenant-scoped credential (#977) would pass this path's `/tenants/<pin>`
5114
+ // confinement, so the handler refuses it by `confinedTenant`: the switched party must
5115
+ // not be able to switch itself back on, and the dashboard has no flow that pulls it.
5116
+ const switchScheduleRoute = (to) => async (c) => {
5117
+ if (confinedTenant(c.get('principal')) !== null) {
5118
+ return c.json({ error: 'forbidden: the schedule switch is staff-only' }, 403);
5119
+ }
5120
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
5121
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
5122
+ const body = systemSwitchBody.parse(await c.req.json());
5123
+ const actor = c.get('actor');
5124
+ // K-3 first, so a scope of another tenant reads as absent before anything is reached.
5125
+ if (!(await admin.getScopeRecord(actor, tenantId, scopeId))) {
5126
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5127
+ }
5128
+ const input = { moduleId: body.moduleId, node: { tenantId, scopeId }, reason: body.reason };
5129
+ return c.json(to === 'off' ? await admin.revokeFromSystem(actor, input) : await admin.restoreToSystem(actor, input));
5130
+ };
5131
+ app.delete('/tenants/:tenantId/scopes/:scopeId/system-grants', switchScheduleRoute('off'));
5132
+ app.post('/tenants/:tenantId/scopes/:scopeId/system-grants', switchScheduleRoute('on'));
4711
5133
  // Orgs: the portal-customer grouping (§4.1). Creating one mints no permission —
4712
5134
  // members reach what the org was GRANTED, and granting stays off this surface.
4713
5135
  app.post('/tenants/:tenantId/orgs', async (c) => {
@@ -4746,7 +5168,7 @@ export function createControlPlaneApi(options) {
4746
5168
  app.get('/ops-failures', async (c) => {
4747
5169
  const p = c.get('principal');
4748
5170
  const filter = opsFailuresQuery.parse({
4749
- tenantId: p.kind === 'builder' ? p.tenantId : c.req.query('tenantId'),
5171
+ tenantId: confinedTenant(p) ?? c.req.query('tenantId'),
4750
5172
  scopeId: c.req.query('scopeId'),
4751
5173
  vertical: c.req.query('vertical'),
4752
5174
  version: c.req.query('version'),
@@ -4771,7 +5193,7 @@ export function createControlPlaneApi(options) {
4771
5193
  kind: c.req.query('kind'),
4772
5194
  unit: c.req.query('unit'),
4773
5195
  outcome: c.req.query('outcome'),
4774
- tenantId: p.kind === 'builder' ? p.tenantId : c.req.query('tenantId'),
5196
+ tenantId: confinedTenant(p) ?? c.req.query('tenantId'),
4775
5197
  scopeId: c.req.query('scopeId'),
4776
5198
  vertical: c.req.query('vertical'),
4777
5199
  connectionId: c.req.query('connectionId'),