@substrat-run/control-plane-api 0.109.0 → 0.111.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
@@ -1,11 +1,12 @@
1
1
  import { Hono } from 'hono';
2
+ import { z } from '@substrat-run/contracts';
2
3
  import type { Connection, ConnectionActivity, ConnectionActivitySource, ConnectionCredential, ConnectionProbe, PlatformActorId } from '@substrat-run/contracts';
3
4
  import type { ScopeHost } from '@substrat-run/kernel';
4
5
  import type { PlatformActorAuth, BuilderAuth, Principal } from './auth.js';
5
6
  import { type VerticalClient } from './vertical-client.js';
6
7
  import type { DeployVerticalFn, FetchVerticalAssetFn, FetchVerticalModulesFn } from './deploy.js';
7
8
  import type { PatchScriptBindingsFn } from './wfp.js';
8
- import type { ObservabilityReader } from './observability.js';
9
+ import { type ObservabilityReader } from './observability.js';
9
10
  import type { PlatformRuntime } from './platform-runtime.js';
10
11
  import { type DoNamespaceReader } from './do-namespaces.js';
11
12
  import type { DirectoryBackupStore, ScopeBackupStore } from './backups.js';
@@ -288,6 +289,26 @@ type Vars = {
288
289
  actor: PlatformActorId;
289
290
  principal: Principal;
290
291
  };
292
+ /**
293
+ * The K-35 denial-log filter (#867). Bounded by default like every other HTTP read
294
+ * here: the log's volume is attacker-influenceable by design (a probing client mints
295
+ * rows), so an unbounded `GET` is exactly the wrong default.
296
+ *
297
+ * The DECODER half of contracts' `denialFilterParams` (#971), and deliberately not the
298
+ * same schema: a query param arrives as a string, so `limit` is coerced here and given
299
+ * this route's own default, which the filter itself must not carry. What the two DO owe
300
+ * each other is the field set — Zod strips an unknown key, so a field added to the
301
+ * filter and sent by every client would be dropped here in silence. Exported (not from
302
+ * `index.ts` — this is not public surface) so `api.test.ts` can pin that.
303
+ */
304
+ export declare const denialLogQuery: z.ZodObject<{
305
+ actor: z.ZodOptional<z.ZodString>;
306
+ permission: z.ZodOptional<z.ZodString>;
307
+ operation: z.ZodOptional<z.ZodString>;
308
+ since: z.ZodOptional<z.ZodString>;
309
+ until: z.ZodOptional<z.ZodString>;
310
+ limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
311
+ }, z.core.$strip>;
291
312
  export declare const DEFAULT_MODEL_MARGIN_PERCENT = 20;
292
313
  /**
293
314
  * The audited HTTP surface over `HostAdmin` (control-plane.md §4.5).
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, 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, 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';
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';
@@ -28,6 +28,7 @@ function problem(c, answer) {
28
28
  }
29
29
  import { backfillDeclaredStores, blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, missingStoresForTenant, tenantStoreBindings, } from './tenant-stores.js';
30
30
  import { mintPushToken, pushActorFor } from './push-token.js';
31
+ import { TENANT_SERIES_SCOPE_CAP } from './observability.js';
31
32
  import { namespacesForScript } from './do-namespaces.js';
32
33
  import { backupDirectoryIfDue } from './directory-backup.js';
33
34
  import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
@@ -248,8 +249,15 @@ const auditLogQuery = z.object({
248
249
  * The K-35 denial-log filter (#867). Bounded by default like every other HTTP read
249
250
  * here: the log's volume is attacker-influenceable by design (a probing client mints
250
251
  * rows), so an unbounded `GET` is exactly the wrong default.
252
+ *
253
+ * The DECODER half of contracts' `denialFilterParams` (#971), and deliberately not the
254
+ * same schema: a query param arrives as a string, so `limit` is coerced here and given
255
+ * this route's own default, which the filter itself must not carry. What the two DO owe
256
+ * each other is the field set — Zod strips an unknown key, so a field added to the
257
+ * filter and sent by every client would be dropped here in silence. Exported (not from
258
+ * `index.ts` — this is not public surface) so `api.test.ts` can pin that.
251
259
  */
252
- const denialLogQuery = z.object({
260
+ export const denialLogQuery = z.object({
253
261
  actor: z.string().optional(),
254
262
  permission: z.string().optional(),
255
263
  operation: z.string().optional(),
@@ -497,6 +505,7 @@ export function createControlPlaneApi(options) {
497
505
  // there is no fleet-wide answer for a forgotten filter to fall back to. Narrowed in
498
506
  // the handler by the forced-filter pattern; the allowlist alone is not authz.
499
507
  { method: 'GET', re: /\/observability\/tenant-metrics$/ },
508
+ { method: 'GET', re: /\/observability\/tenant-metrics-series$/ },
500
509
  { method: 'GET', re: /\/observability\/tenant-logs$/ },
501
510
  ];
502
511
  app.use('*', async (c, next) => {
@@ -1549,6 +1558,40 @@ export function createControlPlaneApi(options) {
1549
1558
  ? await vertical.facetEvents(scopeId, input)
1550
1559
  : await admin.facetEvents(c.get('actor'), tenantId, scopeId, input));
1551
1560
  });
1561
+ // #1237: one event's causal chain, walked backwards. Delegated exactly like the
1562
+ // history read below — through the vertical holding the scope's data when one
1563
+ // resolves, the co-located host otherwise.
1564
+ app.get('/tenants/:tenantId/scopes/:scopeId/cause', async (c) => {
1565
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1566
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1567
+ const input = eventCauseInput.parse({
1568
+ eventId: c.req.query('eventId'),
1569
+ maxDepth: c.req.query('maxDepth') ? Number(c.req.query('maxDepth')) : undefined,
1570
+ });
1571
+ const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1572
+ if (!scope)
1573
+ 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));
1578
+ });
1579
+ // #1237 forward: what one event set off. Delegated like its backwards twin.
1580
+ app.get('/tenants/:tenantId/scopes/:scopeId/effects', async (c) => {
1581
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1582
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1583
+ const input = eventEffectsInput.parse({
1584
+ eventId: c.req.query('eventId'),
1585
+ maxNodes: c.req.query('maxNodes') ? Number(c.req.query('maxNodes')) : undefined,
1586
+ });
1587
+ const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1588
+ if (!scope)
1589
+ 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));
1594
+ });
1552
1595
  // #1235: one record's event history — the per-entity pivot of the audit spine.
1553
1596
  // Delegated exactly like the table reads: through the vertical that holds the
1554
1597
  // scope's data when one resolves, the co-located host otherwise.
@@ -2879,6 +2922,39 @@ export function createControlPlaneApi(options) {
2879
2922
  // sibling route would cost a second full parse for a field sitting beside this one.
2880
2923
  return c.json({ schedules: parsed?.schedules ?? null, freshness: parsed?.freshness ?? null });
2881
2924
  });
2925
+ /**
2926
+ * The declared FLOW of one version (#1234): every event type its modules say they
2927
+ * emit or consume, and the providers the vertical requires — the declared half of
2928
+ * a declared-vs-observed finding.
2929
+ *
2930
+ * Its own route rather than a ride on `/model`, unlike freshness on `/schedules`:
2931
+ * the model is the largest thing on a manifest, and the flow card needs none of it.
2932
+ * `declaredEvents` is null for a version pushed before the field existed, which the
2933
+ * caller must keep apart from a version that declares no events at all — the second
2934
+ * is a finding, the first is nothing to say.
2935
+ */
2936
+ app.get('/verticals/:slug/versions/:id/flow', async (c) => {
2937
+ const p = c.get('principal');
2938
+ const slug = await resolveVerticalId(c, c.req.param('slug'));
2939
+ if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
2940
+ return c.json({ error: 'not found' }, 404);
2941
+ }
2942
+ const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
2943
+ const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
2944
+ return c.json({
2945
+ declaredEvents: parsed?.declaredEvents ?? null,
2946
+ // Whether that surface is the whole declaration or a sample cut at the cap. The
2947
+ // reader's "N of M checked" is a completeness claim, and it may not be made about
2948
+ // a manifest that says it was truncated.
2949
+ declaredEventsTruncated: parsed?.declaredEventsTruncated ?? false,
2950
+ requires: parsed?.requires ?? [],
2951
+ // The other two bands of the flow map (#1234), off the same parse. `schedules`
2952
+ // also has its own route, which answers a different question — did the run
2953
+ // happen — while this one wants the declaration as a node.
2954
+ schedules: parsed?.schedules ?? [],
2955
+ outbound: parsed?.outbound ?? [],
2956
+ });
2957
+ });
2882
2958
  app.get('/verticals/:slug/versions/:id/model', async (c) => {
2883
2959
  const p = c.get('principal');
2884
2960
  const slug = await resolveVerticalId(c, c.req.param('slug'));
@@ -3667,6 +3743,31 @@ export function createControlPlaneApi(options) {
3667
3743
  });
3668
3744
  return c.json(await options.observability.tenantMetrics(input));
3669
3745
  });
3746
+ // The bucketed twin (#1447): the series a team-level chart plots, one line per app.
3747
+ // Same grain, same forced tenant, same 501 when the reader cannot bucket — an empty
3748
+ // series would render as "quiet". `scopeId` repeats, like `service` on the script-grain
3749
+ // series, so "all my apps" is one read; it is capped because the answer is scopes ×
3750
+ // buckets, and at least one is required because an unnarrowed ask is not on offer.
3751
+ app.get('/observability/tenant-metrics-series', async (c) => {
3752
+ if (!options.observability?.tenantMetricsSeries) {
3753
+ return c.json({ error: 'bucketed tenant-grain observability is not configured on this control plane' }, 501);
3754
+ }
3755
+ const p = c.get('principal');
3756
+ const tenantId = p.kind === 'builder' ? p.tenantId : c.req.query('tenantId');
3757
+ if (!tenantId)
3758
+ throw new ControlPlaneError(400, 'tenantId is required');
3759
+ const scopeIds = (c.req.queries('scopeId') ?? []).filter((s) => s.length > 0);
3760
+ const input = z
3761
+ .object({
3762
+ tenantId: tenantIdSchema,
3763
+ // Narrowing WITHIN the tenant, as on /tenant-metrics: a foreign scope id yields
3764
+ // zero rows rather than somebody else's, so no ownership check is needed here.
3765
+ scopeIds: z.array(z.string().min(1).max(64)).min(1).max(TENANT_SERIES_SCOPE_CAP),
3766
+ hours: z.coerce.number().int().min(1).max(72).default(24),
3767
+ })
3768
+ .parse({ tenantId, scopeIds, hours: c.req.query('hours') });
3769
+ return c.json(await options.observability.tenantMetricsSeries(input));
3770
+ });
3670
3771
  app.get('/observability/tenant-logs', async (c) => {
3671
3772
  if (!options.observability?.tenantLogs) {
3672
3773
  return c.json({ error: 'tenant-grain observability is not configured on this control plane' }, 501);