@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 +22 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +103 -2
- package/dist/api.js.map +1 -1
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +126 -34
- package/dist/cf-observability.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +10 -25
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +54 -0
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +6 -1
- package/dist/observability.js.map +1 -1
- package/dist/vertical-client.d.ts +5 -1
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +22 -16
- package/dist/vertical-client.js.map +1 -1
- package/package.json +5 -5
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
|
|
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":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;
|
|
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);
|