@substrat-run/control-plane-api 0.111.0 → 0.113.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 CHANGED
@@ -308,6 +308,10 @@ export declare const denialLogQuery: z.ZodObject<{
308
308
  since: z.ZodOptional<z.ZodString>;
309
309
  until: z.ZodOptional<z.ZodString>;
310
310
  limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
311
+ groupBy: z.ZodOptional<z.ZodEnum<{
312
+ "actor-permission": "actor-permission";
313
+ operation: "operation";
314
+ }>>;
311
315
  }, z.core.$strip>;
312
316
  export declare const DEFAULT_MODEL_MARGIN_PERCENT = 20;
313
317
  /**
package/dist/api.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
package/dist/api.js CHANGED
@@ -1,5 +1,5 @@
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, createOrgInput, roleKey as roleKeySchema, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, registerVerticalInput, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, } 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, 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';
3
3
  import { attributeFailure } from './failure-attribution.js';
4
4
  import { migrationProgress, ulid } from '@substrat-run/kernel';
5
5
  import { TENANT_HEADER } from './auth.js';
@@ -264,6 +264,8 @@ export const denialLogQuery = z.object({
264
264
  since: z.string().optional(),
265
265
  until: z.string().optional(),
266
266
  limit: z.coerce.number().int().min(1).max(DENIAL_LIMIT_MAX).default(DEFAULT_DENIAL_LIMIT),
267
+ // Which bucketing the summary answers with (#1456); the row read ignores it.
268
+ groupBy: denialGroupBy.optional(),
267
269
  });
268
270
  const opsFailuresQuery = z.object({
269
271
  tenantId: tenantIdSchema.optional(),
@@ -1281,10 +1283,10 @@ export function createControlPlaneApi(options) {
1281
1283
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1282
1284
  if (!scope)
1283
1285
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1284
- const vertical = await verticalForScope(c, scope);
1285
- return c.json(vertical
1286
- ? await vertical.listScopeTables(scopeId)
1287
- : await admin.listScopeTables(c.get('actor'), tenantId, scopeId));
1286
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listScopeTables', null, {
1287
+ viaVertical: (v) => v.listScopeTables(scopeId),
1288
+ colocated: () => admin.listScopeTables(c.get('actor'), tenantId, scopeId),
1289
+ }, (r) => r.length));
1288
1290
  });
1289
1291
  // Scope health (#321, criterion #3). The silent failure the field report chased was an
1290
1292
  // ACTIVE scope serving traffic from a DO whose `_substrat_roles` projection is EMPTY:
@@ -1315,10 +1317,12 @@ export function createControlPlaneApi(options) {
1315
1317
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1316
1318
  if (!scope)
1317
1319
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1318
- const vertical = await verticalForScope(c, scope);
1319
- const tables = vertical
1320
- ? await vertical.listScopeTables(scopeId)
1321
- : await admin.listScopeTables(c.get('actor'), tenantId, scopeId);
1320
+ // Health reads the scope the same way the Data view does, so it leaves the same
1321
+ // row: an auditor should not be able to tell which route reached the tables.
1322
+ const tables = await delegatedRead(c, tenantId, scopeId, scope, 'listScopeTables', null, {
1323
+ viaVertical: (v) => v.listScopeTables(scopeId),
1324
+ colocated: () => admin.listScopeTables(c.get('actor'), tenantId, scopeId),
1325
+ }, (r) => r.length);
1322
1326
  const roles = tables.find((t) => t.name === '_substrat_roles');
1323
1327
  const roleCount = roles ? roles.rowCount : null;
1324
1328
  const roleProjectionEmpty = scope.status === 'active' && roleCount === 0;
@@ -1553,11 +1557,58 @@ export function createControlPlaneApi(options) {
1553
1557
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1554
1558
  if (!scope)
1555
1559
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1556
- const vertical = await verticalForScope(c, scope);
1557
- return c.json(vertical
1558
- ? await vertical.facetEvents(scopeId, input)
1559
- : await admin.facetEvents(c.get('actor'), tenantId, scopeId, input));
1560
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'facetEvents', input, {
1561
+ viaVertical: (v) => v.facetEvents(scopeId, input),
1562
+ colocated: () => admin.facetEvents(c.get('actor'), tenantId, scopeId, input),
1563
+ },
1564
+ // The buckets RETURNED, not `total`: the row says how much this read handed
1565
+ // back, and a capped facet hands back fewer than it counted.
1566
+ (r) => r.buckets.length));
1560
1567
  });
1568
+ /**
1569
+ * Serve a scope read from whichever side holds the data, and leave ONE K-24 row
1570
+ * either way (#1357).
1571
+ *
1572
+ * Every scope-addressed read runs the same ladder — resolve the scope record, ask the
1573
+ * vertical when one resolves, the co-located host otherwise — and only the second
1574
+ * branch reaches `HostAdmin`, where `recordAccess` lives. So on the production path,
1575
+ * which is every hosted vertical, the access log held a `getScopeRecord` entry and
1576
+ * nothing saying what was read: a summary page, a table walk and one record's history
1577
+ * were indistinguishable, and the history page is the one carrying payloads.
1578
+ *
1579
+ * `count` exists because the row records how much came back and every answer counts
1580
+ * differently — a page has `entries`, a facet has `buckets`, a chain has its own
1581
+ * length. Handing each site its own counter keeps that honest rather than logging a
1582
+ * zero everywhere the shape is not a list.
1583
+ *
1584
+ * The record is written AFTER the read succeeds and is not caught: a failed write
1585
+ * fails the request. That is the co-located branch's existing trade, where
1586
+ * `recordAccess` is awaited inside the read — and returning rows whose disclosure went
1587
+ * unrecorded is the thing K-24 exists to prevent.
1588
+ *
1589
+ * `input` is the ROUTE's input, and what the row carries is `delegatedReadParams`'s
1590
+ * projection of it — never the input itself. The co-located branch logs a chosen
1591
+ * subset per method (the entity, not the page; the event, not the walk's depth), and
1592
+ * a row that carried the whole input on this branch alone would tell an auditor which
1593
+ * branch served the request. The projection lives in contracts so it is one
1594
+ * definition; the test drives every method both ways and compares the rows.
1595
+ */
1596
+ async function delegatedRead(c, tenantId, scopeId, scope, method, input, run, count) {
1597
+ const vertical = await verticalForScope(c, scope);
1598
+ // The co-located branch already records inside the read — adding a row here would
1599
+ // log it twice, which is a different kind of dishonest log.
1600
+ if (!vertical)
1601
+ return run.colocated();
1602
+ const answer = await run.viaVertical(vertical);
1603
+ await admin.recordDelegatedRead(c.get('actor'), {
1604
+ method,
1605
+ tenantId,
1606
+ scopeId,
1607
+ params: delegatedReadParams[method](input),
1608
+ resultCount: count(answer),
1609
+ });
1610
+ return answer;
1611
+ }
1561
1612
  // #1237: one event's causal chain, walked backwards. Delegated exactly like the
1562
1613
  // history read below — through the vertical holding the scope's data when one
1563
1614
  // resolves, the co-located host otherwise.
@@ -1571,10 +1622,10 @@ export function createControlPlaneApi(options) {
1571
1622
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1572
1623
  if (!scope)
1573
1624
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1574
- const vertical = await verticalForScope(c, scope);
1575
- return c.json(vertical
1576
- ? await vertical.eventCause(scopeId, input)
1577
- : await admin.eventCause(c.get('actor'), tenantId, scopeId, input));
1625
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'eventCause', input, {
1626
+ viaVertical: (v) => v.eventCause(scopeId, input),
1627
+ colocated: () => admin.eventCause(c.get('actor'), tenantId, scopeId, input),
1628
+ }, (r) => r.chain.length));
1578
1629
  });
1579
1630
  // #1237 forward: what one event set off. Delegated like its backwards twin.
1580
1631
  app.get('/tenants/:tenantId/scopes/:scopeId/effects', async (c) => {
@@ -1587,10 +1638,27 @@ export function createControlPlaneApi(options) {
1587
1638
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1588
1639
  if (!scope)
1589
1640
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1590
- const vertical = await verticalForScope(c, scope);
1591
- return c.json(vertical
1592
- ? await vertical.eventEffects(scopeId, input)
1593
- : await admin.eventEffects(c.get('actor'), tenantId, scopeId, input));
1641
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'eventEffects', input, {
1642
+ viaVertical: (v) => v.eventEffects(scopeId, input),
1643
+ colocated: () => admin.eventEffects(c.get('actor'), tenantId, scopeId, input),
1644
+ }, (r) => r.count));
1645
+ });
1646
+ // #1237: everything one call emitted — the siblings neither walk reaches. Delegated
1647
+ // like the two walks, and logged against the call id rather than its page size.
1648
+ app.get('/tenants/:tenantId/scopes/:scopeId/invocation', async (c) => {
1649
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1650
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1651
+ const input = invocationEventsInput.parse({
1652
+ invocationId: c.req.query('invocationId'),
1653
+ limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
1654
+ });
1655
+ const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1656
+ if (!scope)
1657
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1658
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'invocationEvents', input, {
1659
+ viaVertical: (v) => v.invocationEvents(scopeId, input),
1660
+ colocated: () => admin.invocationEvents(c.get('actor'), tenantId, scopeId, input),
1661
+ }, (r) => r.events.length));
1594
1662
  });
1595
1663
  // #1235: one record's event history — the per-entity pivot of the audit spine.
1596
1664
  // Delegated exactly like the table reads: through the vertical that holds the
@@ -1607,10 +1675,10 @@ export function createControlPlaneApi(options) {
1607
1675
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1608
1676
  if (!scope)
1609
1677
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1610
- const vertical = await verticalForScope(c, scope);
1611
- return c.json(vertical
1612
- ? await vertical.entityHistory(scopeId, input)
1613
- : await admin.entityHistory(c.get('actor'), tenantId, scopeId, input));
1678
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'entityHistory', input, {
1679
+ viaVertical: (v) => v.entityHistory(scopeId, input),
1680
+ colocated: () => admin.entityHistory(c.get('actor'), tenantId, scopeId, input),
1681
+ }, (r) => r.entries.length));
1614
1682
  });
1615
1683
  app.get('/tenants/:tenantId/scopes/:scopeId/tables/:table', async (c) => {
1616
1684
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
@@ -1623,10 +1691,10 @@ export function createControlPlaneApi(options) {
1623
1691
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1624
1692
  if (!scope)
1625
1693
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1626
- const vertical = await verticalForScope(c, scope);
1627
- return c.json(vertical
1628
- ? await vertical.readScopeTable(scopeId, input)
1629
- : await admin.readScopeTable(c.get('actor'), tenantId, scopeId, input));
1694
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'readScopeTable', input, {
1695
+ viaVertical: (v) => v.readScopeTable(scopeId, input),
1696
+ colocated: () => admin.readScopeTable(c.get('actor'), tenantId, scopeId, input),
1697
+ }, (r) => r.rows.length));
1630
1698
  });
1631
1699
  // The SQL console (#219): one read-only statement, POSTed because SQL does not
1632
1700
  // belong in a URL. Same delegation as the table reads; the gate's refusal maps to
@@ -1640,10 +1708,14 @@ export function createControlPlaneApi(options) {
1640
1708
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1641
1709
  if (!scope)
1642
1710
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1643
- const vertical = await verticalForScope(c, scope);
1644
- return c.json(vertical
1645
- ? await vertical.queryScope(scopeId, input)
1646
- : await admin.queryScope(c.get('actor'), tenantId, scopeId, input));
1711
+ return c.json(await delegatedRead(
1712
+ // The SQL is the argument the co-located branch logs, so it is the argument
1713
+ // here — an audit row for the read-only console that did not carry the
1714
+ // statement would answer "someone queried" and nothing a reader needs.
1715
+ c, tenantId, scopeId, scope, 'queryScope', input, {
1716
+ viaVertical: (v) => v.queryScope(scopeId, input),
1717
+ colocated: () => admin.queryScope(c.get('actor'), tenantId, scopeId, input),
1718
+ }, (r) => r.rows.length));
1647
1719
  });
1648
1720
  // The K-35 denial log (#867) — the third of the platform's three logs and the last to
1649
1721
  // get a reader. `/admin-log` holds staff mutations and the K-24 access log staff
@@ -1667,10 +1739,10 @@ export function createControlPlaneApi(options) {
1667
1739
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1668
1740
  if (!scope)
1669
1741
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1670
- const vertical = await verticalForScope(c, scope);
1671
- return c.json(vertical
1672
- ? await vertical.listDenials(scopeId, filter)
1673
- : await admin.listDenials(c.get('actor'), tenantId, scopeId, filter));
1742
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listDenials', filter, {
1743
+ viaVertical: (v) => v.listDenials(scopeId, filter),
1744
+ colocated: () => admin.listDenials(c.get('actor'), tenantId, scopeId, filter),
1745
+ }, (r) => r.length));
1674
1746
  });
1675
1747
  app.get('/tenants/:tenantId/scopes/:scopeId/denials/summary', async (c) => {
1676
1748
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
@@ -1683,10 +1755,10 @@ export function createControlPlaneApi(options) {
1683
1755
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1684
1756
  if (!scope)
1685
1757
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1686
- const vertical = await verticalForScope(c, scope);
1687
- return c.json(vertical
1688
- ? await vertical.summarizeDenials(scopeId, filter)
1689
- : await admin.summarizeDenials(c.get('actor'), tenantId, scopeId, filter));
1758
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'summarizeDenials', filter, {
1759
+ viaVertical: (v) => v.summarizeDenials(scopeId, filter),
1760
+ colocated: () => admin.summarizeDenials(c.get('actor'), tenantId, scopeId, filter),
1761
+ }, (r) => r.buckets.length));
1690
1762
  });
1691
1763
  // The owner seat (#925). Both reads go to the VERTICAL — the seat lives in its identity
1692
1764
  // directory, in its deployment — after the same K-3 cross-check every scope-addressed read
@@ -2113,6 +2185,38 @@ export function createControlPlaneApi(options) {
2113
2185
  // vertical must never be asked to wipe a live scope) and re-checked below the seam by
2114
2186
  // reapScope. Same storage-before-row ordering as deleteSnapshot: the vertical wipes its
2115
2187
  // co-located DO first, then the in-process reapScope flips the status and audits.
2188
+ // #1334 — reopen one scope's drained events so the Tier-2 drain ships them again: the
2189
+ // rebuild path for a lake table that was dropped (a Pipelines stream cannot change its
2190
+ // schema in place, so a new column means a new table, and every stamped row would
2191
+ // otherwise be history the drain never offers again). Staff/service only — deliberately
2192
+ // absent from BUILDER_ROUTES: it re-exports a tenant's payloads, and nothing a builder
2193
+ // does should be able to cause that. `scripts/lake-redrain.mjs` walks the fleet with it.
2194
+ app.post('/tenants/:tenantId/scopes/:scopeId/redrain-events', async (c) => {
2195
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
2196
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
2197
+ const actor = c.get('actor');
2198
+ const parsed = redrainEventsInput.safeParse(await c.req.json().catch(() => ({})));
2199
+ if (!parsed.success) {
2200
+ return c.json({ error: 'body must be { drainedBefore: <ISO 8601 instant> } — the end of the window to reopen' }, 400);
2201
+ }
2202
+ // Refused at the door rather than left to the adapters: an instant in the future
2203
+ // reopens rows the drain shipped AFTER the rebuild, which is exactly the double-write
2204
+ // the required instant exists to prevent. Host code, so it may read the real clock.
2205
+ if (Date.parse(parsed.data.drainedBefore) > Date.now()) {
2206
+ return c.json({
2207
+ error: `drainedBefore ${parsed.data.drainedBefore} is in the future — it would reopen rows ` +
2208
+ 'already shipped to the rebuilt table. Pass the instant the old table stopped receiving.',
2209
+ }, 400);
2210
+ }
2211
+ const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
2212
+ if (!scope)
2213
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
2214
+ const redrained = await admin.redrainEvents(actor, tenantId, scopeId, parsed.data);
2215
+ // `more` rather than silence: the verb reopens a bounded batch, so a caller that posts
2216
+ // once can be holding a partial reopen. Saying so is what stops it reading as a finished
2217
+ // window — the operator (or `pnpm lake:redrain`) posts again until `redrained` is 0.
2218
+ return c.json({ redrained, more: redrained >= REDRAIN_BATCH, drainedBefore: parsed.data.drainedBefore });
2219
+ });
2116
2220
  app.post('/tenants/:tenantId/scopes/:scopeId/reap', async (c) => {
2117
2221
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
2118
2222
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -3784,7 +3888,35 @@ export function createControlPlaneApi(options) {
3784
3888
  level: z.enum(['log', 'info', 'warn', 'error', 'debug']).optional(),
3785
3889
  search: z.string().min(1).max(200).optional(),
3786
3890
  hours: z.coerce.number().int().min(1).max(72).default(24),
3891
+ // The chart's time cursor (#1447): a window that ENDS in the past, which `hours`
3892
+ // cannot spell — it always ends at now.
3893
+ since: z.string().datetime({ offset: true }).optional(),
3894
+ until: z.string().datetime({ offset: true }).optional(),
3787
3895
  limit: z.coerce.number().int().min(1).max(200).default(100),
3896
+ })
3897
+ .superRefine((v, ctx) => {
3898
+ const now = Date.now();
3899
+ const to = v.until ? Date.parse(v.until) : now;
3900
+ const from = v.since ? Date.parse(v.since) : to - v.hours * 3_600_000;
3901
+ // On the resolved bounds rather than on the pair, so a lone `since` in the future
3902
+ // is refused by the same line: with no `until` it is measured against now, and
3903
+ // without this it would reach the backend as a window that cannot contain
3904
+ // anything and come back as an empty page nobody could explain.
3905
+ if (from >= to) {
3906
+ ctx.addIssue({ code: 'custom', path: ['since'], message: 'since must be before until' });
3907
+ }
3908
+ // The SAME ceiling `hours` carries, applied to the window however it was spelled.
3909
+ // Otherwise the pair is a second door onto a read the first one caps at three
3910
+ // days, and the two spellings together would read past what either allows.
3911
+ if (to - from > 72 * 3_600_000) {
3912
+ ctx.addIssue({ code: 'custom', path: ['since'], message: 'the window may not exceed 72 hours' });
3913
+ }
3914
+ // A window ending in the future returns nothing and says nothing about why, which
3915
+ // a reader takes for "my app was quiet". Five minutes of slack, because the
3916
+ // instant is stamped by a browser's clock rather than by ours.
3917
+ if (to > now + 5 * 60_000) {
3918
+ ctx.addIssue({ code: 'custom', path: ['until'], message: 'until may not be more than 5 minutes in the future' });
3919
+ }
3788
3920
  })
3789
3921
  .parse({
3790
3922
  tenantId,
@@ -3793,6 +3925,8 @@ export function createControlPlaneApi(options) {
3793
3925
  level: c.req.query('level') || undefined,
3794
3926
  search: c.req.query('search') || undefined,
3795
3927
  hours: c.req.query('hours'),
3928
+ since: c.req.query('since') || undefined,
3929
+ until: c.req.query('until') || undefined,
3796
3930
  limit: c.req.query('limit'),
3797
3931
  });
3798
3932
  return c.json(await options.observability.tenantLogs(input));