@substrat-run/control-plane-api 0.108.0 → 0.110.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,4 +1,5 @@
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';
@@ -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, 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, 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';
@@ -248,8 +248,15 @@ const auditLogQuery = z.object({
248
248
  * The K-35 denial-log filter (#867). Bounded by default like every other HTTP read
249
249
  * here: the log's volume is attacker-influenceable by design (a probing client mints
250
250
  * rows), so an unbounded `GET` is exactly the wrong default.
251
+ *
252
+ * The DECODER half of contracts' `denialFilterParams` (#971), and deliberately not the
253
+ * same schema: a query param arrives as a string, so `limit` is coerced here and given
254
+ * this route's own default, which the filter itself must not carry. What the two DO owe
255
+ * each other is the field set — Zod strips an unknown key, so a field added to the
256
+ * filter and sent by every client would be dropped here in silence. Exported (not from
257
+ * `index.ts` — this is not public surface) so `api.test.ts` can pin that.
251
258
  */
252
- const denialLogQuery = z.object({
259
+ export const denialLogQuery = z.object({
253
260
  actor: z.string().optional(),
254
261
  permission: z.string().optional(),
255
262
  operation: z.string().optional(),
@@ -491,6 +498,13 @@ export function createControlPlaneApi(options) {
491
498
  // go. Tenant-narrowed in the handler (the forced-filter pattern); the allowlist
492
499
  // alone is not authz.
493
500
  { method: 'GET', re: /\/sweep-runs$/ },
501
+ // MY traffic and MY logs — the tenant grain (observability.md §3 view 4). The only
502
+ // observability routes a non-staff caller may reach, and safe to reach precisely
503
+ // because they are a different grain: their backends are keyed on the tenant, so
504
+ // there is no fleet-wide answer for a forgotten filter to fall back to. Narrowed in
505
+ // the handler by the forced-filter pattern; the allowlist alone is not authz.
506
+ { method: 'GET', re: /\/observability\/tenant-metrics$/ },
507
+ { method: 'GET', re: /\/observability\/tenant-logs$/ },
494
508
  ];
495
509
  app.use('*', async (c, next) => {
496
510
  if (c.get('principal').kind === 'builder') {
@@ -1519,6 +1533,29 @@ export function createControlPlaneApi(options) {
1519
1533
  throw e;
1520
1534
  }
1521
1535
  });
1536
+ // #1239: facets over one scope's outbox — narrow, group, count. Delegated like
1537
+ // the history read below: through the vertical that holds the data when one
1538
+ // resolves, the co-located host otherwise.
1539
+ app.get('/tenants/:tenantId/scopes/:scopeId/facets', async (c) => {
1540
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1541
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1542
+ const input = eventFacetInput.parse({
1543
+ groupBy: c.req.query('field') !== undefined
1544
+ ? { kind: 'payload', field: c.req.query('field') }
1545
+ : { kind: c.req.query('groupBy') },
1546
+ type: c.req.query('type') ?? undefined,
1547
+ since: c.req.query('since') ?? undefined,
1548
+ until: c.req.query('until') ?? undefined,
1549
+ limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
1550
+ });
1551
+ const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1552
+ if (!scope)
1553
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1554
+ const vertical = await verticalForScope(c, scope);
1555
+ return c.json(vertical
1556
+ ? await vertical.facetEvents(scopeId, input)
1557
+ : await admin.facetEvents(c.get('actor'), tenantId, scopeId, input));
1558
+ });
1522
1559
  // #1235: one record's event history — the per-entity pivot of the audit spine.
1523
1560
  // Delegated exactly like the table reads: through the vertical that holds the
1524
1561
  // scope's data when one resolves, the co-located host otherwise.
@@ -2849,6 +2886,39 @@ export function createControlPlaneApi(options) {
2849
2886
  // sibling route would cost a second full parse for a field sitting beside this one.
2850
2887
  return c.json({ schedules: parsed?.schedules ?? null, freshness: parsed?.freshness ?? null });
2851
2888
  });
2889
+ /**
2890
+ * The declared FLOW of one version (#1234): every event type its modules say they
2891
+ * emit or consume, and the providers the vertical requires — the declared half of
2892
+ * a declared-vs-observed finding.
2893
+ *
2894
+ * Its own route rather than a ride on `/model`, unlike freshness on `/schedules`:
2895
+ * the model is the largest thing on a manifest, and the flow card needs none of it.
2896
+ * `declaredEvents` is null for a version pushed before the field existed, which the
2897
+ * caller must keep apart from a version that declares no events at all — the second
2898
+ * is a finding, the first is nothing to say.
2899
+ */
2900
+ app.get('/verticals/:slug/versions/:id/flow', async (c) => {
2901
+ const p = c.get('principal');
2902
+ const slug = await resolveVerticalId(c, c.req.param('slug'));
2903
+ if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
2904
+ return c.json({ error: 'not found' }, 404);
2905
+ }
2906
+ const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
2907
+ const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
2908
+ return c.json({
2909
+ declaredEvents: parsed?.declaredEvents ?? null,
2910
+ // Whether that surface is the whole declaration or a sample cut at the cap. The
2911
+ // reader's "N of M checked" is a completeness claim, and it may not be made about
2912
+ // a manifest that says it was truncated.
2913
+ declaredEventsTruncated: parsed?.declaredEventsTruncated ?? false,
2914
+ requires: parsed?.requires ?? [],
2915
+ // The other two bands of the flow map (#1234), off the same parse. `schedules`
2916
+ // also has its own route, which answers a different question — did the run
2917
+ // happen — while this one wants the declaration as a node.
2918
+ schedules: parsed?.schedules ?? [],
2919
+ outbound: parsed?.outbound ?? [],
2920
+ });
2921
+ });
2852
2922
  app.get('/verticals/:slug/versions/:id/model', async (c) => {
2853
2923
  const p = c.get('principal');
2854
2924
  const slug = await resolveVerticalId(c, c.req.param('slug'));
@@ -3597,6 +3667,75 @@ export function createControlPlaneApi(options) {
3597
3667
  });
3598
3668
  return c.json(await options.observability.recentLogs(input));
3599
3669
  });
3670
+ // -- the TENANT grain (observability.md §3 view 4, §4.2) -----------------------
3671
+ //
3672
+ // Everything above is script grain: one deployed unit, which serves every tenant that
3673
+ // installed the vertical. Safe for staff and for the vertical's builder, forbidden to
3674
+ // an installer — §3 is explicit that per-tenant numbers from script-grain data leak.
3675
+ // These two routes are the other grain, and the difference is not a filter over the
3676
+ // same data: they read the router's own tenant-stamped datapoints and the verticals'
3677
+ // stamped log lines, which is the only place the tenant dimension exists at all.
3678
+ //
3679
+ // Unlike their neighbours these ARE in BUILDER_ROUTES, because the whole point is that
3680
+ // an installer may read them — about themselves. The tenant comes from the principal
3681
+ // for a builder and must be named explicitly by a staff/service caller: the
3682
+ // `/service-refs` forced-filter pattern, for its reason, since answering fleet-wide on
3683
+ // a forgotten parameter is the exact leak being closed here.
3684
+ app.get('/observability/tenant-metrics', async (c) => {
3685
+ if (!options.observability?.tenantMetrics) {
3686
+ return c.json({ error: 'tenant-grain observability is not configured on this control plane' }, 501);
3687
+ }
3688
+ const p = c.get('principal');
3689
+ const tenantId = p.kind === 'builder' ? p.tenantId : c.req.query('tenantId');
3690
+ if (!tenantId)
3691
+ throw new ControlPlaneError(400, 'tenantId is required');
3692
+ const input = z
3693
+ .object({
3694
+ tenantId: tenantIdSchema,
3695
+ // Narrowing WITHIN the tenant. Neither can widen the answer — the reader always
3696
+ // applies the tenant predicate — so a foreign scope id yields zero rows rather
3697
+ // than somebody else's, and needs no ownership check of its own to be safe.
3698
+ scopeId: z.string().min(1).max(64).optional(),
3699
+ vertical: z.string().min(1).max(200).optional(),
3700
+ hours: z.coerce.number().int().min(1).max(72).default(24),
3701
+ })
3702
+ .parse({
3703
+ tenantId,
3704
+ scopeId: c.req.query('scopeId') || undefined,
3705
+ vertical: c.req.query('vertical') || undefined,
3706
+ hours: c.req.query('hours'),
3707
+ });
3708
+ return c.json(await options.observability.tenantMetrics(input));
3709
+ });
3710
+ app.get('/observability/tenant-logs', async (c) => {
3711
+ if (!options.observability?.tenantLogs) {
3712
+ return c.json({ error: 'tenant-grain observability is not configured on this control plane' }, 501);
3713
+ }
3714
+ const p = c.get('principal');
3715
+ const tenantId = p.kind === 'builder' ? p.tenantId : c.req.query('tenantId');
3716
+ if (!tenantId)
3717
+ throw new ControlPlaneError(400, 'tenantId is required');
3718
+ const input = z
3719
+ .object({
3720
+ tenantId: tenantIdSchema,
3721
+ scopeId: z.string().min(1).max(64).optional(),
3722
+ vertical: z.string().min(1).max(200).optional(),
3723
+ level: z.enum(['log', 'info', 'warn', 'error', 'debug']).optional(),
3724
+ search: z.string().min(1).max(200).optional(),
3725
+ hours: z.coerce.number().int().min(1).max(72).default(24),
3726
+ limit: z.coerce.number().int().min(1).max(200).default(100),
3727
+ })
3728
+ .parse({
3729
+ tenantId,
3730
+ scopeId: c.req.query('scopeId') || undefined,
3731
+ vertical: c.req.query('vertical') || undefined,
3732
+ level: c.req.query('level') || undefined,
3733
+ search: c.req.query('search') || undefined,
3734
+ hours: c.req.query('hours'),
3735
+ limit: c.req.query('limit'),
3736
+ });
3737
+ return c.json(await options.observability.tenantLogs(input));
3738
+ });
3600
3739
  /**
3601
3740
  * Observed-vs-declared outbound egress for a vertical's deployed versions (#859, D-46).
3602
3741
  *