@substrat-run/control-plane-api 0.122.1 → 0.125.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
@@ -9,7 +9,7 @@ import { connectionGrantsForScope } from './vertical-client.js';
9
9
  import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
10
10
  import { versionReachedAt } from './scope-deployment.js';
11
11
  import { reconcileConnectionGrants } from './connection-grants.js';
12
- import { reconcileThenReassert } from './reconcile.js';
12
+ import { reconcileThenReassert, switchCarryFor } from './reconcile.js';
13
13
  import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
14
14
  import { ControlPlaneError } from './client.js';
15
15
  import { provisionSiblingScope } from './platform-drain.js';
@@ -33,7 +33,8 @@ function problem(c, answer) {
33
33
  }
34
34
  import { backfillDeclaredStores, blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, missingStoresForTenant, tenantStoreBindings, } from './tenant-stores.js';
35
35
  import { mintPushToken, pushActorFor } from './push-token.js';
36
- import { TENANT_SERIES_SCOPE_CAP } from './observability.js';
36
+ import { TENANT_SERIES_SCOPE_CAP, REQUEST_FACET_KEYS } from './observability.js';
37
+ import { parseTenantRequestQuery, parseTenantWindowQuery, refineTenantWindow } from './tenant-request-query.js';
37
38
  import { namespacesForScript } from './do-namespaces.js';
38
39
  import { backupDirectoryIfDue } from './directory-backup.js';
39
40
  import { STORAGE_PAGE_DEFAULT, STORAGE_PAGE_MAX, readStoragePage } from './storage-meter.js';
@@ -73,6 +74,38 @@ class ExportBreakRefused extends ControlPlaneError {
73
74
  function exportBreakBody(e) {
74
75
  return { error: e.message, ...(e instanceof ExportBreakRefused ? { exportBreaks: { affected: e.breaks } } : {}) };
75
76
  }
77
+ /**
78
+ * A delegated facet answer, normalized — and refused when the vertical that produced it
79
+ * predates #1762.
80
+ *
81
+ * The facet runs inside the vertical holding the scope, on the kernel that vertical was
82
+ * pushed with, and a kernel from before the rule groups a payload field over personal
83
+ * data too — one bucket per email address. It says so by omitting `withheldPersonal`.
84
+ * Such a PAYLOAD grouping is not relayed: no buckets, every event that was not erased
85
+ * counted as withheld, and `withheldReason` saying why. An envelope grouping is a
86
+ * kernel-stamped column, which the old kernel answered correctly, so it passes with the
87
+ * count it could not have withheld anything under — 0 — filled in, as the schema
88
+ * requires.
89
+ *
90
+ * A numeric `withheldPersonal` is trusted as it comes. This guard is for version skew,
91
+ * not a defence against a dishonest vertical: a vertical is tenant code that can already
92
+ * read its own scope, so one that lied here would be disclosing nothing it could not
93
+ * show its own users some other way.
94
+ */
95
+ function withholdIfPredatesRule(input, answer) {
96
+ if (input.groupBy.kind !== 'payload')
97
+ return { ...answer, withheldPersonal: answer.withheldPersonal ?? 0 };
98
+ if (typeof answer.withheldPersonal === 'number')
99
+ return answer;
100
+ return {
101
+ buckets: [],
102
+ erased: answer.erased,
103
+ withheldPersonal: Math.max(0, answer.total - answer.erased),
104
+ withheldReason: 'vertical-predates-rule',
105
+ total: answer.total,
106
+ truncated: false,
107
+ };
108
+ }
76
109
  /**
77
110
  * A fleet-wide report, narrowed to what a caller may see: a confined caller gets its own
78
111
  * tenant's rows and a COUNT of the other tenants, never their ids (K-3's forced filter, held on
@@ -492,6 +525,12 @@ const TENANT_ROUTES = [
492
525
  { method: 'GET', re: /\/observability\/tenant-metrics$/, pin: 'query' },
493
526
  { method: 'GET', re: /\/observability\/tenant-metrics-series$/, pin: 'query' },
494
527
  { method: 'GET', re: /\/observability\/tenant-logs$/, pin: 'query' },
528
+ // #1746: the request record's histogram, facets and list — same grain, same forced tenant.
529
+ { method: 'GET', re: /\/observability\/tenant-request-volume$/, pin: 'query' },
530
+ { method: 'GET', re: /\/observability\/tenant-request-facets$/, pin: 'query' },
531
+ { method: 'GET', re: /\/observability\/tenant-requests$/, pin: 'query' },
532
+ // #1747: the tenant's `ctx.log` lines grouped by template — same grain, same forced tenant.
533
+ { method: 'GET', re: /\/observability\/tenant-log-patterns$/, pin: 'query' },
495
534
  { method: 'GET', re: /\/hostnames$/, pin: 'query' },
496
535
  { method: 'POST', re: /\/hostnames$/, pin: 'body' },
497
536
  // The registry + install catalog.
@@ -732,6 +771,12 @@ export function createControlPlaneApi(options) {
732
771
  { method: 'GET', re: /\/observability\/tenant-metrics$/ },
733
772
  { method: 'GET', re: /\/observability\/tenant-metrics-series$/ },
734
773
  { method: 'GET', re: /\/observability\/tenant-logs$/ },
774
+ // #1746: the same lines, counted and listed. Tenant-keyed, so no fleet-wide fallback.
775
+ { method: 'GET', re: /\/observability\/tenant-request-volume$/ },
776
+ { method: 'GET', re: /\/observability\/tenant-request-facets$/ },
777
+ { method: 'GET', re: /\/observability\/tenant-requests$/ },
778
+ // #1747: the same tenant's `ctx.log` lines, grouped by template.
779
+ { method: 'GET', re: /\/observability\/tenant-log-patterns$/ },
735
780
  ];
736
781
  app.use('*', async (c, next) => {
737
782
  if (c.get('principal').kind === 'builder') {
@@ -1253,10 +1298,13 @@ export function createControlPlaneApi(options) {
1253
1298
  // `/connections/health` — absent from BUILDER_ROUTES and TENANT_ROUTES, so both
1254
1299
  // credentials default-deny it.
1255
1300
  //
1256
- // This is a count of terminal FAILURES, never a queue depth (see `platformRequestBacklog`'s
1301
+ // `total` is a count of terminal FAILURES, never a queue depth (see `platformRequestBacklog`'s
1257
1302
  // doc): a still-`pending` intent lives in the vertical's own scope DO and nothing here
1258
1303
  // walks the fleet to find it. A `0` means "nothing has given up lately", not "nothing is
1259
1304
  // waiting" — the console tile is worded to keep that distinction, not just this comment.
1305
+ // What IS waiting is `pending` (#1840): the newest `platform-request` sweep row, which the
1306
+ // platform sweep writes once per pass from the fan-out it already pays. As fresh as that
1307
+ // pass, and null — never 0 — when no pass is on record.
1260
1308
  app.get('/platform-requests/backlog', async (c) => {
1261
1309
  const now = new Date();
1262
1310
  const since = new Date(now.getTime() - PLATFORM_REQUEST_BACKLOG_WINDOW_DAYS * 86_400_000).toISOString();
@@ -1274,22 +1322,31 @@ export function createControlPlaneApi(options) {
1274
1322
  SWEEP_RUNS_KIND,
1275
1323
  ...providers.map(connectorDispatchKind),
1276
1324
  ];
1277
- const counts = await Promise.all(kinds.map(async (kind) => {
1278
- const found = await admin.listOpsFailures(c.get('actor'), {
1279
- operation: `intent.${kind}`,
1280
- since,
1281
- limit: PLATFORM_REQUEST_BACKLOG_CAP + 1,
1282
- });
1283
- return {
1284
- count: Math.min(found.length, PLATFORM_REQUEST_BACKLOG_CAP),
1285
- capped: found.length > PLATFORM_REQUEST_BACKLOG_CAP,
1286
- };
1287
- }));
1325
+ // The drain's newest pass is independent of the failure counts, so it rides alongside them.
1326
+ const [counts, [lastPass]] = await Promise.all([
1327
+ Promise.all(kinds.map(async (kind) => {
1328
+ const found = await admin.listOpsFailures(c.get('actor'), {
1329
+ operation: `intent.${kind}`,
1330
+ since,
1331
+ limit: PLATFORM_REQUEST_BACKLOG_CAP + 1,
1332
+ });
1333
+ return {
1334
+ count: Math.min(found.length, PLATFORM_REQUEST_BACKLOG_CAP),
1335
+ capped: found.length > PLATFORM_REQUEST_BACKLOG_CAP,
1336
+ };
1337
+ })),
1338
+ admin.listSweepRuns(c.get('actor'), { kind: 'platform-request', unit: 'fleet', limit: 1 }),
1339
+ ]);
1288
1340
  const body = {
1289
1341
  total: counts.reduce((sum, k) => sum + k.count, 0),
1290
1342
  capped: counts.some((k) => k.capped),
1291
1343
  since,
1292
1344
  windowDays: PLATFORM_REQUEST_BACKLOG_WINDOW_DAYS,
1345
+ // A row without totals is not a pass this route can read a count from; it answers
1346
+ // null like no row at all rather than inventing a zero.
1347
+ pending: lastPass?.platformRequests
1348
+ ? { count: lastPass.platformRequests.pending, asOf: lastPass.at, floor: lastPass.outcome !== 'ok' }
1349
+ : null,
1293
1350
  };
1294
1351
  return c.json(body);
1295
1352
  });
@@ -1655,6 +1712,16 @@ export function createControlPlaneApi(options) {
1655
1712
  * installs short-circuit.
1656
1713
  * Throws `ControlPlaneError` so callers surface an actionable status, never a bare 500.
1657
1714
  */
1715
+ /**
1716
+ * #1742: restore a dump into a deployment carrying the scope's recorded-off modules, so the
1717
+ * deployment switches them off in the replay's own event. The record is read on EVERY
1718
+ * attempt, inside the retry, so a retried restore carries the record as it stands then, not
1719
+ * as it stood before the failed attempt. Every caller still re-asserts after it (the
1720
+ * fallback), passing what this reports.
1721
+ */
1722
+ const restoreCarryingSwitches = async (actor, dest, tenantId, scopeId, tables) => {
1723
+ return retryTransient(async () => dest.restoreScope(tenantId, scopeId, tables, await switchCarryFor(admin, actor, { tenantId, scopeId })));
1724
+ };
1658
1725
  const adoptScopeOntoServing = async (c, tenantId, scopeId, opts = {}) => {
1659
1726
  const actor = c.get('actor');
1660
1727
  const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
@@ -1690,7 +1757,8 @@ export function createControlPlaneApi(options) {
1690
1757
  throw new ControlPlaneError(501, 'adopt-serving needs dispatch resolution for both ends');
1691
1758
  }
1692
1759
  const dump = await source.exportScope(scopeId);
1693
- const restored = await retryTransient(() => dest.restoreScope(tenantId, scopeId, dump));
1760
+ // #1742: the recorded OFF positions ride the restore, applied in the replay's own event.
1761
+ const restored = await restoreCarryingSwitches(actor, dest, tenantId, scopeId, dump);
1694
1762
  // Data landed — only now flip routing and move the version pointer.
1695
1763
  await admin
1696
1764
  .setScopeServingRef(actor, tenantId, scopeId, serving.ref, { acknowledge: opts.acknowledge })
@@ -1699,8 +1767,8 @@ export function createControlPlaneApi(options) {
1699
1767
  .bindScopeVersion(actor, tenantId, scopeId, serving.versionId, { acknowledge: opts.acknowledge })
1700
1768
  .catch(relayHostRefusal);
1701
1769
  // #1674: the scope now routes to a different store, so put the directory's recorded
1702
- // OFF positions back there — cheap and idempotent when the dump carried them.
1703
- await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
1770
+ // OFF positions back there — cheap and idempotent when the dump or the restore did.
1771
+ await admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: restored.switchedOff });
1704
1772
  return { servingRef: serving.ref, tables: restored.tables };
1705
1773
  };
1706
1774
  /**
@@ -1834,7 +1902,7 @@ export function createControlPlaneApi(options) {
1834
1902
  throw new ControlPlaneError(501, 'rebind-vertical needs dispatch resolution for both ends');
1835
1903
  }
1836
1904
  const dump = await source.exportScope(scopeId);
1837
- const restored = await retryTransient(() => dest.restoreScope(tenantId, scopeId, dump));
1905
+ const restored = await restoreCarryingSwitches(actor, dest, tenantId, scopeId, dump); // #1742, as adopt
1838
1906
  // Data landed on the target script — only now flip routing and cross the pointer.
1839
1907
  // `bindScopeVersion` rewrites `scopes.vertical` from the version row, audited. No
1840
1908
  // extra snapshot here (adopt-serving's precedent): the source script's copy is the
@@ -1842,7 +1910,7 @@ export function createControlPlaneApi(options) {
1842
1910
  await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref, move).catch(relayHostRefusal);
1843
1911
  await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId, move).catch(relayHostRefusal);
1844
1912
  // #1674: re-assert the recorded OFF positions in the store the scope now routes to.
1845
- await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
1913
+ await admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: restored.switchedOff });
1846
1914
  return { servingRef: serving.ref, versionId: serving.versionId, tables: restored.tables };
1847
1915
  };
1848
1916
  /**
@@ -1920,8 +1988,9 @@ export function createControlPlaneApi(options) {
1920
1988
  `so the version was not bound (#1710)`);
1921
1989
  }
1922
1990
  const dump = await retryTransient(() => source.exportScope(scope.id));
1923
- const restored = await retryTransient(() => dest.restoreScope(scope.tenantId, scope.id, dump));
1924
- return { from, to, tables: restored.tables };
1991
+ // #1742: the recorded OFF positions ride the restore, as on adopt and rebind.
1992
+ const restored = await restoreCarryingSwitches(actor, dest, scope.tenantId, scope.id, dump);
1993
+ return { from, to, tables: restored.tables, switchedOff: restored.switchedOff };
1925
1994
  };
1926
1995
  app.get('/tenants/:tenantId/scopes/:scopeId/tables', async (c) => {
1927
1996
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
@@ -2163,13 +2232,14 @@ export function createControlPlaneApi(options) {
2163
2232
  const ranAs = versionReachedAt(reached.via, scope, serving);
2164
2233
  try {
2165
2234
  // #1674: the recorded OFF positions go back after the reconcile, before the receipt.
2166
- const result = await reconcileThenReassert(admin, actor, { tenantId, scopeId }, () => vertical.reconcileInstance({
2235
+ const result = await reconcileThenReassert(admin, actor, { tenantId, scopeId }, (carry) => vertical.reconcileInstance({
2167
2236
  tenantId,
2168
2237
  scopeId,
2169
2238
  entitlements,
2170
2239
  identityLinks,
2171
2240
  connectionGrants,
2172
2241
  connectionKeys,
2242
+ ...carry,
2173
2243
  // Handed over exactly as at provision: a store minted HERE has never been migrated
2174
2244
  // by the vertical, so the reconcile must carry it into the same ready-gate — a bound
2175
2245
  // but unmigrated database fails as loudly as an absent one.
@@ -2219,7 +2289,9 @@ export function createControlPlaneApi(options) {
2219
2289
  if (!scope)
2220
2290
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
2221
2291
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'facetEvents', input, {
2222
- viaVertical: (v) => v.facetEvents(scopeId, input),
2292
+ // #1762: only the vertical's answer needs the guard. The co-located read runs
2293
+ // this platform's own kernel, which withholds by construction.
2294
+ viaVertical: async (v) => withholdIfPredatesRule(input, await v.facetEvents(scopeId, input)),
2223
2295
  colocated: () => admin.facetEvents(c.get('actor'), tenantId, scopeId, input),
2224
2296
  },
2225
2297
  // The buckets RETURNED, not `total`: the row says how much this read handed
@@ -2265,7 +2337,9 @@ export function createControlPlaneApi(options) {
2265
2337
  method,
2266
2338
  tenantId,
2267
2339
  scopeId,
2268
- params: delegatedReadParams[method](input),
2340
+ // The answer rides along for the one method whose row describes it (a facet's
2341
+ // withheld counts, #1762); every other projection ignores it.
2342
+ params: delegatedReadParams[method](input, answer),
2269
2343
  resultCount: count(answer),
2270
2344
  });
2271
2345
  return answer;
@@ -3217,11 +3291,13 @@ export function createControlPlaneApi(options) {
3217
3291
  const landing = { ...dump, tables };
3218
3292
  await host.restoreScope(actor, tenantId, scopeId, landing);
3219
3293
  const vertical = await verticalForScope(c, scope);
3220
- if (vertical)
3221
- await retryTransient(() => vertical.restoreScope(tenantId, scopeId, tables));
3294
+ // #1742: the recorded OFF positions ride the restore, so the deployment switches them
3295
+ // off in the replay's own event — its own sweeper cannot land in between.
3296
+ const restored = vertical ? await restoreCarryingSwitches(actor, vertical, tenantId, scopeId, tables) : undefined;
3222
3297
  // #1674: a backup from before a module was switched off brings it back on; the
3223
- // directory's record puts it back off now, not at the next reconcile.
3224
- await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
3298
+ // directory's record puts it back off now, not at the next reconcile. Where the
3299
+ // deployment already did (#1742), this finds it done and audits what it reported.
3300
+ await admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: restored?.switchedOff });
3225
3301
  return c.json({ restored: scopeId, tables: tables.length });
3226
3302
  }
3227
3303
  catch (e) {
@@ -3297,6 +3373,9 @@ export function createControlPlaneApi(options) {
3297
3373
  // through, makes the next sweep reconcile the scope, and that reconcile re-asserts
3298
3374
  // what the directory records as off. Left to the sweep rather than done here: the
3299
3375
  // scope's store restarts to finish the restore, and a re-assert now would race it.
3376
+ // Meanwhile, the deployment that rewound it holds those modules off (#1819). A hold is
3377
+ // released by that reconcile's re-assert, or earlier by a switch move known to land on the
3378
+ // rewound storage, such as an operator's ON.
3300
3379
  await admin.markScopeProvisioned(actor, tenantId, scopeId, null);
3301
3380
  return c.json(answer);
3302
3381
  }
@@ -3430,15 +3509,18 @@ export function createControlPlaneApi(options) {
3430
3509
  // A refusal throws a ControlPlaneError, which the app's error boundary relays with its
3431
3510
  // status and records as an ops failure. An unknown scope carries nothing, and the bind
3432
3511
  // below refuses it as it always has.
3512
+ let carried = null;
3433
3513
  const carry = async () => {
3434
3514
  if (scope)
3435
- await carryOntoVersion(c, scope, versionId);
3515
+ carried = await carryOntoVersion(c, scope, versionId);
3436
3516
  };
3437
3517
  // #1674: after the bind, the scope may route to a different store (the carry's
3438
- // destination); put the directory's recorded OFF positions back there.
3518
+ // destination); put the directory's recorded OFF positions back there. #1742: the carry
3519
+ // already did so in the restore's own event; what it reports is audited here.
3439
3520
  const reassert = async () => {
3440
- if (scope)
3441
- await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
3521
+ if (scope) {
3522
+ await admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: carried?.switchedOff });
3523
+ }
3442
3524
  };
3443
3525
  // The host's own refusal, should the impact have changed since it was read above (a
3444
3526
  // promote landing in between): relayed as the same 409, never as a 500.
@@ -3558,6 +3640,10 @@ export function createControlPlaneApi(options) {
3558
3640
  tenantId: input.tenantId,
3559
3641
  patchBindings: options.patchScriptBindings,
3560
3642
  });
3643
+ // #1742: the recorded OFF positions ride the provision into the seat's own unit. Read
3644
+ // from the directory whether or not the row exists yet: a brand-new install has no
3645
+ // record, so nothing is carried and the body is unchanged.
3646
+ const switches = await switchCarryFor(admin, c.get('actor'), { tenantId: input.tenantId, scopeId: input.scopeId });
3561
3647
  try {
3562
3648
  // #424 case 2: the binding attach above races Cloudflare script-settings
3563
3649
  // propagation, so the vertical's FIRST answer can be a transient 5xx that a
@@ -3570,6 +3656,7 @@ export function createControlPlaneApi(options) {
3570
3656
  connectionGrants,
3571
3657
  connectionKeys,
3572
3658
  ...(tenantStores.length ? { tenantStores } : {}),
3659
+ ...switches,
3573
3660
  }));
3574
3661
  // #1172: the hook has just run, so record what it ran against — WHEN the scope is
3575
3662
  // already bound to a version. Often it is not: this route does not bind, and the
@@ -3585,7 +3672,7 @@ export function createControlPlaneApi(options) {
3585
3672
  // directory row exists: a brand-new install's is written after this call (see above),
3586
3673
  // has no record to re-assert, and `reassertSystemSwitches` refuses an unknown scope.
3587
3674
  if (provisioned) {
3588
- await admin.reassertSystemSwitches(c.get('actor'), { tenantId: input.tenantId, scopeId: input.scopeId });
3675
+ await admin.reassertSystemSwitches(c.get('actor'), { tenantId: input.tenantId, scopeId: input.scopeId }, { appliedInUnit: instance.switchedOff });
3589
3676
  }
3590
3677
  if (provisioned?.verticalVersionId) {
3591
3678
  await admin.markScopeProvisioned(c.get('actor'), input.tenantId, input.scopeId, provisioned.verticalVersionId);
@@ -5005,6 +5092,9 @@ export function createControlPlaneApi(options) {
5005
5092
  // 0–7, so `Z000…` is not a ULID however it looks. The contracts package exports
5006
5093
  // no generic ULID schema to reuse — each id brands its own private copy.
5007
5094
  invocationId: z.string().regex(/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/, 'invocationId must be a ULID').optional(),
5095
+ // #1747: one pattern's lines — a `ctx.log` template, verbatim. The line bounds a
5096
+ // template at 500 characters, so a longer one can match nothing and is refused.
5097
+ template: z.string().min(1).max(500).optional(),
5008
5098
  hours: z.coerce.number().int().min(1).max(72).default(24),
5009
5099
  // The chart's time cursor (#1447): a window that ENDS in the past, which `hours`
5010
5100
  // cannot spell — it always ends at now.
@@ -5012,30 +5102,8 @@ export function createControlPlaneApi(options) {
5012
5102
  until: z.string().datetime({ offset: true }).optional(),
5013
5103
  limit: z.coerce.number().int().min(1).max(200).default(100),
5014
5104
  })
5015
- .superRefine((v, ctx) => {
5016
- const now = Date.now();
5017
- const to = v.until ? Date.parse(v.until) : now;
5018
- const from = v.since ? Date.parse(v.since) : to - v.hours * 3_600_000;
5019
- // On the resolved bounds rather than on the pair, so a lone `since` in the future
5020
- // is refused by the same line: with no `until` it is measured against now, and
5021
- // without this it would reach the backend as a window that cannot contain
5022
- // anything and come back as an empty page nobody could explain.
5023
- if (from >= to) {
5024
- ctx.addIssue({ code: 'custom', path: ['since'], message: 'since must be before until' });
5025
- }
5026
- // The SAME ceiling `hours` carries, applied to the window however it was spelled.
5027
- // Otherwise the pair is a second door onto a read the first one caps at three
5028
- // days, and the two spellings together would read past what either allows.
5029
- if (to - from > 72 * 3_600_000) {
5030
- ctx.addIssue({ code: 'custom', path: ['since'], message: 'the window may not exceed 72 hours' });
5031
- }
5032
- // A window ending in the future returns nothing and says nothing about why, which
5033
- // a reader takes for "my app was quiet". Five minutes of slack, because the
5034
- // instant is stamped by a browser's clock rather than by ours.
5035
- if (to > now + 5 * 60_000) {
5036
- ctx.addIssue({ code: 'custom', path: ['until'], message: 'until may not be more than 5 minutes in the future' });
5037
- }
5038
- })
5105
+ // The window rules every tenant-grain read shares (`refineTenantWindow` says why each).
5106
+ .superRefine((v, ctx) => refineTenantWindow(v, ctx))
5039
5107
  .parse({
5040
5108
  tenantId,
5041
5109
  scopeId: c.req.query('scopeId') || undefined,
@@ -5045,6 +5113,9 @@ export function createControlPlaneApi(options) {
5045
5113
  // Raw, not `|| undefined`: an EMPTY id is a caller bug, and folding it into
5046
5114
  // "absent" would answer it with the tenant's whole log.
5047
5115
  invocationId: c.req.query('invocationId'),
5116
+ // Raw, like the invocation id: an EMPTY template is a caller bug, and folding it into
5117
+ // "absent" would answer it with the app's whole log instead of the schema's 400.
5118
+ template: c.req.query('template'),
5048
5119
  hours: c.req.query('hours'),
5049
5120
  since: c.req.query('since') || undefined,
5050
5121
  until: c.req.query('until') || undefined,
@@ -5052,6 +5123,78 @@ export function createControlPlaneApi(options) {
5052
5123
  });
5053
5124
  return c.json(await options.observability.tenantLogs(input));
5054
5125
  });
5126
+ // #1746: the per-request record, read at the tenant grain. The three routes take ONE
5127
+ // query — tenant, window, facet filters (`tenant-request-query.ts`) — so the histogram,
5128
+ // the facet counts and the list beside them always describe the same set of requests.
5129
+ // Each 501s when the reader cannot aggregate, never answers an empty set that would read
5130
+ // as "no traffic".
5131
+ const requestQueryOf = (c) => ({
5132
+ one: (k) => c.req.query(k),
5133
+ all: (k) => c.req.queries(k) ?? [],
5134
+ });
5135
+ const requestTenantOf = (c) => {
5136
+ const tenantId = confinedTenant(c.get('principal')) ?? c.req.query('tenantId');
5137
+ if (!tenantId)
5138
+ throw new ControlPlaneError(400, 'tenantId is required');
5139
+ return tenantId;
5140
+ };
5141
+ app.get('/observability/tenant-request-volume', async (c) => {
5142
+ if (!options.observability?.tenantRequestVolume) {
5143
+ return c.json({ error: 'request aggregation is not configured on this control plane' }, 501);
5144
+ }
5145
+ const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
5146
+ // A bucket COUNT: zooming in keeps it and narrows the window, so the bucket shrinks.
5147
+ // 90 is the design's histogram; the bounds keep a chart drawable and a query cheap.
5148
+ const buckets = z.coerce.number().int().min(10).max(240).default(90).parse(c.req.query('buckets'));
5149
+ return c.json(await options.observability.tenantRequestVolume({ ...scope, buckets }));
5150
+ });
5151
+ app.get('/observability/tenant-request-facets', async (c) => {
5152
+ if (!options.observability?.tenantRequestFacets) {
5153
+ return c.json({ error: 'request aggregation is not configured on this control plane' }, 501);
5154
+ }
5155
+ const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
5156
+ // `facet` repeats; absent means every facet. Each one is a backend query, so a panel
5157
+ // that shows three asks for three.
5158
+ const asked = (c.req.queries('facet') ?? []).filter((k) => k.length > 0);
5159
+ const keys = asked.length === 0 ? undefined : z.array(z.enum(REQUEST_FACET_KEYS)).parse([...new Set(asked)]);
5160
+ return c.json(await options.observability.tenantRequestFacets({ ...scope, ...(keys ? { keys } : {}) }));
5161
+ });
5162
+ // #1747: the tenant's `ctx.log` lines grouped by the template they were written from.
5163
+ app.get('/observability/tenant-log-patterns', async (c) => {
5164
+ if (!options.observability?.tenantLogPatterns) {
5165
+ return c.json({ error: 'log patterns are not configured on this control plane' }, 501);
5166
+ }
5167
+ const scope = parseTenantWindowQuery(requestTenantOf(c), requestQueryOf(c));
5168
+ const narrow = z
5169
+ .object({
5170
+ // Each pattern's mini histogram. Few buckets: it is drawn 180px wide.
5171
+ buckets: z.coerce.number().int().min(10).max(120).default(30),
5172
+ level: z.array(z.enum(['debug', 'info', 'warn', 'error'])).max(4),
5173
+ operation: z.array(z.string().min(1).max(200)).max(20),
5174
+ })
5175
+ .parse({
5176
+ buckets: c.req.query('buckets'),
5177
+ level: [...new Set((c.req.queries('level') ?? []).filter((v) => v.length > 0))],
5178
+ operation: [...new Set((c.req.queries('operation') ?? []).filter((v) => v.length > 0))],
5179
+ });
5180
+ return c.json(await options.observability.tenantLogPatterns({
5181
+ tenantId: scope.tenantId,
5182
+ ...(scope.scopeId ? { scopeId: scope.scopeId } : {}),
5183
+ from: scope.from,
5184
+ to: scope.to,
5185
+ buckets: narrow.buckets,
5186
+ ...(narrow.level.length ? { level: narrow.level } : {}),
5187
+ ...(narrow.operation.length ? { operation: narrow.operation } : {}),
5188
+ }));
5189
+ });
5190
+ app.get('/observability/tenant-requests', async (c) => {
5191
+ if (!options.observability?.tenantRequests) {
5192
+ return c.json({ error: 'request aggregation is not configured on this control plane' }, 501);
5193
+ }
5194
+ const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
5195
+ const limit = z.coerce.number().int().min(1).max(200).default(50).parse(c.req.query('limit'));
5196
+ return c.json(await options.observability.tenantRequests({ ...scope, limit }));
5197
+ });
5055
5198
  /**
5056
5199
  * Observed-vs-declared outbound egress for a vertical's deployed versions (#859, D-46).
5057
5200
  *
@@ -5599,7 +5742,7 @@ export function createControlPlaneApi(options) {
5599
5742
  await admin.setScopeServingRef(actor, tenantId, existing.id, null);
5600
5743
  await admin.bindScopeVersion(actor, tenantId, existing.id, opts.versionId);
5601
5744
  // #1674: re-assert any recorded OFF in the script the preview now routes to.
5602
- await admin.reassertSystemSwitches(actor, { tenantId, scopeId: existing.id });
5745
+ await admin.reassertSystemSwitches(actor, { tenantId, scopeId: existing.id }, { appliedInUnit: carried?.switchedOff });
5603
5746
  // Renew (or clear) the preview's GC deadline so a reused preview does not silently die.
5604
5747
  await admin.setScopeExpiresAt(actor, tenantId, existing.id, expiresAt);
5605
5748
  const hostname = await bindPreviewHostname(actor, baseHostname, tenantId, existing.id, opts.tag, surface);