@substrat-run/control-plane-api 0.120.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 +233 -14
- package/dist/api.js.map +1 -1
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +11 -4
- 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 +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +15 -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 +5 -5
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":"AACA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,EAyEL,CAAC,
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
|
package/dist/api.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { resolveObservabilityWindow } from './observability-window.js';
|
|
2
2
|
import { Hono } from 'hono';
|
|
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, 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';
|
|
4
4
|
import { attributeFailure } from './failure-attribution.js';
|
|
5
|
-
import { migrationProgress, ulid } from '@substrat-run/kernel';
|
|
5
|
+
import { crossVerticalHealth, isExportBreakRefusal, migrationProgress, ulid } from '@substrat-run/kernel';
|
|
6
6
|
import { TENANT_HEADER, confinedTenant } from './auth.js';
|
|
7
7
|
import { mintTenantToken } from './tenant-token.js';
|
|
8
8
|
import { connectionGrantsForScope } from './vertical-client.js';
|
|
9
9
|
import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
|
|
10
10
|
import { versionReachedAt } from './scope-deployment.js';
|
|
11
11
|
import { reconcileConnectionGrants } from './connection-grants.js';
|
|
12
|
+
import { reconcileThenReassert } from './reconcile.js';
|
|
12
13
|
import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
|
|
13
14
|
import { ControlPlaneError } from './client.js';
|
|
14
15
|
import { provisionSiblingScope } from './platform-drain.js';
|
|
@@ -56,6 +57,19 @@ function outsideTenant(p, tenantId) {
|
|
|
56
57
|
const pin = confinedTenant(p);
|
|
57
58
|
return pin !== null && pin !== tenantId;
|
|
58
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
|
+
}
|
|
59
73
|
// -- request schemas ---------------------------------------------------------
|
|
60
74
|
// Parse, don't trust: every input crosses Zod at the boundary. The ids stay
|
|
61
75
|
// CALLER-SUPPLIED rather than minted here, exactly as the contract has them —
|
|
@@ -371,6 +385,19 @@ const tenantRoleAssignmentBody = z
|
|
|
371
385
|
* spells out; `.strict()` so a body that tried to name one is refused, not stripped.
|
|
372
386
|
*/
|
|
373
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
|
+
});
|
|
374
401
|
/** The peer kill switch's body (#1706) — the tenant and scope come from the path. */
|
|
375
402
|
const peerSwitchBody = peerSwitch.pick({ vertical: true, reason: true }).strict();
|
|
376
403
|
/** A new org under an addressed tenant (#1343) — `tenantId` comes from the path. */
|
|
@@ -453,6 +480,7 @@ const TENANT_ROUTES = [
|
|
|
453
480
|
{ method: 'DELETE', re: /\/verticals\/[^/]+$/, pin: 'owner' },
|
|
454
481
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions$/, pin: 'owner' },
|
|
455
482
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/registry$/, pin: 'owner' },
|
|
483
|
+
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/migrations$/, pin: 'owner' },
|
|
456
484
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/, pin: 'owner' },
|
|
457
485
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/flow$/, pin: 'owner' },
|
|
458
486
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/assets$/, pin: 'owner' },
|
|
@@ -460,6 +488,7 @@ const TENANT_ROUTES = [
|
|
|
460
488
|
{ method: 'GET', re: /\/verticals\/[^/]+\/channels$/, pin: 'owner' },
|
|
461
489
|
{ method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/history$/, pin: 'owner' },
|
|
462
490
|
{ method: 'POST', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote$/, pin: 'owner' },
|
|
491
|
+
{ method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote-impact$/, pin: 'owner' },
|
|
463
492
|
{ method: 'GET', re: /\/verticals\/[^/]+\/previews$/, pin: 'owner' },
|
|
464
493
|
{ method: 'POST', re: /\/verticals\/[^/]+\/previews$/, pin: 'owner' },
|
|
465
494
|
{ method: 'DELETE', re: /\/verticals\/[^/]+\/previews\/[^/]+$/, pin: 'owner' },
|
|
@@ -612,6 +641,9 @@ export function createControlPlaneApi(options) {
|
|
|
612
641
|
// The declared permission registry of one version (D-39, #336) — owner-narrowed in the
|
|
613
642
|
// handler like the versions list; the builder-facing Permissions tab reads it.
|
|
614
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$/ },
|
|
615
647
|
// The emitted entity model of one version (#1214) — owner-narrowed the same way;
|
|
616
648
|
// the dashboard's Model tab reads it.
|
|
617
649
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/ },
|
|
@@ -1587,8 +1619,13 @@ export function createControlPlaneApi(options) {
|
|
|
1587
1619
|
if (scope.kind === 'preview') {
|
|
1588
1620
|
throw new ControlPlaneError(409, 'preview scopes cannot adopt the production serving script');
|
|
1589
1621
|
}
|
|
1590
|
-
if (scope.servingRef)
|
|
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 });
|
|
1591
1627
|
return { servingRef: scope.servingRef, alreadyAdopted: true };
|
|
1628
|
+
}
|
|
1592
1629
|
if (!scope.vertical) {
|
|
1593
1630
|
throw new ControlPlaneError(409, 'scope has no vertical — nothing to adopt onto');
|
|
1594
1631
|
}
|
|
@@ -1606,6 +1643,9 @@ export function createControlPlaneApi(options) {
|
|
|
1606
1643
|
// Data landed — only now flip routing and move the version pointer.
|
|
1607
1644
|
await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
|
|
1608
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 });
|
|
1609
1649
|
return { servingRef: serving.ref, tables: restored.tables };
|
|
1610
1650
|
};
|
|
1611
1651
|
/**
|
|
@@ -1677,6 +1717,8 @@ export function createControlPlaneApi(options) {
|
|
|
1677
1717
|
throw new ControlPlaneError(409, `vertical '${target}' has no serving script yet — promote a version to prod first`);
|
|
1678
1718
|
}
|
|
1679
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 });
|
|
1680
1722
|
return { servingRef: serving.ref, versionId: serving.versionId, alreadyBound: true };
|
|
1681
1723
|
}
|
|
1682
1724
|
if (opts.abandonData) {
|
|
@@ -1715,6 +1757,8 @@ export function createControlPlaneApi(options) {
|
|
|
1715
1757
|
// pre-migration state, and it is never deleted — that copy is the backout.
|
|
1716
1758
|
await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
|
|
1717
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 });
|
|
1718
1762
|
return { servingRef: serving.ref, versionId: serving.versionId, tables: restored.tables };
|
|
1719
1763
|
};
|
|
1720
1764
|
/**
|
|
@@ -2034,7 +2078,8 @@ export function createControlPlaneApi(options) {
|
|
|
2034
2078
|
: null;
|
|
2035
2079
|
const ranAs = versionReachedAt(reached.via, scope, serving);
|
|
2036
2080
|
try {
|
|
2037
|
-
|
|
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({
|
|
2038
2083
|
tenantId,
|
|
2039
2084
|
scopeId,
|
|
2040
2085
|
entitlements,
|
|
@@ -2045,7 +2090,7 @@ export function createControlPlaneApi(options) {
|
|
|
2045
2090
|
// by the vertical, so the reconcile must carry it into the same ready-gate — a bound
|
|
2046
2091
|
// but unmigrated database fails as loudly as an absent one.
|
|
2047
2092
|
...(tenantStores.length ? { tenantStores } : {}),
|
|
2048
|
-
});
|
|
2093
|
+
}));
|
|
2049
2094
|
// #1172: the reconcile succeeded, so record WHICH version it ran against. Without
|
|
2050
2095
|
// this the sweep would come back and do it again on the next pass — the console
|
|
2051
2096
|
// button and the automatic phase have to write the same receipt, or pressing the
|
|
@@ -3090,6 +3135,9 @@ export function createControlPlaneApi(options) {
|
|
|
3090
3135
|
const vertical = await verticalForScope(c, scope);
|
|
3091
3136
|
if (vertical)
|
|
3092
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 });
|
|
3093
3141
|
return c.json({ restored: scopeId, tables: tables.length });
|
|
3094
3142
|
}
|
|
3095
3143
|
catch (e) {
|
|
@@ -3158,10 +3206,15 @@ export function createControlPlaneApi(options) {
|
|
|
3158
3206
|
force,
|
|
3159
3207
|
localApply: !vertical,
|
|
3160
3208
|
});
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
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);
|
|
3165
3218
|
}
|
|
3166
3219
|
catch (e) {
|
|
3167
3220
|
if (e instanceof ControlPlaneError) {
|
|
@@ -3275,6 +3328,12 @@ export function createControlPlaneApi(options) {
|
|
|
3275
3328
|
if (scope)
|
|
3276
3329
|
await carryOntoVersion(c, scope, versionId);
|
|
3277
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
|
+
};
|
|
3278
3337
|
if (snapshot) {
|
|
3279
3338
|
if (!scope) {
|
|
3280
3339
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
@@ -3300,10 +3359,12 @@ export function createControlPlaneApi(options) {
|
|
|
3300
3359
|
await carry();
|
|
3301
3360
|
await admin.bindScopeVersion(actor, tenantId, scopeId, versionId, { snapshot: true });
|
|
3302
3361
|
}
|
|
3362
|
+
await reassert();
|
|
3303
3363
|
return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
|
|
3304
3364
|
}
|
|
3305
3365
|
await carry();
|
|
3306
3366
|
await admin.bindScopeVersion(actor, tenantId, scopeId, versionId);
|
|
3367
|
+
await reassert();
|
|
3307
3368
|
return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
|
|
3308
3369
|
});
|
|
3309
3370
|
// -- instances (K-31) -------------------------------------------------------
|
|
@@ -3405,6 +3466,14 @@ export function createControlPlaneApi(options) {
|
|
|
3405
3466
|
// one guessed from the serving pointer would claim a hook ran against code this
|
|
3406
3467
|
// scope was not running.
|
|
3407
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
|
+
}
|
|
3408
3477
|
if (provisioned?.verticalVersionId) {
|
|
3409
3478
|
await admin.markScopeProvisioned(c.get('actor'), input.tenantId, input.scopeId, provisioned.verticalVersionId);
|
|
3410
3479
|
}
|
|
@@ -3680,6 +3749,31 @@ export function createControlPlaneApi(options) {
|
|
|
3680
3749
|
const registry = json ? (storedDeployManifest.parse(JSON.parse(json)).registry ?? null) : null;
|
|
3681
3750
|
return c.json({ registry });
|
|
3682
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
|
+
});
|
|
3683
3777
|
// The emitted entity model (#1214) one version ships: entities, field schemas, parent
|
|
3684
3778
|
// edges, declared lifecycles — read out of the same retained manifest as the registry
|
|
3685
3779
|
// above, and owner-narrowed the same way. `model` is null for a version pushed by a
|
|
@@ -4024,6 +4118,25 @@ export function createControlPlaneApi(options) {
|
|
|
4024
4118
|
return { minted: [], error: message };
|
|
4025
4119
|
}
|
|
4026
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
|
+
});
|
|
4027
4140
|
app.post('/verticals/:slug/channels/:channel/promote', async (c) => {
|
|
4028
4141
|
const p = c.get('principal');
|
|
4029
4142
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
@@ -4060,10 +4173,37 @@ export function createControlPlaneApi(options) {
|
|
|
4060
4173
|
}
|
|
4061
4174
|
}
|
|
4062
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;
|
|
4063
4190
|
// The blast-radius moment: refuses a changed digest without the acknowledgement,
|
|
4064
4191
|
// and refuses a non-admitted version. Both are enforced below the seam and
|
|
4065
4192
|
// surface as a 4xx through mapError, not a 500.
|
|
4066
|
-
|
|
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
|
+
}
|
|
4067
4207
|
// The in-place serve (#286), prod only, AFTER every promote gate has passed —
|
|
4068
4208
|
// uploading first would deploy to live scopes before the acknowledgement check.
|
|
4069
4209
|
// A failed serve is NOT a failed promote: the channel moved (audited), old code
|
|
@@ -4103,12 +4243,11 @@ export function createControlPlaneApi(options) {
|
|
|
4103
4243
|
// report must not be the one place that hands them the tenant ids of everyone who
|
|
4104
4244
|
// installed their vertical. The rest is a count, which tells them the fleet was covered
|
|
4105
4245
|
// without naming who is in it.
|
|
4106
|
-
const
|
|
4107
|
-
const reportPin = confinedTenant(p2);
|
|
4108
|
-
const visible = reportPin ? backfill.minted.filter((m) => m.tenantId === reportPin) : backfill.minted;
|
|
4109
|
-
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')));
|
|
4110
4247
|
return c.json({
|
|
4111
4248
|
...promoted,
|
|
4249
|
+
// What the acknowledged break reached, so a promoter who passed `exportBreak` reads it.
|
|
4250
|
+
...(exportBreaks ? { exportBreaks } : {}),
|
|
4112
4251
|
...(backfill.minted.length || backfill.error
|
|
4113
4252
|
? {
|
|
4114
4253
|
storeBackfill: {
|
|
@@ -4566,6 +4705,8 @@ export function createControlPlaneApi(options) {
|
|
|
4566
4705
|
// than somebody else's, and needs no ownership check of its own to be safe.
|
|
4567
4706
|
scopeId: z.string().min(1).max(64).optional(),
|
|
4568
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(),
|
|
4569
4710
|
hours: z.coerce.number().int().min(1).max(72).default(24),
|
|
4570
4711
|
since: z.string().datetime({ offset: true }).optional(),
|
|
4571
4712
|
until: z.string().datetime({ offset: true }).optional(),
|
|
@@ -4574,6 +4715,7 @@ export function createControlPlaneApi(options) {
|
|
|
4574
4715
|
tenantId,
|
|
4575
4716
|
scopeId: c.req.query('scopeId') || undefined,
|
|
4576
4717
|
vertical: c.req.query('vertical') || undefined,
|
|
4718
|
+
grain: c.req.query('grain') || undefined,
|
|
4577
4719
|
hours: c.req.query('hours'), since: c.req.query('since'), until: c.req.query('until'),
|
|
4578
4720
|
});
|
|
4579
4721
|
try {
|
|
@@ -5240,6 +5382,8 @@ export function createControlPlaneApi(options) {
|
|
|
5240
5382
|
if (existing.servingRef)
|
|
5241
5383
|
await admin.setScopeServingRef(actor, tenantId, existing.id, null);
|
|
5242
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 });
|
|
5243
5387
|
// Renew (or clear) the preview's GC deadline so a reused preview does not silently die.
|
|
5244
5388
|
await admin.setScopeExpiresAt(actor, tenantId, existing.id, expiresAt);
|
|
5245
5389
|
const hostname = await bindPreviewHostname(actor, baseHostname, tenantId, existing.id, opts.tag, surface);
|
|
@@ -5599,6 +5743,36 @@ export function createControlPlaneApi(options) {
|
|
|
5599
5743
|
}
|
|
5600
5744
|
return c.json(await admin.systemGrantsStatus(actor, { tenantId, scopeId }));
|
|
5601
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
|
+
});
|
|
5602
5776
|
// -- the peer kill switch (#1706) ------------------------------------------
|
|
5603
5777
|
//
|
|
5604
5778
|
// One calling vertical's access to ONE scope, off and back on — `revokeFromPeer` and
|
|
@@ -5721,6 +5895,51 @@ export function createControlPlaneApi(options) {
|
|
|
5721
5895
|
const entries = await admin.listSweepRuns(c.get('actor'), filter);
|
|
5722
5896
|
return c.json(pageOf(entries, filter.limit, (e) => e.id));
|
|
5723
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
|
+
});
|
|
5724
5943
|
// -- issues (#1233): failures grouped by fingerprint, with a lifecycle --------
|
|
5725
5944
|
// Staff-only (absent from BUILDER_ROUTES): an issue is a fleet-scoped aggregate
|
|
5726
5945
|
// with no tenant column, so the forced-filter posture cannot narrow it — a
|