@substrat-run/control-plane-api 0.138.0 → 0.140.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 +14 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +398 -49
- package/dist/api.js.map +1 -1
- package/dist/audited-change.d.ts +139 -0
- package/dist/audited-change.d.ts.map +1 -0
- package/dist/audited-change.js +214 -0
- package/dist/audited-change.js.map +1 -0
- package/dist/connect-links.d.ts +29 -0
- package/dist/connect-links.d.ts.map +1 -0
- package/dist/connect-links.js +139 -0
- package/dist/connect-links.js.map +1 -0
- package/dist/connect-url.d.ts +20 -1
- package/dist/connect-url.d.ts.map +1 -1
- package/dist/connect-url.js +1 -1
- package/dist/connect-url.js.map +1 -1
- package/dist/connection-relay.d.ts +24 -0
- package/dist/connection-relay.d.ts.map +1 -1
- package/dist/connection-relay.js +31 -12
- package/dist/connection-relay.js.map +1 -1
- package/dist/email-intent.d.ts +71 -0
- package/dist/email-intent.d.ts.map +1 -0
- package/dist/email-intent.js +186 -0
- package/dist/email-intent.js.map +1 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/platform-drain.d.ts +12 -2
- package/dist/platform-drain.d.ts.map +1 -1
- package/dist/platform-drain.js +15 -3
- package/dist/platform-drain.js.map +1 -1
- package/dist/reconcile.d.ts +17 -2
- package/dist/reconcile.d.ts.map +1 -1
- package/dist/reconcile.js +35 -1
- package/dist/reconcile.js.map +1 -1
- package/dist/staff-client.d.ts +3 -1
- package/dist/staff-client.d.ts.map +1 -1
- package/dist/staff-client.js +2 -0
- package/dist/staff-client.js.map +1 -1
- package/dist/vertical-client.d.ts +60 -2
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +111 -20
- package/dist/vertical-client.js.map +1 -1
- package/package.json +6 -6
package/dist/api.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { resolveObservabilityWindow } from './observability-window.js';
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
|
-
import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ownerTransferInput,
|
|
3
|
+
import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, channelName, connectLinkId, connectionActivity, connectionActivitySource, connectionCredential, connectionCertificate, connectionFilter, connectionHealthState, connectionProvider, connectorDispatchKind, PROVISION_SIBLING_KIND, ARCHIVE_SCOPE_KIND, PROVISION_TENANT_KIND, SET_ENTITLEMENTS_KIND, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, toConnectionHealthEntry, CONNECTION_EXPIRY_WARNING_DAYS, CONNECTION_STALE_DAYS, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, platformScriptCollision, platformScriptRefusal, PLATFORM_SCRIPT_NAMES, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, errorCodeOf, capabilityFilterQuery, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, } from '@substrat-run/contracts';
|
|
4
4
|
import { attributeFailure } from './failure-attribution.js';
|
|
5
|
-
import {
|
|
5
|
+
import { auditedChange, withAuditedOutcomes } from './audited-change.js';
|
|
6
|
+
import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isPrimaryScope, migrationProgress, ulid, auditWarningOf, } from '@substrat-run/kernel';
|
|
6
7
|
import { TENANT_HEADER, confinedTenant } from './auth.js';
|
|
7
8
|
import { mintTenantToken } from './tenant-token.js';
|
|
8
9
|
import { connectionGrantsForScope } from './vertical-client.js';
|
|
9
10
|
import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
|
|
10
11
|
import { versionReachedAt } from './scope-deployment.js';
|
|
11
12
|
import { reconcileConnectionGrants } from './connection-grants.js';
|
|
12
|
-
import { reconcileThenReassert, switchCarryFor } from './reconcile.js';
|
|
13
|
+
import { reconcileThenReassert, reviewedEntityGrants, switchCarryFor } from './reconcile.js';
|
|
13
14
|
import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
|
|
14
15
|
import { ControlPlaneError } from '@substrat-run/control-plane-client';
|
|
15
16
|
import { provisionSiblingScope } from './platform-drain.js';
|
|
@@ -478,6 +479,22 @@ const issueStatusUpdate = z.object({
|
|
|
478
479
|
fingerprint: z.string().min(1),
|
|
479
480
|
status: issueStatusInput,
|
|
480
481
|
});
|
|
482
|
+
// The findings read (#1748). No cursor, for the issues read's reason: the cardinality is the
|
|
483
|
+
// number of distinct shapes a tenant has. `tenantId` is forced for a confined principal.
|
|
484
|
+
const findingsQuery = z.object({
|
|
485
|
+
tenantId: tenantIdSchema.optional(),
|
|
486
|
+
status: findingStatus.optional(),
|
|
487
|
+
kind: findingKind.optional(),
|
|
488
|
+
limit: z.coerce.number().int().min(1).max(500).optional(),
|
|
489
|
+
});
|
|
490
|
+
const findingStatusUpdate = z.object({ status: findingStatusInput }).strict();
|
|
491
|
+
const findingRulesQuery = z.object({
|
|
492
|
+
active: z
|
|
493
|
+
.enum(['true', 'false'])
|
|
494
|
+
.optional()
|
|
495
|
+
.transform((v) => v === 'true'),
|
|
496
|
+
limit: z.coerce.number().int().min(1).max(500).optional(),
|
|
497
|
+
});
|
|
481
498
|
/**
|
|
482
499
|
* A TENANT-LEVEL role assignment under an addressed tenant (#1343) — which is
|
|
483
500
|
* what a team membership is, and all this surface does.
|
|
@@ -579,7 +596,16 @@ const TENANT_ROUTES = [
|
|
|
579
596
|
{ method: 'DELETE', re: /\/tenants\/[^/]+\/connections\/[^/]+$/, pin: 'path' },
|
|
580
597
|
{ method: 'GET', re: /\/tenants\/[^/]+\/connections\/[^/]+\/activity$/, pin: 'path' },
|
|
581
598
|
{ method: 'GET', re: /\/tenants\/[^/]+\/connections\/[^/]+\/credential$/, pin: 'path' },
|
|
599
|
+
// #2100: the PUBLIC certificate a connection signs in with — same pin as the credential view.
|
|
600
|
+
{ method: 'GET', re: /\/tenants\/[^/]+\/connections\/[^/]+\/certificate$/, pin: 'path' },
|
|
582
601
|
{ method: 'POST', re: /\/tenants\/[^/]+\/connections\/[^/]+\/verify$/, pin: 'path' },
|
|
602
|
+
// A vertical's mailed connect links (connections.md §3.5.4): the consent landing's liveness
|
|
603
|
+
// read, the callback's spend and its undo, and the integrations card's list and revoke.
|
|
604
|
+
{ method: 'GET', re: /\/tenants\/[^/]+\/connect-links$/, pin: 'path' },
|
|
605
|
+
{ method: 'GET', re: /\/tenants\/[^/]+\/connect-links\/[^/]+$/, pin: 'path' },
|
|
606
|
+
{ method: 'POST', re: /\/tenants\/[^/]+\/connect-links\/[^/]+\/consume$/, pin: 'path' },
|
|
607
|
+
{ method: 'POST', re: /\/tenants\/[^/]+\/connect-links\/[^/]+\/restore$/, pin: 'path' },
|
|
608
|
+
{ method: 'DELETE', re: /\/tenants\/[^/]+\/connect-links\/[^/]+$/, pin: 'path' },
|
|
583
609
|
// Members: identity links mirrored from the dashboard, orgs, role assignments.
|
|
584
610
|
{ method: 'GET', re: /\/tenants\/[^/]+\/identities$/, pin: 'path' },
|
|
585
611
|
{ method: 'PUT', re: /\/tenants\/[^/]+\/identities$/, pin: 'path' },
|
|
@@ -592,6 +618,12 @@ const TENANT_ROUTES = [
|
|
|
592
618
|
// grant (`tenantGrantableEntitlement`): the pin alone would let it grant itself any.
|
|
593
619
|
{ method: 'PUT', re: /\/tenants\/[^/]+\/entitlements\/[^/]+$/, pin: 'path' },
|
|
594
620
|
{ method: 'GET', re: /\/tenants\/[^/]+\/cross-vertical\/edges$/, pin: 'path' },
|
|
621
|
+
// Findings (#1748): the tenant's inbox, its verdicts and its suppress rules. The dashboard
|
|
622
|
+
// checks WHO inside the tenant may read or act (its findings permissions) before calling.
|
|
623
|
+
{ method: 'PUT', re: /\/tenants\/[^/]+\/findings\/[^/]+\/status$/, pin: 'path' },
|
|
624
|
+
{ method: 'GET', re: /\/tenants\/[^/]+\/finding-rules$/, pin: 'path' },
|
|
625
|
+
{ method: 'POST', re: /\/tenants\/[^/]+\/finding-rules$/, pin: 'path' },
|
|
626
|
+
{ method: 'DELETE', re: /\/tenants\/[^/]+\/finding-rules\/[^/]+$/, pin: 'path' },
|
|
595
627
|
// Scopes: provision, read, and the lifecycle acts the Data and Deployments tabs offer.
|
|
596
628
|
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes$/, pin: 'path' },
|
|
597
629
|
{ method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+$/, pin: 'path' },
|
|
@@ -611,6 +643,8 @@ const TENANT_ROUTES = [
|
|
|
611
643
|
{ method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/migrations$/, pin: 'path' },
|
|
612
644
|
{ method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/owner-seat$/, pin: 'path' },
|
|
613
645
|
{ method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/peer-grants$/, pin: 'path' },
|
|
646
|
+
{ method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/peer-bindings$/, pin: 'path' },
|
|
647
|
+
{ method: 'PUT', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/peer-bindings$/, pin: 'path' },
|
|
614
648
|
// The peer switch (#1706): on is POST, off is DELETE, on the tenant's own scope.
|
|
615
649
|
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/peer-grants$/, pin: 'path' },
|
|
616
650
|
{ method: 'DELETE', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/peer-grants$/, pin: 'path' },
|
|
@@ -624,6 +658,11 @@ const TENANT_ROUTES = [
|
|
|
624
658
|
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/lifecycle-flow$/, pin: 'path' },
|
|
625
659
|
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/operation-series$/, pin: 'path' },
|
|
626
660
|
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/owner-claim$/, pin: 'path' },
|
|
661
|
+
// An installed vertical's members (#1150): the roster, an invite, a role move, a removal.
|
|
662
|
+
{ method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/members$/, pin: 'path' },
|
|
663
|
+
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/members$/, pin: 'path' },
|
|
664
|
+
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/members\/[^/]+\/role$/, pin: 'path' },
|
|
665
|
+
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/members\/[^/]+\/remove$/, pin: 'path' },
|
|
627
666
|
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/query$/, pin: 'path' },
|
|
628
667
|
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/reap$/, pin: 'path' },
|
|
629
668
|
{ method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/rebind-vertical$/, pin: 'path' },
|
|
@@ -652,6 +691,7 @@ const TENANT_ROUTES = [
|
|
|
652
691
|
{ method: 'GET', re: /\/admin-log$/, pin: 'query' },
|
|
653
692
|
{ method: 'GET', re: /\/ops-failures$/, pin: 'query' },
|
|
654
693
|
{ method: 'GET', re: /\/sweep-runs$/, pin: 'query' },
|
|
694
|
+
{ method: 'GET', re: /\/findings$/, pin: 'query' },
|
|
655
695
|
{ method: 'GET', re: /\/service-refs$/, pin: 'query' },
|
|
656
696
|
{ method: 'GET', re: /\/observability\/tenant-metrics$/, pin: 'query' },
|
|
657
697
|
{ method: 'GET', re: /\/observability\/tenant-metrics-series$/, pin: 'query' },
|
|
@@ -1560,7 +1600,7 @@ export function createControlPlaneApi(options) {
|
|
|
1560
1600
|
return c.json({ error: 'body tenantId disagrees with the route tenant' }, 400);
|
|
1561
1601
|
}
|
|
1562
1602
|
try {
|
|
1563
|
-
return c.json(await relayConnectionUpsert(c.var.host, c.get('actor'), { ...body, tenantId }, { probeCandidate }));
|
|
1603
|
+
return c.json(await relayConnectionUpsert(c.var.host, c.get('actor'), { ...body, tenantId }, { probeCandidate, prepareCandidate }));
|
|
1564
1604
|
}
|
|
1565
1605
|
catch (err) {
|
|
1566
1606
|
if (err instanceof ConnectionRelayError && err.status < 500) {
|
|
@@ -1590,6 +1630,80 @@ export function createControlPlaneApi(options) {
|
|
|
1590
1630
|
await c.var.admin.revokeConnection(c.get('actor'), row.id);
|
|
1591
1631
|
return c.body(null, 204);
|
|
1592
1632
|
});
|
|
1633
|
+
// -- a vertical's mailed connect links, tenant-scoped (connections.md §3.5.4) ---------
|
|
1634
|
+
//
|
|
1635
|
+
// The dashboard's half of a link a VERTICAL minted (`/internal/connections/connect-links`):
|
|
1636
|
+
// its consent landing asks whether the row still stands, its callback spends the row
|
|
1637
|
+
// before storing and restores it if the store fails, and its integrations card lists and
|
|
1638
|
+
// revokes them. The tenant is the path's; the scope is named on every call and is half
|
|
1639
|
+
// the store's key, so another scope's link — or another tenant's — reads as absent (K-3).
|
|
1640
|
+
// Not in BUILDER_ROUTES: a builder has no business spending a tenant's consent round.
|
|
1641
|
+
const connectLinkScope = z.object({ scopeId: scopeIdSchema });
|
|
1642
|
+
app.get('/tenants/:tenantId/connect-links', async (c) => {
|
|
1643
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1644
|
+
const q = connectLinkScope
|
|
1645
|
+
.extend({ provider: connectionProvider.optional(), outstanding: z.literal('1').optional() })
|
|
1646
|
+
.parse({
|
|
1647
|
+
scopeId: c.req.query('scopeId'),
|
|
1648
|
+
provider: c.req.query('provider') || undefined,
|
|
1649
|
+
outstanding: c.req.query('outstanding') || undefined,
|
|
1650
|
+
});
|
|
1651
|
+
return c.json(await c.var.admin.listConnectLinks(c.get('actor'), {
|
|
1652
|
+
tenantId,
|
|
1653
|
+
scopeId: q.scopeId,
|
|
1654
|
+
...(q.provider ? { provider: q.provider } : {}),
|
|
1655
|
+
...(q.outstanding ? { outstandingOnly: true } : {}),
|
|
1656
|
+
}));
|
|
1657
|
+
});
|
|
1658
|
+
app.get('/tenants/:tenantId/connect-links/:linkId', async (c) => {
|
|
1659
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1660
|
+
const { scopeId } = connectLinkScope.parse({ scopeId: c.req.query('scopeId') });
|
|
1661
|
+
const link = await c.var.admin.getConnectLink(c.get('actor'), {
|
|
1662
|
+
tenantId,
|
|
1663
|
+
scopeId,
|
|
1664
|
+
id: connectLinkId.parse(c.req.param('linkId')),
|
|
1665
|
+
});
|
|
1666
|
+
return link ? c.json(link) : c.json({ error: 'unknown connect link' }, 404);
|
|
1667
|
+
});
|
|
1668
|
+
// The callback's spend. A refusal is a 200 carrying the typed reason, not an error status:
|
|
1669
|
+
// "already used" is the answer to the question asked, and the callback renders each one.
|
|
1670
|
+
app.post('/tenants/:tenantId/connect-links/:linkId/consume', async (c) => {
|
|
1671
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1672
|
+
const body = connectLinkScope
|
|
1673
|
+
.extend({
|
|
1674
|
+
provider: connectionProvider,
|
|
1675
|
+
accountRef: z.string().max(256).optional(),
|
|
1676
|
+
accountLabel: z.string().max(256).optional(),
|
|
1677
|
+
})
|
|
1678
|
+
.parse(await c.req.json());
|
|
1679
|
+
return c.json(await c.var.admin.consumeConnectLink(c.get('actor'), {
|
|
1680
|
+
...body,
|
|
1681
|
+
tenantId,
|
|
1682
|
+
id: connectLinkId.parse(c.req.param('linkId')),
|
|
1683
|
+
}));
|
|
1684
|
+
});
|
|
1685
|
+
app.post('/tenants/:tenantId/connect-links/:linkId/restore', async (c) => {
|
|
1686
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1687
|
+
const { scopeId } = connectLinkScope.parse(await c.req.json());
|
|
1688
|
+
const restored = await c.var.admin.restoreConnectLink(c.get('actor'), {
|
|
1689
|
+
tenantId,
|
|
1690
|
+
scopeId,
|
|
1691
|
+
id: connectLinkId.parse(c.req.param('linkId')),
|
|
1692
|
+
});
|
|
1693
|
+
return c.json({ restored });
|
|
1694
|
+
});
|
|
1695
|
+
// Idempotent, as the store is: a revoked or spent link answers 204 again. 404 only for a
|
|
1696
|
+
// link this scope never held.
|
|
1697
|
+
app.delete('/tenants/:tenantId/connect-links/:linkId', async (c) => {
|
|
1698
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1699
|
+
const { scopeId } = connectLinkScope.parse({ scopeId: c.req.query('scopeId') });
|
|
1700
|
+
const link = await c.var.admin.revokeConnectLink(c.get('actor'), {
|
|
1701
|
+
tenantId,
|
|
1702
|
+
scopeId,
|
|
1703
|
+
id: connectLinkId.parse(c.req.param('linkId')),
|
|
1704
|
+
});
|
|
1705
|
+
return link ? c.body(null, 204) : c.json({ error: 'unknown connect link' }, 404);
|
|
1706
|
+
});
|
|
1593
1707
|
/**
|
|
1594
1708
|
* The tenant-scoped connection lookup the two inspection routes share. Same K-3
|
|
1595
1709
|
* existence hiding as the revoke above — a foreign tenant's connection id reads
|
|
@@ -1602,6 +1716,8 @@ export function createControlPlaneApi(options) {
|
|
|
1602
1716
|
* in which case the upsert behaves exactly as it always did, storing unverified.
|
|
1603
1717
|
*/
|
|
1604
1718
|
const probeCandidate = async (provider, secret) => options.connectionInspectors?.[provider]?.probeCandidate?.(secret);
|
|
1719
|
+
/** #2100: the provider's own half of a credential, made before the probe sees it. */
|
|
1720
|
+
const prepareCandidate = (provider) => options.connectionInspectors?.[provider]?.prepareCandidate;
|
|
1605
1721
|
const inspectableConnection = async (c) => {
|
|
1606
1722
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1607
1723
|
const id = c.req.param('id');
|
|
@@ -1675,6 +1791,20 @@ export function createControlPlaneApi(options) {
|
|
|
1675
1791
|
}
|
|
1676
1792
|
return c.json(connectionCredential.parse(await credential(c.var.host, row)));
|
|
1677
1793
|
});
|
|
1794
|
+
/**
|
|
1795
|
+
* The public certificate a connection signs in with (#2100) — what the tenant downloads and
|
|
1796
|
+
* uploads to the provider. Public by construction: the private key never leaves the sealed
|
|
1797
|
+
* credential, and only a provider whose keypair the platform generates has one at all.
|
|
1798
|
+
*/
|
|
1799
|
+
app.get('/tenants/:tenantId/connections/:id/certificate', async (c) => {
|
|
1800
|
+
const row = await inspectableConnection(c);
|
|
1801
|
+
if (!row)
|
|
1802
|
+
return c.json({ error: 'unknown connection' }, 404);
|
|
1803
|
+
const certificate = await options.connectionInspectors?.[row.provider]?.certificate?.(c.var.host, row);
|
|
1804
|
+
if (!certificate)
|
|
1805
|
+
return c.json({ error: 'this connection signs in with no certificate' }, 404);
|
|
1806
|
+
return c.json(connectionCertificate.parse(certificate));
|
|
1807
|
+
});
|
|
1678
1808
|
// -- the scope directory (§3.2/§4.2) ---------------------------------------
|
|
1679
1809
|
app.get('/scopes', async (c) => {
|
|
1680
1810
|
const p = c.get('principal');
|
|
@@ -2797,6 +2927,9 @@ export function createControlPlaneApi(options) {
|
|
|
2797
2927
|
? await c.var.admin.verticalServing(actor, scope.vertical).catch(() => null)
|
|
2798
2928
|
: null;
|
|
2799
2929
|
const ranAs = versionReachedAt(reached.via, scope, serving);
|
|
2930
|
+
// #2071: the shapes this reconcile tops holders up to, from the reviewed registry of the
|
|
2931
|
+
// version it reaches — never anything the vertical's own code names.
|
|
2932
|
+
const entityGrants = scope.vertical && ranAs ? await reviewedEntityGrants(c.var.admin, actor, scope.vertical, ranAs) : undefined;
|
|
2800
2933
|
try {
|
|
2801
2934
|
// #1674: the recorded OFF positions go back after the reconcile, before the receipt.
|
|
2802
2935
|
const result = await reconcileThenReassert(c.var.admin, actor, { tenantId, scopeId }, (carry) => vertical.reconcileInstance({
|
|
@@ -2806,6 +2939,7 @@ export function createControlPlaneApi(options) {
|
|
|
2806
2939
|
identityLinks,
|
|
2807
2940
|
connectionGrants,
|
|
2808
2941
|
connectionKeys,
|
|
2942
|
+
...(entityGrants ? { entityGrants } : {}),
|
|
2809
2943
|
...carry,
|
|
2810
2944
|
// Handed over exactly as at provision: a store minted HERE has never been migrated
|
|
2811
2945
|
// by the vertical, so the reconcile must carry it into the same ready-gate — a bound
|
|
@@ -3158,6 +3292,19 @@ export function createControlPlaneApi(options) {
|
|
|
3158
3292
|
throw e;
|
|
3159
3293
|
}
|
|
3160
3294
|
});
|
|
3295
|
+
/**
|
|
3296
|
+
* The address a link into an instance opens on: the scope's canonical `app` hostname,
|
|
3297
|
+
* preferring one the platform has activated but not requiring it — a platform hostname is
|
|
3298
|
+
* bound `pending` and activated a step later, and that step can fail while the app is fully
|
|
3299
|
+
* reachable (#294). `failed` is the one state that never routes, so only it is skipped. The
|
|
3300
|
+
* platform owns the hostname directory, so the origin is never taken from a body.
|
|
3301
|
+
*/
|
|
3302
|
+
const instanceOrigin = async (admin, actor, scopeId) => {
|
|
3303
|
+
const bound = (await admin.listHostnames(actor, { scopeId })).filter((h) => h.status !== 'failed');
|
|
3304
|
+
const pick = (hs) => hs.find((h) => h.surface === 'app' && h.canonical) ?? hs.find((h) => h.surface === 'app') ?? hs[0];
|
|
3305
|
+
const host = pick(bound.filter((h) => h.status === 'active')) ?? pick(bound);
|
|
3306
|
+
return host ? `https://${host.hostname}` : null;
|
|
3307
|
+
};
|
|
3161
3308
|
app.post('/tenants/:tenantId/scopes/:scopeId/owner-claim', async (c) => {
|
|
3162
3309
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
3163
3310
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
@@ -3172,18 +3319,97 @@ export function createControlPlaneApi(options) {
|
|
|
3172
3319
|
const vertical = await verticalForScope(c, scope);
|
|
3173
3320
|
if (!vertical)
|
|
3174
3321
|
return c.json({ error: await diagnoseUnboundScope(actor, scope) }, 501);
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
// `pending` and activated a step later, and that step can fail while the app is fully
|
|
3178
|
-
// reachable (#294). `failed` is the one state that never routes, so only it is skipped.
|
|
3179
|
-
const bound = (await c.var.admin.listHostnames(actor, { scopeId })).filter((h) => h.status !== 'failed');
|
|
3180
|
-
const pick = (hs) => hs.find((h) => h.surface === 'app' && h.canonical) ?? hs.find((h) => h.surface === 'app') ?? hs[0];
|
|
3181
|
-
const host = pick(bound.filter((h) => h.status === 'active')) ?? pick(bound);
|
|
3182
|
-
if (!host) {
|
|
3322
|
+
const origin = await instanceOrigin(c.var.admin, actor, scopeId);
|
|
3323
|
+
if (!origin) {
|
|
3183
3324
|
return c.json({ error: `scope ${scopeId} has no hostname bound — a claim link needs an address to open on` }, 409);
|
|
3184
3325
|
}
|
|
3185
3326
|
try {
|
|
3186
|
-
return c.json(await vertical.mintOwnerClaim({ tenantId, scopeId, origin
|
|
3327
|
+
return c.json(await vertical.mintOwnerClaim({ tenantId, scopeId, origin, actor }), 201);
|
|
3328
|
+
}
|
|
3329
|
+
catch (e) {
|
|
3330
|
+
if (e instanceof ControlPlaneError)
|
|
3331
|
+
return c.json({ error: e.message }, e.status);
|
|
3332
|
+
throw e;
|
|
3333
|
+
}
|
|
3334
|
+
});
|
|
3335
|
+
// An installed vertical's MEMBERS (#1150) — the dashboard's Members section. The roster and
|
|
3336
|
+
// every change live in the VERTICAL (its scope's role tuples, its identity directory), so each
|
|
3337
|
+
// route makes the same K-3 cross-check as the owner seat and then asks the deployment that
|
|
3338
|
+
// holds the scope. One scope per call: no walk across scopes (#1581).
|
|
3339
|
+
//
|
|
3340
|
+
// **Who is acting is the credential's person, and only that.** Every change is bounded in the
|
|
3341
|
+
// vertical by the K-21 set comparison at the vertical's scope, asked about a principal — and
|
|
3342
|
+
// the one principal this plane can vouch for is the `onBehalfOf` the dashboard's tenant token
|
|
3343
|
+
// carries (#977), which only the staff mint writes. A builder session, a staff token or a
|
|
3344
|
+
// tenant token minted for nobody names no person, so the writes refuse it rather than
|
|
3345
|
+
// borrowing an authority it does not have. Being a tenant (dashboard) admin confers nothing in
|
|
3346
|
+
// the vertical's scope: what the person holds THERE is what bounds them.
|
|
3347
|
+
//
|
|
3348
|
+
// Every change is audited intent-first then outcome (`manageScopeMember`), like the hand-over.
|
|
3349
|
+
const memberPerson = (p) => p.kind === 'tenant' && p.onBehalfOf ? p.onBehalfOf.principal : null;
|
|
3350
|
+
/** The scope, its vertical and the acting person, or the answer that refuses the request. */
|
|
3351
|
+
const memberTarget = async (c) => {
|
|
3352
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
3353
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3354
|
+
const principal = c.get('principal');
|
|
3355
|
+
const actor = c.get('actor');
|
|
3356
|
+
// K-3: another tenant's pair, or no such scope, reads the same — absent.
|
|
3357
|
+
const scope = outsideTenant(principal, tenantId) ? null : await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
3358
|
+
if (!scope)
|
|
3359
|
+
return { refusal: c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404) };
|
|
3360
|
+
const vertical = await verticalForScope(c, scope);
|
|
3361
|
+
if (!vertical)
|
|
3362
|
+
return { refusal: c.json({ error: await diagnoseUnboundScope(actor, scope) }, 501) };
|
|
3363
|
+
return { tenantId, scopeId, actor, vertical, person: memberPerson(principal) };
|
|
3364
|
+
};
|
|
3365
|
+
/**
|
|
3366
|
+
* Answer for one `auditedChange` (#2064). A refusal or failure answers with the vertical's own
|
|
3367
|
+
* status (a non-`ControlPlaneError` throw is rethrown). A change that went through answers
|
|
3368
|
+
* `ok`, with its `operationId`, whether or not its outcome row could be written. When it could
|
|
3369
|
+
* not, the answer also carries `auditWarning`, which says so. That is a success on purpose:
|
|
3370
|
+
* the change happened, so no client should read a retryable error and make it twice (a second
|
|
3371
|
+
* invite, a second hand-over). The missing row is logged, and the scheduled settle closes it.
|
|
3372
|
+
*/
|
|
3373
|
+
const auditedAnswer = (c, done, what, ok) => {
|
|
3374
|
+
const { operationId } = done;
|
|
3375
|
+
if ('error' in done) {
|
|
3376
|
+
if (done.error instanceof ControlPlaneError) {
|
|
3377
|
+
return c.json({ error: done.error.message, operationId }, done.error.status);
|
|
3378
|
+
}
|
|
3379
|
+
throw done.error;
|
|
3380
|
+
}
|
|
3381
|
+
if ('unrecorded' in done) {
|
|
3382
|
+
return ok(done.result, {
|
|
3383
|
+
operationId,
|
|
3384
|
+
auditWarning: auditWarningOf(what, done.unrecorded),
|
|
3385
|
+
});
|
|
3386
|
+
}
|
|
3387
|
+
return ok(done.result, { operationId });
|
|
3388
|
+
};
|
|
3389
|
+
/** Run one audited member change: intent, the vertical's call, then the outcome row. */
|
|
3390
|
+
const auditedMemberChange = (c, entry, run, applied) => {
|
|
3391
|
+
const actor = c.get('actor');
|
|
3392
|
+
const operationId = ulid();
|
|
3393
|
+
return auditedChange({
|
|
3394
|
+
flow: 'member-change',
|
|
3395
|
+
operationId,
|
|
3396
|
+
run,
|
|
3397
|
+
// A 4xx is the vertical's refusal: nothing was written there.
|
|
3398
|
+
refused: (e) => e instanceof ControlPlaneError && e.status < 500,
|
|
3399
|
+
record: (row) => c.var.admin.recordMemberChange(actor, {
|
|
3400
|
+
...entry,
|
|
3401
|
+
operationId,
|
|
3402
|
+
...(row.phase === 'applied' ? { phase: 'applied', ...applied(row.result) } : row),
|
|
3403
|
+
}),
|
|
3404
|
+
});
|
|
3405
|
+
};
|
|
3406
|
+
const NO_PERSON = 'a member change needs the signed-in person it is made for — this credential names nobody';
|
|
3407
|
+
app.get('/tenants/:tenantId/scopes/:scopeId/members', async (c) => {
|
|
3408
|
+
const target = await memberTarget(c);
|
|
3409
|
+
if ('refusal' in target)
|
|
3410
|
+
return target.refusal;
|
|
3411
|
+
try {
|
|
3412
|
+
return c.json(await target.vertical.listMembers(target.tenantId, target.scopeId));
|
|
3187
3413
|
}
|
|
3188
3414
|
catch (e) {
|
|
3189
3415
|
if (e instanceof ControlPlaneError)
|
|
@@ -3191,6 +3417,44 @@ export function createControlPlaneApi(options) {
|
|
|
3191
3417
|
throw e;
|
|
3192
3418
|
}
|
|
3193
3419
|
});
|
|
3420
|
+
app.post('/tenants/:tenantId/scopes/:scopeId/members', async (c) => {
|
|
3421
|
+
const target = await memberTarget(c);
|
|
3422
|
+
if ('refusal' in target)
|
|
3423
|
+
return target.refusal;
|
|
3424
|
+
if (!target.person)
|
|
3425
|
+
return c.json({ error: NO_PERSON }, 403);
|
|
3426
|
+
const body = memberInviteInput.parse(await c.req.json());
|
|
3427
|
+
const origin = await instanceOrigin(c.var.admin, target.actor, target.scopeId);
|
|
3428
|
+
if (!origin) {
|
|
3429
|
+
return c.json({ error: `scope ${target.scopeId} has no hostname bound — an invite link needs an address to open on` }, 409);
|
|
3430
|
+
}
|
|
3431
|
+
const { tenantId, scopeId, person } = target;
|
|
3432
|
+
const done = await auditedMemberChange(c, { tenantId, scopeId, change: 'invite', caller: person, roleKey: body.roleKey }, () => target.vertical.inviteMember({ tenantId, scopeId, caller: person, origin, roleKey: body.roleKey, email: body.email ?? null }), (link) => ({ principal: link.principal }));
|
|
3433
|
+
return auditedAnswer(c, done, 'the invite', (link, audit) => c.json({ ...link, ...audit }, 201));
|
|
3434
|
+
});
|
|
3435
|
+
app.post('/tenants/:tenantId/scopes/:scopeId/members/:principal/role', async (c) => {
|
|
3436
|
+
const target = await memberTarget(c);
|
|
3437
|
+
if ('refusal' in target)
|
|
3438
|
+
return target.refusal;
|
|
3439
|
+
if (!target.person)
|
|
3440
|
+
return c.json({ error: NO_PERSON }, 403);
|
|
3441
|
+
const member = principalIdSchema.parse(c.req.param('principal'));
|
|
3442
|
+
const body = memberRoleInput.parse(await c.req.json());
|
|
3443
|
+
const { tenantId, scopeId, person } = target;
|
|
3444
|
+
const done = await auditedMemberChange(c, { tenantId, scopeId, change: 'role', caller: person, principal: member, from: body.from, to: body.to }, () => target.vertical.changeMemberRole({ tenantId, scopeId, caller: person, principal: member, from: body.from, to: body.to }), () => ({}));
|
|
3445
|
+
return auditedAnswer(c, done, 'the role change', (_, audit) => c.json({ principal: member, from: body.from, to: body.to, ...audit }));
|
|
3446
|
+
});
|
|
3447
|
+
app.post('/tenants/:tenantId/scopes/:scopeId/members/:principal/remove', async (c) => {
|
|
3448
|
+
const target = await memberTarget(c);
|
|
3449
|
+
if ('refusal' in target)
|
|
3450
|
+
return target.refusal;
|
|
3451
|
+
if (!target.person)
|
|
3452
|
+
return c.json({ error: NO_PERSON }, 403);
|
|
3453
|
+
const member = principalIdSchema.parse(c.req.param('principal'));
|
|
3454
|
+
const { tenantId, scopeId, person } = target;
|
|
3455
|
+
const done = await auditedMemberChange(c, { tenantId, scopeId, change: 'remove', caller: person, principal: member }, () => target.vertical.removeMember({ tenantId, scopeId, caller: person, principal: member }), (removal) => ({ revoked: removal.revoked }));
|
|
3456
|
+
return auditedAnswer(c, done, 'the removal', (removal, audit) => c.json({ ...removal, ...audit }));
|
|
3457
|
+
});
|
|
3194
3458
|
// The owner HAND-OVER (#1665). The move runs in the VERTICAL, behind `/internal/owner-transfer`
|
|
3195
3459
|
// (vertical-host says in what order and why). This route decides who may ask, and audits every
|
|
3196
3460
|
// attempt: an `intent` row before the vertical is reached, so a row the log cannot take means
|
|
@@ -3220,41 +3484,19 @@ export function createControlPlaneApi(options) {
|
|
|
3220
3484
|
// An abandon (`abandon: true`) is audited the same way, every row marked, so the log tells
|
|
3221
3485
|
// a hand-over closed without finishing from one that finished.
|
|
3222
3486
|
const base = { tenantId, scopeId, operationId, from: body.from, to: body.to, ...(body.abandon ? { abandon: true } : {}) };
|
|
3223
|
-
await
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
}
|
|
3237
|
-
throw e;
|
|
3238
|
-
}
|
|
3239
|
-
try {
|
|
3240
|
-
await c.var.admin.recordOwnerTransfer(actor, {
|
|
3241
|
-
...base,
|
|
3242
|
-
phase: 'applied',
|
|
3243
|
-
outcome: moved.outcome,
|
|
3244
|
-
fromRevoked: moved.fromRevoked,
|
|
3245
|
-
});
|
|
3246
|
-
}
|
|
3247
|
-
catch (e) {
|
|
3248
|
-
// The owner HAS moved; only the outcome row is missing. Say exactly that, so nobody retries
|
|
3249
|
-
// a hand-over believing it failed, and name the operation whose intent row stands alone.
|
|
3250
|
-
const why = e instanceof Error ? e.message : String(e);
|
|
3251
|
-
return c.json({
|
|
3252
|
-
error: `the hand-over completed, but its outcome could not be written to the admin log: ${why}`,
|
|
3253
|
-
operationId,
|
|
3254
|
-
...moved,
|
|
3255
|
-
}, 500);
|
|
3256
|
-
}
|
|
3257
|
-
return c.json({ operationId, ...moved });
|
|
3487
|
+
const done = await auditedChange({
|
|
3488
|
+
flow: 'owner-transfer',
|
|
3489
|
+
operationId,
|
|
3490
|
+
run: () => vertical.transferOwner({ tenantId, scopeId, ...body }),
|
|
3491
|
+
// Only the vertical's 409 is a refusal: anything else may have stopped part-way.
|
|
3492
|
+
refused: (e) => e instanceof ControlPlaneError && e.status === 409,
|
|
3493
|
+
record: (row) => c.var.admin.recordOwnerTransfer(actor, row.phase === 'applied'
|
|
3494
|
+
? { ...base, phase: 'applied', outcome: row.result.outcome, fromRevoked: row.result.fromRevoked }
|
|
3495
|
+
: { ...base, ...row }),
|
|
3496
|
+
});
|
|
3497
|
+
// A missing outcome row is said in `auditWarning` on a success, so nobody retries a hand-over
|
|
3498
|
+
// believing it failed, under the operation whose intent row stands alone.
|
|
3499
|
+
return auditedAnswer(c, done, 'the hand-over', (moved, audit) => c.json({ ...audit, ...moved }));
|
|
3258
3500
|
});
|
|
3259
3501
|
// Deliver per-instance CONFIG to the scope's own storage (vertical-auth-detach.md
|
|
3260
3502
|
// §2.2) — the missing "delivery" step behind the dashboard's Env tab. Same K-3
|
|
@@ -6084,6 +6326,9 @@ export function createControlPlaneApi(options) {
|
|
|
6084
6326
|
// `emailSender` grant itself is never touched by a push. Refreshes on every re-push
|
|
6085
6327
|
// like the rest of the install spec, exactly because it grants nothing.
|
|
6086
6328
|
...(manifest.sendsEmail ? { sendsEmail: true } : {}),
|
|
6329
|
+
// Where the scope lifecycle is held (#1713): `'router'` keeps the vertical's scopes out of
|
|
6330
|
+
// the lifecycle delivery. Refreshes on every re-push like the rest of the install spec.
|
|
6331
|
+
...(manifest.lifecycle ? { lifecycle: manifest.lifecycle } : {}),
|
|
6087
6332
|
// The declared surfaces (K-26) ride like envSpec: registry metadata for the
|
|
6088
6333
|
// hostname-binding picker, never behavior. Not part of any admission digest.
|
|
6089
6334
|
...(manifest.surfaces ? { surfaces: manifest.surfaces } : {}),
|
|
@@ -7527,6 +7772,56 @@ export function createControlPlaneApi(options) {
|
|
|
7527
7772
|
}
|
|
7528
7773
|
return c.json(await c.var.admin.peerGrantsStatus(actor, { tenantId, scopeId }));
|
|
7529
7774
|
});
|
|
7775
|
+
// #1720: a caller's explicit target is a tenant decision. The dashboard checks its own
|
|
7776
|
+
// app-management permission; this route confines the tenant and both directory endpoints.
|
|
7777
|
+
const peerBindingBody = z.object({ vertical: verticalSlug, targetScopeId: scopeIdSchema.nullable() }).strict();
|
|
7778
|
+
app.get('/tenants/:tenantId/scopes/:scopeId/peer-bindings', async (c) => {
|
|
7779
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
7780
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
7781
|
+
const vertical = c.req.query('vertical');
|
|
7782
|
+
const pin = confinedTenant(c.get('principal'));
|
|
7783
|
+
if (pin !== null && pin !== tenantId)
|
|
7784
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
7785
|
+
const actor = c.get('actor');
|
|
7786
|
+
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId)))
|
|
7787
|
+
return c.json({ error: 'app not found' }, 404);
|
|
7788
|
+
return c.json(vertical === undefined
|
|
7789
|
+
? await c.var.admin.peerBindings(actor, tenantId, scopeId)
|
|
7790
|
+
: await c.var.admin.peerBinding(actor, tenantId, scopeId, verticalSlug.parse(vertical)) ?? null);
|
|
7791
|
+
});
|
|
7792
|
+
app.put('/tenants/:tenantId/scopes/:scopeId/peer-bindings', async (c) => {
|
|
7793
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
7794
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
7795
|
+
const pin = confinedTenant(c.get('principal'));
|
|
7796
|
+
// The target choice is the tenant's alone. Staff may inspect it through GET, but cannot
|
|
7797
|
+
// choose a tenant's site on its behalf.
|
|
7798
|
+
if (pin !== tenantId)
|
|
7799
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
7800
|
+
const body = peerBindingBody.parse(await c.req.json());
|
|
7801
|
+
const actor = c.get('actor');
|
|
7802
|
+
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId)))
|
|
7803
|
+
return c.json({ error: 'app not found' }, 404);
|
|
7804
|
+
const operationId = ulid();
|
|
7805
|
+
const base = { tenantId, scopeId, operationId, vertical: body.vertical, targetScopeId: body.targetScopeId };
|
|
7806
|
+
const done = await auditedChange({
|
|
7807
|
+
flow: 'peer-binding', operationId,
|
|
7808
|
+
run: async () => {
|
|
7809
|
+
try {
|
|
7810
|
+
return await c.var.admin.setPeerBinding(actor, tenantId, scopeId, body.vertical, body.targetScopeId);
|
|
7811
|
+
}
|
|
7812
|
+
catch (e) {
|
|
7813
|
+
if (errorCodeOf(e) === 'not_found')
|
|
7814
|
+
throw new ControlPlaneError(404, e instanceof Error ? e.message : String(e));
|
|
7815
|
+
throw e;
|
|
7816
|
+
}
|
|
7817
|
+
},
|
|
7818
|
+
refused: (e) => e instanceof ControlPlaneError && e.status < 500,
|
|
7819
|
+
record: (row) => c.var.admin.recordPeerBindingChange(actor, row.phase === 'applied'
|
|
7820
|
+
? { ...base, phase: 'applied', changed: row.result.changed, previousScopeId: row.result.previous }
|
|
7821
|
+
: { ...base, ...row }),
|
|
7822
|
+
});
|
|
7823
|
+
return auditedAnswer(c, done, 'the peer binding', (result, audit) => c.json({ ...result, ...audit }));
|
|
7824
|
+
});
|
|
7530
7825
|
// Orgs: the portal-customer grouping (§4.1). Creating one mints no permission —
|
|
7531
7826
|
// members reach what the org was GRANTED, and granting stays off this surface.
|
|
7532
7827
|
app.post('/tenants/:tenantId/orgs', async (c) => {
|
|
@@ -7553,9 +7848,12 @@ export function createControlPlaneApi(options) {
|
|
|
7553
7848
|
order: c.req.query('order'),
|
|
7554
7849
|
});
|
|
7555
7850
|
const entries = await c.var.admin.auditLog(c.get('actor'), filter);
|
|
7851
|
+
// #2064: each audited change's row says the outcome its operation stands at — the latest
|
|
7852
|
+
// outcome row wins — beside the raw row, which is returned as written.
|
|
7853
|
+
const resolved = await withAuditedOutcomes(c.var.admin, c.get('actor'), entries);
|
|
7556
7854
|
// The cursor IS the last entry's id (ULID order is chronological), so the
|
|
7557
7855
|
// page carries its own continuation and the console never assembles one.
|
|
7558
|
-
return c.json(pageOf(
|
|
7856
|
+
return c.json(pageOf(resolved, filter.limit, (e) => e.id));
|
|
7559
7857
|
});
|
|
7560
7858
|
// The recorded operational failures (#559) — the console's failures view, and the
|
|
7561
7859
|
// "what does this `reference = <id>` belong to" lookup. Newest first by default,
|
|
@@ -7674,6 +7972,57 @@ export function createControlPlaneApi(options) {
|
|
|
7674
7972
|
return c.json({ error: 'unknown issue fingerprint' }, 404);
|
|
7675
7973
|
return c.json(updated);
|
|
7676
7974
|
});
|
|
7975
|
+
// -- findings (#1748): the tenant inbox, with a lifecycle and suppress rules ----
|
|
7976
|
+
// Staff read the fleet; a tenant credential's tenant is forced from the principal, the
|
|
7977
|
+
// /ops-failures posture. Builders never reach any of these (absent from BUILDER_ROUTES): a
|
|
7978
|
+
// finding is the tenant's, not the vertical author's.
|
|
7979
|
+
app.get('/findings', async (c) => {
|
|
7980
|
+
const filter = findingsQuery.parse({
|
|
7981
|
+
tenantId: confinedTenant(c.get('principal')) ?? c.req.query('tenantId'),
|
|
7982
|
+
status: c.req.query('status'),
|
|
7983
|
+
kind: c.req.query('kind'),
|
|
7984
|
+
limit: c.req.query('limit'),
|
|
7985
|
+
});
|
|
7986
|
+
return c.json({ entries: await c.var.admin.listFindings(c.get('actor'), filter) });
|
|
7987
|
+
});
|
|
7988
|
+
// A verdict: acknowledge, resolve or reopen. Keyed on the tenant in the path as well as the id,
|
|
7989
|
+
// so another tenant's finding is a 404, never a write.
|
|
7990
|
+
app.put('/tenants/:tenantId/findings/:id/status', async (c) => {
|
|
7991
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
7992
|
+
if (outsideTenant(c.get('principal'), tenantId))
|
|
7993
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
7994
|
+
const { status } = findingStatusUpdate.parse(await c.req.json());
|
|
7995
|
+
const updated = await c.var.admin.setFindingStatus(c.get('actor'), tenantId, c.req.param('id'), status);
|
|
7996
|
+
if (!updated)
|
|
7997
|
+
return c.json({ error: 'unknown finding' }, 404);
|
|
7998
|
+
return c.json(updated);
|
|
7999
|
+
});
|
|
8000
|
+
app.get('/tenants/:tenantId/finding-rules', async (c) => {
|
|
8001
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8002
|
+
if (outsideTenant(c.get('principal'), tenantId))
|
|
8003
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
8004
|
+
const filter = findingRulesQuery.parse({ active: c.req.query('active'), limit: c.req.query('limit') });
|
|
8005
|
+
const entries = await c.var.admin.listFindingRules(c.get('actor'), tenantId, filter);
|
|
8006
|
+
return c.json({ entries });
|
|
8007
|
+
});
|
|
8008
|
+
// Suppress with a rule: a scope plus an expiry, audited. 201 with the rule and how many
|
|
8009
|
+
// findings it suppressed at once.
|
|
8010
|
+
app.post('/tenants/:tenantId/finding-rules', async (c) => {
|
|
8011
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8012
|
+
if (outsideTenant(c.get('principal'), tenantId))
|
|
8013
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
8014
|
+
const input = findingRuleInput.parse(await c.req.json());
|
|
8015
|
+
return c.json(await c.var.admin.createFindingRule(c.get('actor'), tenantId, input), 201);
|
|
8016
|
+
});
|
|
8017
|
+
app.delete('/tenants/:tenantId/finding-rules/:ruleId', async (c) => {
|
|
8018
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8019
|
+
if (outsideTenant(c.get('principal'), tenantId))
|
|
8020
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
8021
|
+
const revoked = await c.var.admin.revokeFindingRule(c.get('actor'), tenantId, c.req.param('ruleId'));
|
|
8022
|
+
if (!revoked)
|
|
8023
|
+
return c.json({ error: 'unknown finding rule' }, 404);
|
|
8024
|
+
return c.json(revoked);
|
|
8025
|
+
});
|
|
7677
8026
|
// -- model usage (#1054): meter 3, the one D-30 could not compute -------------
|
|
7678
8027
|
// The ledger the `model-usage` intents drain into. Staff-only: the tenant-facing
|
|
7679
8028
|
// read is the vertical's own usage screen over its metering engine; this is the
|