@substrat-run/control-plane-api 0.119.0 → 0.121.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 +9 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +267 -21
- package/dist/api.js.map +1 -1
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +21 -8
- package/dist/cf-observability.js.map +1 -1
- package/dist/connection-relay.d.ts.map +1 -1
- package/dist/connection-relay.js +1 -0
- package/dist/connection-relay.js.map +1 -1
- package/dist/cross-vertical.d.ts +54 -0
- package/dist/cross-vertical.d.ts.map +1 -0
- package/dist/cross-vertical.js +94 -0
- package/dist/cross-vertical.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/observability-window.d.ts +13 -0
- package/dist/observability-window.d.ts.map +1 -0
- package/dist/observability-window.js +20 -0
- package/dist/observability-window.js.map +1 -0
- package/dist/observability.d.ts +23 -0
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +7 -0
- package/dist/observability.js.map +1 -1
- package/dist/platform-drain.d.ts.map +1 -1
- package/dist/platform-drain.js +9 -6
- package/dist/platform-drain.js.map +1 -1
- package/dist/reconcile.d.ts +18 -0
- package/dist/reconcile.d.ts.map +1 -1
- package/dist/reconcile.js +18 -0
- package/dist/reconcile.js.map +1 -1
- package/dist/vertical-client.d.ts +53 -0
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +75 -1
- package/dist/vertical-client.js.map +1 -1
- package/package.json +6 -6
package/dist/api.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { z } from '@substrat-run/contracts';
|
|
3
3
|
import type { Connection, ConnectionActivity, ConnectionActivitySource, ConnectionCredential, ConnectionProbe, PlatformActorId } from '@substrat-run/contracts';
|
|
4
|
-
import type { ScopeHost } from '@substrat-run/kernel';
|
|
4
|
+
import type { CrossVerticalOptions, ScopeHost } from '@substrat-run/kernel';
|
|
5
5
|
import type { PlatformActorAuth, BuilderAuth, Principal, TenantServiceAuth } from './auth.js';
|
|
6
6
|
import { type VerticalClient } from './vertical-client.js';
|
|
7
7
|
import type { DeployVerticalFn, FetchVerticalAssetFn, FetchVerticalModulesFn } from './deploy.js';
|
|
@@ -53,6 +53,14 @@ export interface ControlPlaneApiOptions {
|
|
|
53
53
|
*/
|
|
54
54
|
modelMarginPercent?: number;
|
|
55
55
|
host: ScopeHost;
|
|
56
|
+
/**
|
|
57
|
+
* How edge health reaches the two ends of a cross-vertical edge (#1705 PR 3): the phase's own
|
|
58
|
+
* reach (`hostedCrossVerticalReach` on the control plane). Absent, the host's own verbs, which
|
|
59
|
+
* is right where one host serves every scope. On a host that serves its apps elsewhere (the
|
|
60
|
+
* shared control plane without DISPATCH), the whole read answers `unavailable` instead of
|
|
61
|
+
* reporting no edges.
|
|
62
|
+
*/
|
|
63
|
+
crossVertical?: CrossVerticalOptions | undefined;
|
|
56
64
|
/**
|
|
57
65
|
* How to reach each vertical, by slug (K-31). Absent slugs simply cannot be
|
|
58
66
|
* provisioned — the route 501s rather than pretending, because a control plane that
|
package/dist/api.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
|
package/dist/api.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import { resolveObservabilityWindow } from './observability-window.js';
|
|
1
2
|
import { Hono } from 'hono';
|
|
2
|
-
import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, 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, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, systemSwitch, 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, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
3
|
+
import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, 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, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, systemSwitch, 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, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, } from '@substrat-run/contracts';
|
|
3
4
|
import { attributeFailure } from './failure-attribution.js';
|
|
4
|
-
import { migrationProgress, ulid } from '@substrat-run/kernel';
|
|
5
|
+
import { crossVerticalHealth, isExportBreakRefusal, migrationProgress, ulid } from '@substrat-run/kernel';
|
|
5
6
|
import { TENANT_HEADER, confinedTenant } from './auth.js';
|
|
6
7
|
import { mintTenantToken } from './tenant-token.js';
|
|
7
8
|
import { connectionGrantsForScope } from './vertical-client.js';
|
|
8
9
|
import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
|
|
9
10
|
import { versionReachedAt } from './scope-deployment.js';
|
|
10
11
|
import { reconcileConnectionGrants } from './connection-grants.js';
|
|
12
|
+
import { reconcileThenReassert } from './reconcile.js';
|
|
11
13
|
import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
|
|
12
14
|
import { ControlPlaneError } from './client.js';
|
|
13
15
|
import { provisionSiblingScope } from './platform-drain.js';
|
|
@@ -55,6 +57,19 @@ function outsideTenant(p, tenantId) {
|
|
|
55
57
|
const pin = confinedTenant(p);
|
|
56
58
|
return pin !== null && pin !== tenantId;
|
|
57
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* A fleet-wide report, narrowed to what a caller may see: a confined caller gets its own
|
|
62
|
+
* tenant's rows and a COUNT of the other tenants, never their ids (K-3's forced filter, held on
|
|
63
|
+
* reports that have to be computed fleet-wide). Staff (`pin === null`) get every row.
|
|
64
|
+
*/
|
|
65
|
+
function narrowToCaller(rows, pin) {
|
|
66
|
+
if (pin === null)
|
|
67
|
+
return { visible: [...rows], otherTenants: 0 };
|
|
68
|
+
return {
|
|
69
|
+
visible: rows.filter((r) => r.tenantId === pin),
|
|
70
|
+
otherTenants: new Set(rows.filter((r) => r.tenantId !== pin).map((r) => r.tenantId)).size,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
58
73
|
// -- request schemas ---------------------------------------------------------
|
|
59
74
|
// Parse, don't trust: every input crosses Zod at the boundary. The ids stay
|
|
60
75
|
// CALLER-SUPPLIED rather than minted here, exactly as the contract has them —
|
|
@@ -370,6 +385,19 @@ const tenantRoleAssignmentBody = z
|
|
|
370
385
|
* spells out; `.strict()` so a body that tried to name one is refused, not stripped.
|
|
371
386
|
*/
|
|
372
387
|
const systemSwitchBody = systemSwitch.pick({ moduleId: true, reason: true }).strict();
|
|
388
|
+
/**
|
|
389
|
+
* The fleet read's filter (#1674). `position` defaults to `off` — "what is switched off
|
|
390
|
+
* across the fleet" is the question the route exists for — and `all` asks for both. Paged
|
|
391
|
+
* by `operationId`, ascending by default (oldest switch first, as the admin log reads).
|
|
392
|
+
*/
|
|
393
|
+
const systemSwitchesQuery = z.object({
|
|
394
|
+
position: z.enum(['on', 'off', 'all']).default('off'),
|
|
395
|
+
tenantId: tenantIdSchema.optional(),
|
|
396
|
+
scopeId: scopeIdSchema.optional(),
|
|
397
|
+
moduleId: systemSwitch.shape.moduleId.optional(),
|
|
398
|
+
vertical: z.string().min(1).optional(),
|
|
399
|
+
...listPageQuery.shape,
|
|
400
|
+
});
|
|
373
401
|
/** The peer kill switch's body (#1706) — the tenant and scope come from the path. */
|
|
374
402
|
const peerSwitchBody = peerSwitch.pick({ vertical: true, reason: true }).strict();
|
|
375
403
|
/** A new org under an addressed tenant (#1343) — `tenantId` comes from the path. */
|
|
@@ -452,6 +480,7 @@ const TENANT_ROUTES = [
|
|
|
452
480
|
{ method: 'DELETE', re: /\/verticals\/[^/]+$/, pin: 'owner' },
|
|
453
481
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions$/, pin: 'owner' },
|
|
454
482
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/registry$/, pin: 'owner' },
|
|
483
|
+
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/migrations$/, pin: 'owner' },
|
|
455
484
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/, pin: 'owner' },
|
|
456
485
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/flow$/, pin: 'owner' },
|
|
457
486
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/assets$/, pin: 'owner' },
|
|
@@ -459,6 +488,7 @@ const TENANT_ROUTES = [
|
|
|
459
488
|
{ method: 'GET', re: /\/verticals\/[^/]+\/channels$/, pin: 'owner' },
|
|
460
489
|
{ method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/history$/, pin: 'owner' },
|
|
461
490
|
{ method: 'POST', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote$/, pin: 'owner' },
|
|
491
|
+
{ method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote-impact$/, pin: 'owner' },
|
|
462
492
|
{ method: 'GET', re: /\/verticals\/[^/]+\/previews$/, pin: 'owner' },
|
|
463
493
|
{ method: 'POST', re: /\/verticals\/[^/]+\/previews$/, pin: 'owner' },
|
|
464
494
|
{ method: 'DELETE', re: /\/verticals\/[^/]+\/previews\/[^/]+$/, pin: 'owner' },
|
|
@@ -611,6 +641,9 @@ export function createControlPlaneApi(options) {
|
|
|
611
641
|
// The declared permission registry of one version (D-39, #336) — owner-narrowed in the
|
|
612
642
|
// handler like the versions list; the builder-facing Permissions tab reads it.
|
|
613
643
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/registry$/ },
|
|
644
|
+
// The SQL migrations one version adds on top of another (#1677), owner-narrowed the same
|
|
645
|
+
// way; the promote dialog and `substrat promote` read it.
|
|
646
|
+
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/migrations$/ },
|
|
614
647
|
// The emitted entity model of one version (#1214) — owner-narrowed the same way;
|
|
615
648
|
// the dashboard's Model tab reads it.
|
|
616
649
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/ },
|
|
@@ -1573,7 +1606,8 @@ export function createControlPlaneApi(options) {
|
|
|
1573
1606
|
* current dispatch — resolved BEFORE any version rebind), restore into the serving
|
|
1574
1607
|
* script (which re-projects the vertical's roles), and only then `setScopeServingRef` +
|
|
1575
1608
|
* advance the version pointer. A crash before the flip leaves the scope serving its old
|
|
1576
|
-
* script intact, and the adopt retries idempotently.
|
|
1609
|
+
* script intact, and the adopt retries idempotently. Previews are refused; already-adopted
|
|
1610
|
+
* installs short-circuit.
|
|
1577
1611
|
* Throws `ControlPlaneError` so callers surface an actionable status, never a bare 500.
|
|
1578
1612
|
*/
|
|
1579
1613
|
const adoptScopeOntoServing = async (c, tenantId, scopeId) => {
|
|
@@ -1582,8 +1616,16 @@ export function createControlPlaneApi(options) {
|
|
|
1582
1616
|
if (!scope) {
|
|
1583
1617
|
throw new ControlPlaneError(404, `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
1584
1618
|
}
|
|
1585
|
-
if (scope.
|
|
1619
|
+
if (scope.kind === 'preview') {
|
|
1620
|
+
throw new ControlPlaneError(409, 'preview scopes cannot adopt the production serving script');
|
|
1621
|
+
}
|
|
1622
|
+
if (scope.servingRef) {
|
|
1623
|
+
// #1674 (Copilot review): an adopt that flipped routing and then failed to re-assert is
|
|
1624
|
+
// retried HERE, so the retry re-asserts too — else a store that lost its OFF marker in
|
|
1625
|
+
// the copy would stay on for good. Cheap and idempotent when nothing is owed.
|
|
1626
|
+
await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
|
|
1586
1627
|
return { servingRef: scope.servingRef, alreadyAdopted: true };
|
|
1628
|
+
}
|
|
1587
1629
|
if (!scope.vertical) {
|
|
1588
1630
|
throw new ControlPlaneError(409, 'scope has no vertical — nothing to adopt onto');
|
|
1589
1631
|
}
|
|
@@ -1601,6 +1643,9 @@ export function createControlPlaneApi(options) {
|
|
|
1601
1643
|
// Data landed — only now flip routing and move the version pointer.
|
|
1602
1644
|
await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
|
|
1603
1645
|
await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
|
|
1646
|
+
// #1674: the scope now routes to a different store, so put the directory's recorded
|
|
1647
|
+
// OFF positions back there — cheap and idempotent when the dump carried them.
|
|
1648
|
+
await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
|
|
1604
1649
|
return { servingRef: serving.ref, tables: restored.tables };
|
|
1605
1650
|
};
|
|
1606
1651
|
/**
|
|
@@ -1610,8 +1655,8 @@ export function createControlPlaneApi(options) {
|
|
|
1610
1655
|
* pointer to the promoted version so Update stops offering a crossing already made.
|
|
1611
1656
|
*
|
|
1612
1657
|
* Gated exactly where the host cascade would have run: PRIVATE (owned, unlisted) only,
|
|
1613
|
-
* active non-fork scopes only. Runs only when a serving script exists —
|
|
1614
|
-
* actually happened (dispatch-backed + deploy configured); for an embedded vertical the
|
|
1658
|
+
* active non-fork, non-preview scopes only. Runs only when a serving script exists —
|
|
1659
|
+
* i.e. the serve actually happened (dispatch-backed + deploy configured); for an embedded vertical the
|
|
1615
1660
|
* host cascade already rebound, and `verticalServing` is null, so this is a no-op.
|
|
1616
1661
|
* Idempotent and retry-safe: the host cascade never rebound these scopes, so their data
|
|
1617
1662
|
* is still findable on a retry after a failed serve.
|
|
@@ -1624,7 +1669,7 @@ export function createControlPlaneApi(options) {
|
|
|
1624
1669
|
const v = await verticalOf(actor, slug);
|
|
1625
1670
|
if (!v || v.ownerTenant === null || v.listed)
|
|
1626
1671
|
return; // private only, like the host cascade
|
|
1627
|
-
const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom);
|
|
1672
|
+
const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom && s.kind !== 'preview');
|
|
1628
1673
|
for (const s of owned) {
|
|
1629
1674
|
if (!s.servingRef) {
|
|
1630
1675
|
// Adopt: export from the scope's current (un-rebound) dispatch → serving script,
|
|
@@ -1672,6 +1717,8 @@ export function createControlPlaneApi(options) {
|
|
|
1672
1717
|
throw new ControlPlaneError(409, `vertical '${target}' has no serving script yet — promote a version to prod first`);
|
|
1673
1718
|
}
|
|
1674
1719
|
if (scope.vertical === target && scope.servingRef === serving.ref) {
|
|
1720
|
+
// #1674: a rebind retried after its re-assert failed lands here; re-assert, as adopt does.
|
|
1721
|
+
await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
|
|
1675
1722
|
return { servingRef: serving.ref, versionId: serving.versionId, alreadyBound: true };
|
|
1676
1723
|
}
|
|
1677
1724
|
if (opts.abandonData) {
|
|
@@ -1710,6 +1757,8 @@ export function createControlPlaneApi(options) {
|
|
|
1710
1757
|
// pre-migration state, and it is never deleted — that copy is the backout.
|
|
1711
1758
|
await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
|
|
1712
1759
|
await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
|
|
1760
|
+
// #1674: re-assert the recorded OFF positions in the store the scope now routes to.
|
|
1761
|
+
await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
|
|
1713
1762
|
return { servingRef: serving.ref, versionId: serving.versionId, tables: restored.tables };
|
|
1714
1763
|
};
|
|
1715
1764
|
/**
|
|
@@ -2029,7 +2078,8 @@ export function createControlPlaneApi(options) {
|
|
|
2029
2078
|
: null;
|
|
2030
2079
|
const ranAs = versionReachedAt(reached.via, scope, serving);
|
|
2031
2080
|
try {
|
|
2032
|
-
|
|
2081
|
+
// #1674: the recorded OFF positions go back after the reconcile, before the receipt.
|
|
2082
|
+
const result = await reconcileThenReassert(admin, actor, { tenantId, scopeId }, () => vertical.reconcileInstance({
|
|
2033
2083
|
tenantId,
|
|
2034
2084
|
scopeId,
|
|
2035
2085
|
entitlements,
|
|
@@ -2040,7 +2090,7 @@ export function createControlPlaneApi(options) {
|
|
|
2040
2090
|
// by the vertical, so the reconcile must carry it into the same ready-gate — a bound
|
|
2041
2091
|
// but unmigrated database fails as loudly as an absent one.
|
|
2042
2092
|
...(tenantStores.length ? { tenantStores } : {}),
|
|
2043
|
-
});
|
|
2093
|
+
}));
|
|
2044
2094
|
// #1172: the reconcile succeeded, so record WHICH version it ran against. Without
|
|
2045
2095
|
// this the sweep would come back and do it again on the next pass — the console
|
|
2046
2096
|
// button and the automatic phase have to write the same receipt, or pressing the
|
|
@@ -3085,6 +3135,9 @@ export function createControlPlaneApi(options) {
|
|
|
3085
3135
|
const vertical = await verticalForScope(c, scope);
|
|
3086
3136
|
if (vertical)
|
|
3087
3137
|
await retryTransient(() => vertical.restoreScope(tenantId, scopeId, tables));
|
|
3138
|
+
// #1674: a backup from before a module was switched off brings it back on; the
|
|
3139
|
+
// directory's record puts it back off now, not at the next reconcile.
|
|
3140
|
+
await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
|
|
3088
3141
|
return c.json({ restored: scopeId, tables: tables.length });
|
|
3089
3142
|
}
|
|
3090
3143
|
catch (e) {
|
|
@@ -3153,10 +3206,15 @@ export function createControlPlaneApi(options) {
|
|
|
3153
3206
|
force,
|
|
3154
3207
|
localApply: !vertical,
|
|
3155
3208
|
});
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3209
|
+
const answer = vertical ? await vertical.rewindScope(scopeId, bookmark, { force }) : result;
|
|
3210
|
+
// #1674: a rewind takes the scope's grants back to the bookmark, and with them any
|
|
3211
|
+
// schedule kill switch pulled since — a tenant owner could otherwise undo a staff
|
|
3212
|
+
// switch this way. Clearing the provisioned receipt, only once the rewind has gone
|
|
3213
|
+
// through, makes the next sweep reconcile the scope, and that reconcile re-asserts
|
|
3214
|
+
// what the directory records as off. Left to the sweep rather than done here: the
|
|
3215
|
+
// scope's store restarts to finish the restore, and a re-assert now would race it.
|
|
3216
|
+
await admin.markScopeProvisioned(actor, tenantId, scopeId, null);
|
|
3217
|
+
return c.json(answer);
|
|
3160
3218
|
}
|
|
3161
3219
|
catch (e) {
|
|
3162
3220
|
if (e instanceof ControlPlaneError) {
|
|
@@ -3205,7 +3263,7 @@ export function createControlPlaneApi(options) {
|
|
|
3205
3263
|
if (v.ownerTenant === null) {
|
|
3206
3264
|
return c.json({ error: 'adopt-serving is a private-vertical operation' }, 409);
|
|
3207
3265
|
}
|
|
3208
|
-
const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug })).filter((s) => !s.forkedFrom && s.status === 'active');
|
|
3266
|
+
const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug })).filter((s) => !s.forkedFrom && s.kind !== 'preview' && s.status === 'active');
|
|
3209
3267
|
const adopted = [];
|
|
3210
3268
|
const alreadyAdopted = [];
|
|
3211
3269
|
try {
|
|
@@ -3270,6 +3328,12 @@ export function createControlPlaneApi(options) {
|
|
|
3270
3328
|
if (scope)
|
|
3271
3329
|
await carryOntoVersion(c, scope, versionId);
|
|
3272
3330
|
};
|
|
3331
|
+
// #1674: after the bind, the scope may route to a different store (the carry's
|
|
3332
|
+
// destination); put the directory's recorded OFF positions back there.
|
|
3333
|
+
const reassert = async () => {
|
|
3334
|
+
if (scope)
|
|
3335
|
+
await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
|
|
3336
|
+
};
|
|
3273
3337
|
if (snapshot) {
|
|
3274
3338
|
if (!scope) {
|
|
3275
3339
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
@@ -3295,10 +3359,12 @@ export function createControlPlaneApi(options) {
|
|
|
3295
3359
|
await carry();
|
|
3296
3360
|
await admin.bindScopeVersion(actor, tenantId, scopeId, versionId, { snapshot: true });
|
|
3297
3361
|
}
|
|
3362
|
+
await reassert();
|
|
3298
3363
|
return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
|
|
3299
3364
|
}
|
|
3300
3365
|
await carry();
|
|
3301
3366
|
await admin.bindScopeVersion(actor, tenantId, scopeId, versionId);
|
|
3367
|
+
await reassert();
|
|
3302
3368
|
return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
|
|
3303
3369
|
});
|
|
3304
3370
|
// -- instances (K-31) -------------------------------------------------------
|
|
@@ -3400,6 +3466,14 @@ export function createControlPlaneApi(options) {
|
|
|
3400
3466
|
// one guessed from the serving pointer would claim a hook ran against code this
|
|
3401
3467
|
// scope was not running.
|
|
3402
3468
|
const provisioned = await admin.getScopeRecord(c.get('actor'), input.tenantId, input.scopeId);
|
|
3469
|
+
// #1674: this route is idempotent (K-31), so an already-bound scope that was wiped can
|
|
3470
|
+
// come back through it, re-seated live by the deployment — and the receipt below means
|
|
3471
|
+
// no sweep follows. Re-assert the recorded OFF positions before it. Only once the
|
|
3472
|
+
// directory row exists: a brand-new install's is written after this call (see above),
|
|
3473
|
+
// has no record to re-assert, and `reassertSystemSwitches` refuses an unknown scope.
|
|
3474
|
+
if (provisioned) {
|
|
3475
|
+
await admin.reassertSystemSwitches(c.get('actor'), { tenantId: input.tenantId, scopeId: input.scopeId });
|
|
3476
|
+
}
|
|
3403
3477
|
if (provisioned?.verticalVersionId) {
|
|
3404
3478
|
await admin.markScopeProvisioned(c.get('actor'), input.tenantId, input.scopeId, provisioned.verticalVersionId);
|
|
3405
3479
|
}
|
|
@@ -3675,6 +3749,31 @@ export function createControlPlaneApi(options) {
|
|
|
3675
3749
|
const registry = json ? (storedDeployManifest.parse(JSON.parse(json)).registry ?? null) : null;
|
|
3676
3750
|
return c.json({ registry });
|
|
3677
3751
|
});
|
|
3752
|
+
// The SQL migrations one version adds on top of another (#1677): what the promote dialog
|
|
3753
|
+
// and `substrat promote` show before a migration change is acknowledged. `?base=` names the
|
|
3754
|
+
// version to compare against (the one `prod` serves); without it every migration is listed.
|
|
3755
|
+
// Owner-narrowed like `/registry`, and never widened: migration SQL describes a schema.
|
|
3756
|
+
// `migrations` is null for a version whose manifest carries none (pushed before the field,
|
|
3757
|
+
// or over the push's cap), which a reader must show as "SQL not available", never as
|
|
3758
|
+
// "no migrations". Bounded in count and bytes (`migrationsOnTop`).
|
|
3759
|
+
app.get('/verticals/:slug/versions/:id/migrations', async (c) => {
|
|
3760
|
+
const p = c.get('principal');
|
|
3761
|
+
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
3762
|
+
if (await notOwned(p, slug)) {
|
|
3763
|
+
return c.json({ error: 'not found' }, 404);
|
|
3764
|
+
}
|
|
3765
|
+
// `versionManifest` refuses a version of another vertical, so `base` cannot reach one.
|
|
3766
|
+
const migrationsOf = async (id) => {
|
|
3767
|
+
const json = await admin.versionManifest(c.get('actor'), slug, id);
|
|
3768
|
+
return json ? (storedDeployManifest.parse(JSON.parse(json)).migrations ?? null) : null;
|
|
3769
|
+
};
|
|
3770
|
+
const base = c.req.query('base');
|
|
3771
|
+
const [incoming, baseline] = await Promise.all([
|
|
3772
|
+
migrationsOf(c.req.param('id')),
|
|
3773
|
+
base === undefined ? undefined : migrationsOf(base),
|
|
3774
|
+
]);
|
|
3775
|
+
return c.json({ migrations: incoming === null ? null : migrationsOnTop(incoming, baseline) });
|
|
3776
|
+
});
|
|
3678
3777
|
// The emitted entity model (#1214) one version ships: entities, field schemas, parent
|
|
3679
3778
|
// edges, declared lifecycles — read out of the same retained manifest as the registry
|
|
3680
3779
|
// above, and owner-narrowed the same way. `model` is null for a version pushed by a
|
|
@@ -4019,6 +4118,25 @@ export function createControlPlaneApi(options) {
|
|
|
4019
4118
|
return { minted: [], error: message };
|
|
4020
4119
|
}
|
|
4021
4120
|
};
|
|
4121
|
+
// #1705 PR 3: which installed apps promoting `?versionId=` would break (an export they import,
|
|
4122
|
+
// dropped or re-versioned), BEFORE promoting, so a dialog can show it and ask for the
|
|
4123
|
+
// acknowledgement up front, as it does for the digests. The promote's own 409 carries the same
|
|
4124
|
+
// listing. Narrowed to the caller exactly as that 409 is. Owner-checked like the promote.
|
|
4125
|
+
app.get('/verticals/:slug/channels/:channel/promote-impact', async (c) => {
|
|
4126
|
+
const p = c.get('principal');
|
|
4127
|
+
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
4128
|
+
const channel = channelName.parse(c.req.param('channel'));
|
|
4129
|
+
const versionId = z.string().min(1).parse(c.req.query('versionId'));
|
|
4130
|
+
const pin = confinedTenant(p);
|
|
4131
|
+
if (pin) {
|
|
4132
|
+
const v = await verticalOf(p.actor, slug);
|
|
4133
|
+
if (!v || v.ownerTenant !== pin)
|
|
4134
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
4135
|
+
}
|
|
4136
|
+
const breaks = await admin.promotionImpact(c.get('actor'), slug, channel, versionId);
|
|
4137
|
+
const { visible, otherTenants } = narrowToCaller(breaks, pin);
|
|
4138
|
+
return c.json({ affected: visible, ...(otherTenants ? { otherTenants } : {}) });
|
|
4139
|
+
});
|
|
4022
4140
|
app.post('/verticals/:slug/channels/:channel/promote', async (c) => {
|
|
4023
4141
|
const p = c.get('principal');
|
|
4024
4142
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
@@ -4055,10 +4173,37 @@ export function createControlPlaneApi(options) {
|
|
|
4055
4173
|
}
|
|
4056
4174
|
}
|
|
4057
4175
|
const { versionId, acknowledge } = promoteVersionBody.parse(await c.req.json());
|
|
4176
|
+
// #1705 PR 3: the installed consumers a promotion breaks (an export they import, dropped or
|
|
4177
|
+
// re-versioned). The host refuses it without `exportBreak` whoever calls, in counts only,
|
|
4178
|
+
// because it does not know who is asking. This door knows, so it adds the listing, narrowed
|
|
4179
|
+
// the way the store backfill below is: a confined caller sees its own tenant's apps and the
|
|
4180
|
+
// rest as a count, never another tenant's id. Read only when there is something to say: on
|
|
4181
|
+
// the refusal, and before an acknowledged promotion moves the channel it is measured from.
|
|
4182
|
+
const breaksOf = async () => {
|
|
4183
|
+
const breaks = await admin.promotionImpact(c.get('actor'), slug, channel, versionId);
|
|
4184
|
+
if (breaks.length === 0)
|
|
4185
|
+
return null;
|
|
4186
|
+
const { visible, otherTenants } = narrowToCaller(breaks, confinedTenant(p));
|
|
4187
|
+
return { affected: visible, ...(otherTenants ? { otherTenants } : {}) };
|
|
4188
|
+
};
|
|
4189
|
+
const exportBreaks = acknowledge?.exportBreak ? await breaksOf() : null;
|
|
4058
4190
|
// The blast-radius moment: refuses a changed digest without the acknowledgement,
|
|
4059
4191
|
// and refuses a non-admitted version. Both are enforced below the seam and
|
|
4060
4192
|
// surface as a 4xx through mapError, not a 500.
|
|
4061
|
-
|
|
4193
|
+
try {
|
|
4194
|
+
await admin.promoteVersion(c.get('actor'), slug, channel, versionId, acknowledge);
|
|
4195
|
+
}
|
|
4196
|
+
catch (err) {
|
|
4197
|
+
if (!isExportBreakRefusal(err))
|
|
4198
|
+
throw err;
|
|
4199
|
+
// The refusal stands whether or not the listing can be read. A listing that fails turns
|
|
4200
|
+
// into a note, never into a 500 that would hide that the promote was refused.
|
|
4201
|
+
const listing = await breaksOf().then((exportBreaks) => ({ exportBreaks }), (e) => ({
|
|
4202
|
+
exportBreaks: null,
|
|
4203
|
+
exportBreaksUnavailable: `which apps it breaks could not be read: ${e instanceof Error ? e.message : String(e)}`,
|
|
4204
|
+
}));
|
|
4205
|
+
return c.json({ error: err.message, ...listing }, 409);
|
|
4206
|
+
}
|
|
4062
4207
|
// The in-place serve (#286), prod only, AFTER every promote gate has passed —
|
|
4063
4208
|
// uploading first would deploy to live scopes before the acknowledgement check.
|
|
4064
4209
|
// A failed serve is NOT a failed promote: the channel moved (audited), old code
|
|
@@ -4098,12 +4243,11 @@ export function createControlPlaneApi(options) {
|
|
|
4098
4243
|
// report must not be the one place that hands them the tenant ids of everyone who
|
|
4099
4244
|
// installed their vertical. The rest is a count, which tells them the fleet was covered
|
|
4100
4245
|
// without naming who is in it.
|
|
4101
|
-
const
|
|
4102
|
-
const reportPin = confinedTenant(p2);
|
|
4103
|
-
const visible = reportPin ? backfill.minted.filter((m) => m.tenantId === reportPin) : backfill.minted;
|
|
4104
|
-
const otherTenants = new Set(backfill.minted.filter((m) => !visible.includes(m)).map((m) => m.tenantId)).size;
|
|
4246
|
+
const { visible, otherTenants } = narrowToCaller(backfill.minted, confinedTenant(c.get('principal')));
|
|
4105
4247
|
return c.json({
|
|
4106
4248
|
...promoted,
|
|
4249
|
+
// What the acknowledged break reached, so a promoter who passed `exportBreak` reads it.
|
|
4250
|
+
...(exportBreaks ? { exportBreaks } : {}),
|
|
4107
4251
|
...(backfill.minted.length || backfill.error
|
|
4108
4252
|
? {
|
|
4109
4253
|
storeBackfill: {
|
|
@@ -4561,14 +4705,28 @@ export function createControlPlaneApi(options) {
|
|
|
4561
4705
|
// than somebody else's, and needs no ownership check of its own to be safe.
|
|
4562
4706
|
scopeId: z.string().min(1).max(64).optional(),
|
|
4563
4707
|
vertical: z.string().min(1).max(200).optional(),
|
|
4708
|
+
// A coarser grouping of the same rows (#1767) — never a wider read.
|
|
4709
|
+
grain: z.enum(['surface', 'scope']).optional(),
|
|
4564
4710
|
hours: z.coerce.number().int().min(1).max(72).default(24),
|
|
4711
|
+
since: z.string().datetime({ offset: true }).optional(),
|
|
4712
|
+
until: z.string().datetime({ offset: true }).optional(),
|
|
4565
4713
|
})
|
|
4566
4714
|
.parse({
|
|
4567
4715
|
tenantId,
|
|
4568
4716
|
scopeId: c.req.query('scopeId') || undefined,
|
|
4569
4717
|
vertical: c.req.query('vertical') || undefined,
|
|
4570
|
-
|
|
4718
|
+
grain: c.req.query('grain') || undefined,
|
|
4719
|
+
hours: c.req.query('hours'), since: c.req.query('since'), until: c.req.query('until'),
|
|
4571
4720
|
});
|
|
4721
|
+
try {
|
|
4722
|
+
resolveObservabilityWindow(input);
|
|
4723
|
+
}
|
|
4724
|
+
catch (e) {
|
|
4725
|
+
throw new ControlPlaneError(400, e.message);
|
|
4726
|
+
}
|
|
4727
|
+
if (input.since !== undefined && !options.observability.absoluteTenantWindows) {
|
|
4728
|
+
throw new ControlPlaneError(501, 'absolute tenant telemetry windows are not supported by this backend');
|
|
4729
|
+
}
|
|
4572
4730
|
return c.json(await options.observability.tenantMetrics(input));
|
|
4573
4731
|
});
|
|
4574
4732
|
// The bucketed twin (#1447): the series a team-level chart plots, one line per app.
|
|
@@ -4592,8 +4750,19 @@ export function createControlPlaneApi(options) {
|
|
|
4592
4750
|
// zero rows rather than somebody else's, so no ownership check is needed here.
|
|
4593
4751
|
scopeIds: z.array(z.string().min(1).max(64)).min(1).max(TENANT_SERIES_SCOPE_CAP),
|
|
4594
4752
|
hours: z.coerce.number().int().min(1).max(72).default(24),
|
|
4753
|
+
since: z.string().datetime({ offset: true }).optional(),
|
|
4754
|
+
until: z.string().datetime({ offset: true }).optional(),
|
|
4595
4755
|
})
|
|
4596
|
-
.parse({ tenantId, scopeIds, hours: c.req.query('hours') });
|
|
4756
|
+
.parse({ tenantId, scopeIds, hours: c.req.query('hours'), since: c.req.query('since'), until: c.req.query('until') });
|
|
4757
|
+
try {
|
|
4758
|
+
resolveObservabilityWindow(input);
|
|
4759
|
+
}
|
|
4760
|
+
catch (e) {
|
|
4761
|
+
throw new ControlPlaneError(400, e.message);
|
|
4762
|
+
}
|
|
4763
|
+
if (input.since !== undefined && !options.observability.absoluteTenantWindows) {
|
|
4764
|
+
throw new ControlPlaneError(501, 'absolute tenant telemetry windows are not supported by this backend');
|
|
4765
|
+
}
|
|
4597
4766
|
return c.json(await options.observability.tenantMetricsSeries(input));
|
|
4598
4767
|
});
|
|
4599
4768
|
app.get('/observability/tenant-logs', async (c) => {
|
|
@@ -5213,6 +5382,8 @@ export function createControlPlaneApi(options) {
|
|
|
5213
5382
|
if (existing.servingRef)
|
|
5214
5383
|
await admin.setScopeServingRef(actor, tenantId, existing.id, null);
|
|
5215
5384
|
await admin.bindScopeVersion(actor, tenantId, existing.id, opts.versionId);
|
|
5385
|
+
// #1674: re-assert any recorded OFF in the script the preview now routes to.
|
|
5386
|
+
await admin.reassertSystemSwitches(actor, { tenantId, scopeId: existing.id });
|
|
5216
5387
|
// Renew (or clear) the preview's GC deadline so a reused preview does not silently die.
|
|
5217
5388
|
await admin.setScopeExpiresAt(actor, tenantId, existing.id, expiresAt);
|
|
5218
5389
|
const hostname = await bindPreviewHostname(actor, baseHostname, tenantId, existing.id, opts.tag, surface);
|
|
@@ -5572,6 +5743,36 @@ export function createControlPlaneApi(options) {
|
|
|
5572
5743
|
}
|
|
5573
5744
|
return c.json(await admin.systemGrantsStatus(actor, { tenantId, scopeId }));
|
|
5574
5745
|
});
|
|
5746
|
+
// The fleet read (#1674): every scope with a module switched off, from the directory's
|
|
5747
|
+
// record of the switch, so it needs no walk of every scope's store. The record is not the
|
|
5748
|
+
// gate; the per-scope read above is where a drift between the two shows (`recorded`).
|
|
5749
|
+
//
|
|
5750
|
+
// **Staff and the platform service token ONLY.** Outside `/tenants/:id`, and in neither
|
|
5751
|
+
// BUILDER_ROUTES nor TENANT_ROUTES, so a builder and a tenant credential are both refused
|
|
5752
|
+
// by default-deny before this runs. The `confinedTenant` check is the second lock, not
|
|
5753
|
+
// the first: a fleet-wide read is exactly what a confined credential must never make, and
|
|
5754
|
+
// it must not start answering if this path is ever allowlisted for a narrowed read.
|
|
5755
|
+
app.get('/system-switches', async (c) => {
|
|
5756
|
+
if (confinedTenant(c.get('principal')) !== null) {
|
|
5757
|
+
return c.json({ error: 'forbidden: the schedule switch is staff-only' }, 403);
|
|
5758
|
+
}
|
|
5759
|
+
const q = systemSwitchesQuery.parse({
|
|
5760
|
+
position: c.req.query('position'),
|
|
5761
|
+
tenantId: c.req.query('tenantId'),
|
|
5762
|
+
scopeId: c.req.query('scopeId'),
|
|
5763
|
+
moduleId: c.req.query('moduleId'),
|
|
5764
|
+
vertical: c.req.query('vertical'),
|
|
5765
|
+
limit: c.req.query('limit'),
|
|
5766
|
+
cursor: c.req.query('cursor'),
|
|
5767
|
+
order: c.req.query('order'),
|
|
5768
|
+
});
|
|
5769
|
+
const { position, ...rest } = q;
|
|
5770
|
+
const entries = await admin.listSystemSwitches(c.get('actor'), {
|
|
5771
|
+
...rest,
|
|
5772
|
+
...(position === 'all' ? {} : { position }),
|
|
5773
|
+
});
|
|
5774
|
+
return c.json(pageOf(entries, q.limit, (e) => e.operationId));
|
|
5775
|
+
});
|
|
5575
5776
|
// -- the peer kill switch (#1706) ------------------------------------------
|
|
5576
5777
|
//
|
|
5577
5778
|
// One calling vertical's access to ONE scope, off and back on — `revokeFromPeer` and
|
|
@@ -5694,6 +5895,51 @@ export function createControlPlaneApi(options) {
|
|
|
5694
5895
|
const entries = await admin.listSweepRuns(c.get('actor'), filter);
|
|
5695
5896
|
return c.json(pageOf(entries, filter.limit, (e) => e.id));
|
|
5696
5897
|
});
|
|
5898
|
+
// -- the replay lever (#1705 PR 3): move a consumer's watermark on one edge ----
|
|
5899
|
+
// Staff (the console) and a tenant credential (the dashboard, for someone who may manage the
|
|
5900
|
+
// tenant's apps) may pull it: the edge is between two of the tenant's own apps, and what a
|
|
5901
|
+
// replay runs lands in the tenant's own scope. The peer switch's posture, for that reason.
|
|
5902
|
+
// Builders may not: this route is absent from BUILDER_ROUTES, which is default-deny.
|
|
5903
|
+
//
|
|
5904
|
+
// The acknowledgement is refused HERE with the sentence it stands for, rather than left to a
|
|
5905
|
+
// Zod message about a literal. The person pulling the lever has to be told, in words, that a
|
|
5906
|
+
// replay runs handlers again and repeats what they send or call outside the app.
|
|
5907
|
+
app.post('/tenants/:tenantId/scopes/:scopeId/import-cursor', async (c) => {
|
|
5908
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
5909
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
5910
|
+
const pin = confinedTenant(c.get('principal'));
|
|
5911
|
+
if (pin !== null && pin !== tenantId)
|
|
5912
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
5913
|
+
const raw = await c.req.json().catch(() => ({}));
|
|
5914
|
+
const refused = importCursorAcknowledgementMissing(raw);
|
|
5915
|
+
if (refused)
|
|
5916
|
+
return c.json({ error: refused }, 400);
|
|
5917
|
+
const move = importCursorMove.parse(raw);
|
|
5918
|
+
const actor = c.get('actor');
|
|
5919
|
+
// K-3 first, so a scope of another tenant reads as absent before anything is reached.
|
|
5920
|
+
if (!(await admin.getScopeRecord(actor, tenantId, scopeId))) {
|
|
5921
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
5922
|
+
}
|
|
5923
|
+
return c.json(await admin.moveImportCursor(actor, tenantId, scopeId, move));
|
|
5924
|
+
});
|
|
5925
|
+
// -- edge health (#1705 PR 3): where each cross-vertical edge of one tenant stands -
|
|
5926
|
+
// Read live, through the phase's own reach, so it cannot disagree with the next pass about
|
|
5927
|
+
// which edges exist. Staff and the tenant's own credential, tenant-pinned (the dashboard shows
|
|
5928
|
+
// a tenant its own apps' edges). Builders may not: absent from BUILDER_ROUTES. `?scopeId=`
|
|
5929
|
+
// narrows it to one scope's edges, so a per-app view asks nothing about the rest.
|
|
5930
|
+
app.get('/tenants/:tenantId/cross-vertical/edges', async (c) => {
|
|
5931
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
5932
|
+
const pin = confinedTenant(c.get('principal'));
|
|
5933
|
+
if (pin !== null && pin !== tenantId)
|
|
5934
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
5935
|
+
const focus = c.req.query('scopeId');
|
|
5936
|
+
return c.json(await crossVerticalHealth(host, {
|
|
5937
|
+
actor: c.get('actor'),
|
|
5938
|
+
tenantId,
|
|
5939
|
+
...(focus ? { focus: scopeIdSchema.parse(focus) } : {}),
|
|
5940
|
+
...(options.crossVertical ? { crossVertical: options.crossVertical } : {}),
|
|
5941
|
+
}));
|
|
5942
|
+
});
|
|
5697
5943
|
// -- issues (#1233): failures grouped by fingerprint, with a lifecycle --------
|
|
5698
5944
|
// Staff-only (absent from BUILDER_ROUTES): an issue is a fleet-scoped aggregate
|
|
5699
5945
|
// with no tenant column, so the forced-filter posture cannot narrow it — a
|