@substrat-run/control-plane-api 0.111.0 → 0.112.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 +4 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +126 -41
- package/dist/api.js.map +1 -1
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +22 -8
- package/dist/cf-observability.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +11 -0
- package/dist/observability.d.ts.map +1 -1
- package/dist/pipelines-sink.d.ts +23 -0
- package/dist/pipelines-sink.d.ts.map +1 -0
- package/dist/pipelines-sink.js +125 -0
- package/dist/pipelines-sink.js.map +1 -0
- package/dist/vertical-client.d.ts +13 -1
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +17 -0
- package/dist/vertical-client.js.map +1 -1
- package/package.json +6 -6
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":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,
|
|
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, 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, } 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
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
:
|
|
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
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
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
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
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,10 @@ 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
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
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));
|
|
1594
1645
|
});
|
|
1595
1646
|
// #1235: one record's event history — the per-entity pivot of the audit spine.
|
|
1596
1647
|
// Delegated exactly like the table reads: through the vertical that holds the
|
|
@@ -1607,10 +1658,10 @@ export function createControlPlaneApi(options) {
|
|
|
1607
1658
|
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
1608
1659
|
if (!scope)
|
|
1609
1660
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1661
|
+
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'entityHistory', input, {
|
|
1662
|
+
viaVertical: (v) => v.entityHistory(scopeId, input),
|
|
1663
|
+
colocated: () => admin.entityHistory(c.get('actor'), tenantId, scopeId, input),
|
|
1664
|
+
}, (r) => r.entries.length));
|
|
1614
1665
|
});
|
|
1615
1666
|
app.get('/tenants/:tenantId/scopes/:scopeId/tables/:table', async (c) => {
|
|
1616
1667
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
@@ -1623,10 +1674,10 @@ export function createControlPlaneApi(options) {
|
|
|
1623
1674
|
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
1624
1675
|
if (!scope)
|
|
1625
1676
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1677
|
+
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'readScopeTable', input, {
|
|
1678
|
+
viaVertical: (v) => v.readScopeTable(scopeId, input),
|
|
1679
|
+
colocated: () => admin.readScopeTable(c.get('actor'), tenantId, scopeId, input),
|
|
1680
|
+
}, (r) => r.rows.length));
|
|
1630
1681
|
});
|
|
1631
1682
|
// The SQL console (#219): one read-only statement, POSTed because SQL does not
|
|
1632
1683
|
// belong in a URL. Same delegation as the table reads; the gate's refusal maps to
|
|
@@ -1640,10 +1691,14 @@ export function createControlPlaneApi(options) {
|
|
|
1640
1691
|
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
1641
1692
|
if (!scope)
|
|
1642
1693
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1694
|
+
return c.json(await delegatedRead(
|
|
1695
|
+
// The SQL is the argument the co-located branch logs, so it is the argument
|
|
1696
|
+
// here — an audit row for the read-only console that did not carry the
|
|
1697
|
+
// statement would answer "someone queried" and nothing a reader needs.
|
|
1698
|
+
c, tenantId, scopeId, scope, 'queryScope', input, {
|
|
1699
|
+
viaVertical: (v) => v.queryScope(scopeId, input),
|
|
1700
|
+
colocated: () => admin.queryScope(c.get('actor'), tenantId, scopeId, input),
|
|
1701
|
+
}, (r) => r.rows.length));
|
|
1647
1702
|
});
|
|
1648
1703
|
// The K-35 denial log (#867) — the third of the platform's three logs and the last to
|
|
1649
1704
|
// get a reader. `/admin-log` holds staff mutations and the K-24 access log staff
|
|
@@ -1667,10 +1722,10 @@ export function createControlPlaneApi(options) {
|
|
|
1667
1722
|
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
1668
1723
|
if (!scope)
|
|
1669
1724
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1725
|
+
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listDenials', filter, {
|
|
1726
|
+
viaVertical: (v) => v.listDenials(scopeId, filter),
|
|
1727
|
+
colocated: () => admin.listDenials(c.get('actor'), tenantId, scopeId, filter),
|
|
1728
|
+
}, (r) => r.length));
|
|
1674
1729
|
});
|
|
1675
1730
|
app.get('/tenants/:tenantId/scopes/:scopeId/denials/summary', async (c) => {
|
|
1676
1731
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
@@ -1683,10 +1738,10 @@ export function createControlPlaneApi(options) {
|
|
|
1683
1738
|
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
1684
1739
|
if (!scope)
|
|
1685
1740
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1741
|
+
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'summarizeDenials', filter, {
|
|
1742
|
+
viaVertical: (v) => v.summarizeDenials(scopeId, filter),
|
|
1743
|
+
colocated: () => admin.summarizeDenials(c.get('actor'), tenantId, scopeId, filter),
|
|
1744
|
+
}, (r) => r.buckets.length));
|
|
1690
1745
|
});
|
|
1691
1746
|
// The owner seat (#925). Both reads go to the VERTICAL — the seat lives in its identity
|
|
1692
1747
|
// directory, in its deployment — after the same K-3 cross-check every scope-addressed read
|
|
@@ -3784,7 +3839,35 @@ export function createControlPlaneApi(options) {
|
|
|
3784
3839
|
level: z.enum(['log', 'info', 'warn', 'error', 'debug']).optional(),
|
|
3785
3840
|
search: z.string().min(1).max(200).optional(),
|
|
3786
3841
|
hours: z.coerce.number().int().min(1).max(72).default(24),
|
|
3842
|
+
// The chart's time cursor (#1447): a window that ENDS in the past, which `hours`
|
|
3843
|
+
// cannot spell — it always ends at now.
|
|
3844
|
+
since: z.string().datetime({ offset: true }).optional(),
|
|
3845
|
+
until: z.string().datetime({ offset: true }).optional(),
|
|
3787
3846
|
limit: z.coerce.number().int().min(1).max(200).default(100),
|
|
3847
|
+
})
|
|
3848
|
+
.superRefine((v, ctx) => {
|
|
3849
|
+
const now = Date.now();
|
|
3850
|
+
const to = v.until ? Date.parse(v.until) : now;
|
|
3851
|
+
const from = v.since ? Date.parse(v.since) : to - v.hours * 3_600_000;
|
|
3852
|
+
// On the resolved bounds rather than on the pair, so a lone `since` in the future
|
|
3853
|
+
// is refused by the same line: with no `until` it is measured against now, and
|
|
3854
|
+
// without this it would reach the backend as a window that cannot contain
|
|
3855
|
+
// anything and come back as an empty page nobody could explain.
|
|
3856
|
+
if (from >= to) {
|
|
3857
|
+
ctx.addIssue({ code: 'custom', path: ['since'], message: 'since must be before until' });
|
|
3858
|
+
}
|
|
3859
|
+
// The SAME ceiling `hours` carries, applied to the window however it was spelled.
|
|
3860
|
+
// Otherwise the pair is a second door onto a read the first one caps at three
|
|
3861
|
+
// days, and the two spellings together would read past what either allows.
|
|
3862
|
+
if (to - from > 72 * 3_600_000) {
|
|
3863
|
+
ctx.addIssue({ code: 'custom', path: ['since'], message: 'the window may not exceed 72 hours' });
|
|
3864
|
+
}
|
|
3865
|
+
// A window ending in the future returns nothing and says nothing about why, which
|
|
3866
|
+
// a reader takes for "my app was quiet". Five minutes of slack, because the
|
|
3867
|
+
// instant is stamped by a browser's clock rather than by ours.
|
|
3868
|
+
if (to > now + 5 * 60_000) {
|
|
3869
|
+
ctx.addIssue({ code: 'custom', path: ['until'], message: 'until may not be more than 5 minutes in the future' });
|
|
3870
|
+
}
|
|
3788
3871
|
})
|
|
3789
3872
|
.parse({
|
|
3790
3873
|
tenantId,
|
|
@@ -3793,6 +3876,8 @@ export function createControlPlaneApi(options) {
|
|
|
3793
3876
|
level: c.req.query('level') || undefined,
|
|
3794
3877
|
search: c.req.query('search') || undefined,
|
|
3795
3878
|
hours: c.req.query('hours'),
|
|
3879
|
+
since: c.req.query('since') || undefined,
|
|
3880
|
+
until: c.req.query('until') || undefined,
|
|
3796
3881
|
limit: c.req.query('limit'),
|
|
3797
3882
|
});
|
|
3798
3883
|
return c.json(await options.observability.tenantLogs(input));
|