@substrat-run/control-plane-api 0.118.0 → 0.120.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 +2 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +533 -18
- package/dist/api.js.map +1 -1
- package/dist/cf-observability.d.ts +8 -0
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +135 -6
- package/dist/cf-observability.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- 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 +63 -0
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js.map +1 -1
- package/dist/platform-drain.d.ts +35 -0
- package/dist/platform-drain.d.ts.map +1 -1
- package/dist/platform-drain.js +109 -2
- package/dist/platform-drain.js.map +1 -1
- package/dist/preview-auth.d.ts +51 -0
- package/dist/preview-auth.d.ts.map +1 -0
- package/dist/preview-auth.js +273 -0
- package/dist/preview-auth.js.map +1 -0
- package/dist/vertical-client.d.ts +81 -1
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +185 -1
- package/dist/vertical-client.js.map +1 -1
- package/package.json +6 -6
package/dist/api.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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, 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, 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, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
3
4
|
import { attributeFailure } from './failure-attribution.js';
|
|
4
5
|
import { 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';
|
|
9
|
+
import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
|
|
8
10
|
import { versionReachedAt } from './scope-deployment.js';
|
|
9
11
|
import { reconcileConnectionGrants } from './connection-grants.js';
|
|
10
12
|
import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
|
|
@@ -298,6 +300,32 @@ const opsFailuresQuery = z.object({
|
|
|
298
300
|
// Bounded by default exactly as /admin-log, and for the same reason.
|
|
299
301
|
...listPageQuery.shape,
|
|
300
302
|
});
|
|
303
|
+
// The fleet connection-health read (#1690). `status` is the DERIVED health state, not the
|
|
304
|
+
// stored `Connection.status`. Paged like every HTTP list read; the cursor is a connection id.
|
|
305
|
+
const connectionHealthQuery = z.object({
|
|
306
|
+
status: connectionHealthState.optional(),
|
|
307
|
+
provider: connectionProvider.optional(),
|
|
308
|
+
tenantId: tenantIdSchema.optional(),
|
|
309
|
+
// Free text, matched case-insensitively against the row's own fields BEFORE paging, so
|
|
310
|
+
// a search finds a row on any page rather than only the ones a console has loaded.
|
|
311
|
+
q: z.string().trim().min(1).max(200).optional(),
|
|
312
|
+
limit: listPageQuery.shape.limit,
|
|
313
|
+
cursor: listPageQuery.shape.cursor,
|
|
314
|
+
// The walk is ascending by connection id, only. A `desc` is refused rather than
|
|
315
|
+
// silently ignored: a caller would otherwise read ascending pages as descending ones.
|
|
316
|
+
order: z.literal('asc').optional(),
|
|
317
|
+
});
|
|
318
|
+
/** The dead-letter window (#1690) — a week, the same horizon as the stale window. */
|
|
319
|
+
const CONNECTOR_DEAD_LETTER_WINDOW_DAYS = 7;
|
|
320
|
+
/** The per-provider read bound: past it the count is a floor, and says so (`capped`). */
|
|
321
|
+
export const CONNECTOR_DEAD_LETTER_CAP = 200;
|
|
322
|
+
/**
|
|
323
|
+
* `/platform-requests/backlog` (#1690 §2) rides the SAME window and per-kind bound as the
|
|
324
|
+
* connector dead-letter count above — one incident horizon, one honestly-capped read, for
|
|
325
|
+
* every fleet-wide "how much has given up lately" number this API answers.
|
|
326
|
+
*/
|
|
327
|
+
const PLATFORM_REQUEST_BACKLOG_WINDOW_DAYS = CONNECTOR_DEAD_LETTER_WINDOW_DAYS;
|
|
328
|
+
const PLATFORM_REQUEST_BACKLOG_CAP = CONNECTOR_DEAD_LETTER_CAP;
|
|
301
329
|
// The issues read (#1233). No cursor by design: grouping IS the compression —
|
|
302
330
|
// cardinality is the number of distinct failure shapes — and `limit` bounds it.
|
|
303
331
|
const issuesQuery = z.object({
|
|
@@ -343,6 +371,8 @@ const tenantRoleAssignmentBody = z
|
|
|
343
371
|
* spells out; `.strict()` so a body that tried to name one is refused, not stripped.
|
|
344
372
|
*/
|
|
345
373
|
const systemSwitchBody = systemSwitch.pick({ moduleId: true, reason: true }).strict();
|
|
374
|
+
/** The peer kill switch's body (#1706) — the tenant and scope come from the path. */
|
|
375
|
+
const peerSwitchBody = peerSwitch.pick({ vertical: true, reason: true }).strict();
|
|
346
376
|
/** A new org under an addressed tenant (#1343) — `tenantId` comes from the path. */
|
|
347
377
|
const tenantOrgBody = createOrgInput.omit({ tenantId: true });
|
|
348
378
|
const sweepRunsQuery = z.object({
|
|
@@ -1031,6 +1061,180 @@ export function createControlPlaneApi(options) {
|
|
|
1031
1061
|
});
|
|
1032
1062
|
return c.json(await admin.listConnections(c.get('actor'), filter));
|
|
1033
1063
|
});
|
|
1064
|
+
// -- connection health, fleet-wide (#1690) -----------------------------------
|
|
1065
|
+
//
|
|
1066
|
+
// The staff console's Health → Connections read: every tenant's connections, each with
|
|
1067
|
+
// its derived health (`deriveConnectionHealth`, contracts) and refresh-expiry warning,
|
|
1068
|
+
// plus connector dead letters counted per provider. Read-only, and staff/service only
|
|
1069
|
+
// by construction: it is neither in BUILDER_ROUTES nor in the tenant credential's
|
|
1070
|
+
// TENANT_ROUTES, so both default-deny it — and it lives outside `/tenants/:id` so no
|
|
1071
|
+
// future tenant-path pin can reach it by pattern.
|
|
1072
|
+
//
|
|
1073
|
+
// Secrets: the read goes through the AUDITED `listConnections` (the access log records
|
|
1074
|
+
// the filter and a count, never rows — §3.4's rule for the log), whose `Connection`
|
|
1075
|
+
// cannot carry a credential; and each row is then projected through
|
|
1076
|
+
// `toConnectionHealthEntry`'s allow-list rather than spread, so a widened row upstream
|
|
1077
|
+
// does not ride out to a browser.
|
|
1078
|
+
//
|
|
1079
|
+
// Cost: `listConnections` has no page of its own, so this loads the (filtered) fleet's
|
|
1080
|
+
// connection rows on every request and pages in memory. Unfiltered that is a full scan
|
|
1081
|
+
// of `_substrat_connections`, a table in the low hundreds today. Past ~2,000 live rows
|
|
1082
|
+
// (a directory read and a response-time cost paid on every console load) it wants a
|
|
1083
|
+
// keyset page pushed into the adapter, which the derived-status filter makes
|
|
1084
|
+
// non-trivial, because health is not a column. No cache: a stale health view is the
|
|
1085
|
+
// one thing this page must not show. Filter-then-page is deliberate: paging first would hand back a short or
|
|
1086
|
+
// empty page while a cursor still existed.
|
|
1087
|
+
app.get('/connections/health', async (c) => {
|
|
1088
|
+
const q = connectionHealthQuery.parse({
|
|
1089
|
+
status: c.req.query('status'),
|
|
1090
|
+
provider: c.req.query('provider'),
|
|
1091
|
+
tenantId: c.req.query('tenantId'),
|
|
1092
|
+
q: c.req.query('q'),
|
|
1093
|
+
limit: c.req.query('limit'),
|
|
1094
|
+
cursor: c.req.query('cursor'),
|
|
1095
|
+
order: c.req.query('order'),
|
|
1096
|
+
});
|
|
1097
|
+
const now = new Date();
|
|
1098
|
+
const rows = await admin.listConnections(c.get('actor'), {
|
|
1099
|
+
...(q.tenantId ? { tenantId: q.tenantId } : {}),
|
|
1100
|
+
...(q.provider ? { provider: q.provider } : {}),
|
|
1101
|
+
});
|
|
1102
|
+
const all = rows.map((r) => toConnectionHealthEntry(r, now)).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
1103
|
+
const summary = {
|
|
1104
|
+
total: all.length,
|
|
1105
|
+
healthy: 0,
|
|
1106
|
+
erroring: 0,
|
|
1107
|
+
stale: 0,
|
|
1108
|
+
'never-used': 0,
|
|
1109
|
+
expiring: 0,
|
|
1110
|
+
expired: 0,
|
|
1111
|
+
};
|
|
1112
|
+
for (const e of all) {
|
|
1113
|
+
summary[e.health] += 1;
|
|
1114
|
+
if (e.expiryWarning === 'soon')
|
|
1115
|
+
summary.expiring += 1;
|
|
1116
|
+
if (e.expiryWarning === 'expired')
|
|
1117
|
+
summary.expired += 1;
|
|
1118
|
+
}
|
|
1119
|
+
// #1716: ids are Crockford-base32 ULIDs, drawn from a small alphabet that a short
|
|
1120
|
+
// human needle collides with by chance (`a2` inside a random 16-char tail, ~1.5%
|
|
1121
|
+
// of the time) — worse, two ids minted in the same millisecond share a monotonic
|
|
1122
|
+
// prefix, so one hit there matches every row born that millisecond. The console's
|
|
1123
|
+
// box promises "provider, vertical, account, or error" (ConnectionsHealth.tsx) —
|
|
1124
|
+
// never ids — so only THOSE fields get substring matching. A tenant already has the
|
|
1125
|
+
// exact `?tenantId=` filter, so `e.tenantId` gets no needle match at all. Pasting a
|
|
1126
|
+
// connection id still finds its row: `q` equal to the WHOLE id, case-insensitively,
|
|
1127
|
+
// is an exact match a fragment can never collide with.
|
|
1128
|
+
const needle = q.q?.toLowerCase();
|
|
1129
|
+
const matching = all.filter((e) => (!q.status || e.health === q.status) &&
|
|
1130
|
+
(!needle ||
|
|
1131
|
+
e.id.toLowerCase() === needle ||
|
|
1132
|
+
[e.provider, e.vertical, e.label, e.externalAccountRef, e.lastError].some((f) => f?.toLowerCase().includes(needle))));
|
|
1133
|
+
const page = pageSlice(matching, { limit: q.limit, cursor: q.cursor }, (e) => e.id);
|
|
1134
|
+
// Connector dead letters. A hosted vertical is CP-less, so its connector deliveries
|
|
1135
|
+
// run as `connector:<provider>` platform intents, and a terminal or given-up intent
|
|
1136
|
+
// lands an ops-failure row (platform-drain.ts) — directory-side, so this is one
|
|
1137
|
+
// bounded query per provider rather than a walk over every scope. What it cannot see:
|
|
1138
|
+
// an IN-PROCESS executor drain's dead letters (a host that runs connectors itself —
|
|
1139
|
+
// dev, self-host), which journal only in the scope.
|
|
1140
|
+
const since = new Date(now.getTime() - CONNECTOR_DEAD_LETTER_WINDOW_DAYS * 86_400_000).toISOString();
|
|
1141
|
+
const providers = q.provider ? [q.provider] : [...new Set(rows.map((r) => r.provider))].sort();
|
|
1142
|
+
const deadLetters = await Promise.all(providers.map(async (provider) => {
|
|
1143
|
+
const found = await admin.listOpsFailures(c.get('actor'), {
|
|
1144
|
+
operation: `intent.${connectorDispatchKind(provider)}`,
|
|
1145
|
+
...(q.tenantId ? { tenantId: q.tenantId } : {}),
|
|
1146
|
+
since,
|
|
1147
|
+
limit: CONNECTOR_DEAD_LETTER_CAP + 1,
|
|
1148
|
+
});
|
|
1149
|
+
return {
|
|
1150
|
+
provider,
|
|
1151
|
+
count: Math.min(found.length, CONNECTOR_DEAD_LETTER_CAP),
|
|
1152
|
+
capped: found.length > CONNECTOR_DEAD_LETTER_CAP,
|
|
1153
|
+
};
|
|
1154
|
+
}));
|
|
1155
|
+
const body = {
|
|
1156
|
+
...page,
|
|
1157
|
+
summary,
|
|
1158
|
+
deadLetters,
|
|
1159
|
+
deadLettersSince: since,
|
|
1160
|
+
staleAfterDays: CONNECTION_STALE_DAYS,
|
|
1161
|
+
expiryWarningDays: CONNECTION_EXPIRY_WARNING_DAYS,
|
|
1162
|
+
asOf: now.toISOString(),
|
|
1163
|
+
};
|
|
1164
|
+
return c.json(body);
|
|
1165
|
+
});
|
|
1166
|
+
// -- connector calls, fleet-wide, over time (#1691) ---------------------------
|
|
1167
|
+
//
|
|
1168
|
+
// The trend behind the health line above: calls and errors per provider, bucketed,
|
|
1169
|
+
// from the connector-call dataset the host writes on every settled call (kernel
|
|
1170
|
+
// `connector-calls.ts`). Staff/service only by the same construction as
|
|
1171
|
+
// `/connections/health` — in neither BUILDER_ROUTES nor TENANT_ROUTES, so both
|
|
1172
|
+
// credentials default-deny it. 501 when the reader has no dataset named: an empty
|
|
1173
|
+
// series would draw as "no calls". Up to a week, because "is this provider getting
|
|
1174
|
+
// worse this week" is the question it exists for.
|
|
1175
|
+
app.get('/connections/calls', async (c) => {
|
|
1176
|
+
if (!options.observability?.connectorCallsSeries) {
|
|
1177
|
+
return c.json({ error: 'connector-call analytics are not configured on this control plane' }, 501);
|
|
1178
|
+
}
|
|
1179
|
+
const input = z
|
|
1180
|
+
.object({
|
|
1181
|
+
hours: z.coerce.number().int().min(1).max(168).default(24),
|
|
1182
|
+
// A provider slug — and the character set the AE literal accepts, so a bad one is
|
|
1183
|
+
// this route's 400 rather than the reader's 500.
|
|
1184
|
+
provider: z.string().regex(/^[A-Za-z0-9_\-.]{1,64}$/).optional(),
|
|
1185
|
+
})
|
|
1186
|
+
.parse({ hours: c.req.query('hours'), provider: c.req.query('provider') || undefined });
|
|
1187
|
+
const buckets = await options.observability.connectorCallsSeries(input);
|
|
1188
|
+
return c.json({ hours: input.hours, buckets });
|
|
1189
|
+
});
|
|
1190
|
+
// -- platform-request backlog, fleet-wide (#1690 §2) -----------------------
|
|
1191
|
+
//
|
|
1192
|
+
// "Is the platform's own intent drain keeping up" — composed from the SAME ops-failure
|
|
1193
|
+
// record the dead-letter count above reads, generalized from one provider family to
|
|
1194
|
+
// every known intent kind. Staff/service only by the same construction as
|
|
1195
|
+
// `/connections/health` — absent from BUILDER_ROUTES and TENANT_ROUTES, so both
|
|
1196
|
+
// credentials default-deny it.
|
|
1197
|
+
//
|
|
1198
|
+
// This is a count of terminal FAILURES, never a queue depth (see `platformRequestBacklog`'s
|
|
1199
|
+
// doc): a still-`pending` intent lives in the vertical's own scope DO and nothing here
|
|
1200
|
+
// walks the fleet to find it. A `0` means "nothing has given up lately", not "nothing is
|
|
1201
|
+
// waiting" — the console tile is worded to keep that distinction, not just this comment.
|
|
1202
|
+
app.get('/platform-requests/backlog', async (c) => {
|
|
1203
|
+
const now = new Date();
|
|
1204
|
+
const since = new Date(now.getTime() - PLATFORM_REQUEST_BACKLOG_WINDOW_DAYS * 86_400_000).toISOString();
|
|
1205
|
+
// The fixed kinds the platform itself registers, plus one `connector:<provider>` kind
|
|
1206
|
+
// per provider that has (or had) a live connection anywhere in the fleet — the same
|
|
1207
|
+
// derivation the dead-letter count above uses, since that kind only exists for a
|
|
1208
|
+
// provider some connection actually names.
|
|
1209
|
+
const providers = [...new Set((await admin.listConnections(c.get('actor'), { includeRevoked: true })).map((r) => r.provider))];
|
|
1210
|
+
const kinds = [
|
|
1211
|
+
PROVISION_SIBLING_KIND,
|
|
1212
|
+
ARCHIVE_SCOPE_KIND,
|
|
1213
|
+
PROVISION_TENANT_KIND,
|
|
1214
|
+
SET_ENTITLEMENTS_KIND,
|
|
1215
|
+
MODEL_USAGE_KIND,
|
|
1216
|
+
SWEEP_RUNS_KIND,
|
|
1217
|
+
...providers.map(connectorDispatchKind),
|
|
1218
|
+
];
|
|
1219
|
+
const counts = await Promise.all(kinds.map(async (kind) => {
|
|
1220
|
+
const found = await admin.listOpsFailures(c.get('actor'), {
|
|
1221
|
+
operation: `intent.${kind}`,
|
|
1222
|
+
since,
|
|
1223
|
+
limit: PLATFORM_REQUEST_BACKLOG_CAP + 1,
|
|
1224
|
+
});
|
|
1225
|
+
return {
|
|
1226
|
+
count: Math.min(found.length, PLATFORM_REQUEST_BACKLOG_CAP),
|
|
1227
|
+
capped: found.length > PLATFORM_REQUEST_BACKLOG_CAP,
|
|
1228
|
+
};
|
|
1229
|
+
}));
|
|
1230
|
+
const body = {
|
|
1231
|
+
total: counts.reduce((sum, k) => sum + k.count, 0),
|
|
1232
|
+
capped: counts.some((k) => k.capped),
|
|
1233
|
+
since,
|
|
1234
|
+
windowDays: PLATFORM_REQUEST_BACKLOG_WINDOW_DAYS,
|
|
1235
|
+
};
|
|
1236
|
+
return c.json(body);
|
|
1237
|
+
});
|
|
1034
1238
|
// The same upsert semantics as `/internal/connections/upsert` (§3.5.2) — create under a
|
|
1035
1239
|
// fresh id, or rotate the one live row in place so its grant tuples survive — behind
|
|
1036
1240
|
// platform-actor auth instead of the vertical-harness PLATFORM_SECRET. The tenant is
|
|
@@ -1370,7 +1574,8 @@ export function createControlPlaneApi(options) {
|
|
|
1370
1574
|
* current dispatch — resolved BEFORE any version rebind), restore into the serving
|
|
1371
1575
|
* script (which re-projects the vertical's roles), and only then `setScopeServingRef` +
|
|
1372
1576
|
* advance the version pointer. A crash before the flip leaves the scope serving its old
|
|
1373
|
-
* script intact, and the adopt retries idempotently.
|
|
1577
|
+
* script intact, and the adopt retries idempotently. Previews are refused; already-adopted
|
|
1578
|
+
* installs short-circuit.
|
|
1374
1579
|
* Throws `ControlPlaneError` so callers surface an actionable status, never a bare 500.
|
|
1375
1580
|
*/
|
|
1376
1581
|
const adoptScopeOntoServing = async (c, tenantId, scopeId) => {
|
|
@@ -1379,6 +1584,9 @@ export function createControlPlaneApi(options) {
|
|
|
1379
1584
|
if (!scope) {
|
|
1380
1585
|
throw new ControlPlaneError(404, `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
1381
1586
|
}
|
|
1587
|
+
if (scope.kind === 'preview') {
|
|
1588
|
+
throw new ControlPlaneError(409, 'preview scopes cannot adopt the production serving script');
|
|
1589
|
+
}
|
|
1382
1590
|
if (scope.servingRef)
|
|
1383
1591
|
return { servingRef: scope.servingRef, alreadyAdopted: true };
|
|
1384
1592
|
if (!scope.vertical) {
|
|
@@ -1407,8 +1615,8 @@ export function createControlPlaneApi(options) {
|
|
|
1407
1615
|
* pointer to the promoted version so Update stops offering a crossing already made.
|
|
1408
1616
|
*
|
|
1409
1617
|
* Gated exactly where the host cascade would have run: PRIVATE (owned, unlisted) only,
|
|
1410
|
-
* active non-fork scopes only. Runs only when a serving script exists —
|
|
1411
|
-
* actually happened (dispatch-backed + deploy configured); for an embedded vertical the
|
|
1618
|
+
* active non-fork, non-preview scopes only. Runs only when a serving script exists —
|
|
1619
|
+
* i.e. the serve actually happened (dispatch-backed + deploy configured); for an embedded vertical the
|
|
1412
1620
|
* host cascade already rebound, and `verticalServing` is null, so this is a no-op.
|
|
1413
1621
|
* Idempotent and retry-safe: the host cascade never rebound these scopes, so their data
|
|
1414
1622
|
* is still findable on a retry after a failed serve.
|
|
@@ -1421,7 +1629,7 @@ export function createControlPlaneApi(options) {
|
|
|
1421
1629
|
const v = await verticalOf(actor, slug);
|
|
1422
1630
|
if (!v || v.ownerTenant === null || v.listed)
|
|
1423
1631
|
return; // private only, like the host cascade
|
|
1424
|
-
const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom);
|
|
1632
|
+
const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom && s.kind !== 'preview');
|
|
1425
1633
|
for (const s of owned) {
|
|
1426
1634
|
if (!s.servingRef) {
|
|
1427
1635
|
// Adopt: export from the scope's current (un-rebound) dispatch → serving script,
|
|
@@ -1509,6 +1717,84 @@ export function createControlPlaneApi(options) {
|
|
|
1509
1717
|
await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
|
|
1510
1718
|
return { servingRef: serving.ref, versionId: serving.versionId, tables: restored.tables };
|
|
1511
1719
|
};
|
|
1720
|
+
/**
|
|
1721
|
+
* Carry a scope's data onto the script a version bind is about to route it to (#1710).
|
|
1722
|
+
* The caller binds only after this returns.
|
|
1723
|
+
*
|
|
1724
|
+
* A scope that is not on the serving script routes by its bound version's own
|
|
1725
|
+
* per-version script (`COALESCE(servingRef, deploymentRef)`), and a Durable Object
|
|
1726
|
+
* namespace belongs to its script. So a bind to a version with a DIFFERENT
|
|
1727
|
+
* `deploymentRef` points the scope at an empty namespace. Previews live there by design
|
|
1728
|
+
* (#527), and so does a forked scope that `scope bind` re-points. This moves the data
|
|
1729
|
+
* first, in adopt-serving's order: export from the script the router resolves the scope
|
|
1730
|
+
* to TODAY, then restore into the incoming version's script.
|
|
1731
|
+
*
|
|
1732
|
+
* After validating the incoming version, it reads no scope data and carries nothing
|
|
1733
|
+
* when the route does not move: the incoming
|
|
1734
|
+
* version has no script of its own (co-located), the two refs are the same script, the
|
|
1735
|
+
* scope stays pinned to the serving script (the version pointer never touches its
|
|
1736
|
+
* route), or the scope's data is not in a named script. It also carries nothing on a
|
|
1737
|
+
* host with no per-version resolver, because such a host has no per-version namespaces
|
|
1738
|
+
* to move between. `dropServingRef` is for a caller that clears the pin in the same act:
|
|
1739
|
+
* the route then does move, off the serving script.
|
|
1740
|
+
*
|
|
1741
|
+
* A failure strands nothing. The pointer has not moved and the source copy is untouched,
|
|
1742
|
+
* and the restore is one transaction in the target DO (drop-then-replay). So a failed
|
|
1743
|
+
* carry leaves the scope serving its data from the old script, and a retry carries again
|
|
1744
|
+
* over any partial copy. A retry after a bind that landed finds the same script on both
|
|
1745
|
+
* ends and carries nothing.
|
|
1746
|
+
*
|
|
1747
|
+
* What it does not do:
|
|
1748
|
+
* - Carry writes that land on the source between the export and the bind. Those are lost.
|
|
1749
|
+
* - Delete the source copy. That copy is unreachable once the caller binds, and it stays
|
|
1750
|
+
* in the old script, because nothing reaps it yet (#1722). Deleting it here would race
|
|
1751
|
+
* a concurrent push that is still exporting from it.
|
|
1752
|
+
* - Cross lineages. A version of another vertical reads as absent here, and moving a scope
|
|
1753
|
+
* between lineages is `rebindScopeOntoVertical`'s job, behind its migration gate.
|
|
1754
|
+
*/
|
|
1755
|
+
const carryOntoVersion = async (c, scope, versionId, opts = {}) => {
|
|
1756
|
+
const resolveVersion = options.resolveVerticalVersion;
|
|
1757
|
+
if (!scope.vertical)
|
|
1758
|
+
return null;
|
|
1759
|
+
const actor = c.get('actor');
|
|
1760
|
+
const incoming = await admin.getVersion(actor, versionId, scope.vertical);
|
|
1761
|
+
// The bind refuses a version that is not admitted, except onto a preview. Check that
|
|
1762
|
+
// BEFORE the copy. Otherwise unreviewed code would receive the scope's data through its
|
|
1763
|
+
// own restore handler, only for the bind to refuse afterwards.
|
|
1764
|
+
if (!incoming) {
|
|
1765
|
+
throw new ControlPlaneError(404, `unknown version ${versionId} for vertical '${scope.vertical}'`);
|
|
1766
|
+
}
|
|
1767
|
+
if (incoming.admission !== 'admitted' && scope.kind !== 'preview') {
|
|
1768
|
+
throw new ControlPlaneError(409, `version ${versionId} is not admitted`);
|
|
1769
|
+
}
|
|
1770
|
+
if (!resolveVersion || (scope.servingRef && !opts.dropServingRef))
|
|
1771
|
+
return null;
|
|
1772
|
+
const to = incoming.deploymentRef ?? null;
|
|
1773
|
+
const bound = scope.verticalVersionId
|
|
1774
|
+
? await admin.getVersion(actor, scope.verticalVersionId, scope.vertical)
|
|
1775
|
+
: undefined;
|
|
1776
|
+
const from = scope.servingRef ?? bound?.deploymentRef ?? null;
|
|
1777
|
+
if (!to || !from || from === to)
|
|
1778
|
+
return null;
|
|
1779
|
+
// Resolve each end at exactly the script named above. No fallback to the prod channel:
|
|
1780
|
+
// a guessed source would be exported, found empty, and carried as if it were the data.
|
|
1781
|
+
const source = scope.servingRef
|
|
1782
|
+
? await options.resolveVerticalRef?.(scope.servingRef)
|
|
1783
|
+
: await resolveVersion(scope.vertical, scope.verticalVersionId, actor);
|
|
1784
|
+
const dest = await resolveVersion(scope.vertical, versionId, actor);
|
|
1785
|
+
if (!source || !dest) {
|
|
1786
|
+
// Binding anyway would point the scope at an empty store and leave its data behind,
|
|
1787
|
+
// so refuse and name the end that did not resolve. 502, like any unreachable
|
|
1788
|
+
// dependency here (`vertical-client.ts` `reach`, `preview-auth.ts`): this is a wiring or
|
|
1789
|
+
// transient failure, not a deployment that is too old to do it (which is what 501 means).
|
|
1790
|
+
const missing = !source ? `the script holding its data ('${from}')` : `version ${versionId}'s script ('${to}')`;
|
|
1791
|
+
throw new ControlPlaneError(502, `cannot move scope ${scope.id} from '${from}' to '${to}': ${missing} could not be reached, ` +
|
|
1792
|
+
`so the version was not bound (#1710)`);
|
|
1793
|
+
}
|
|
1794
|
+
const dump = await retryTransient(() => source.exportScope(scope.id));
|
|
1795
|
+
const restored = await retryTransient(() => dest.restoreScope(scope.tenantId, scope.id, dump));
|
|
1796
|
+
return { from, to, tables: restored.tables };
|
|
1797
|
+
};
|
|
1512
1798
|
app.get('/tenants/:tenantId/scopes/:scopeId/tables', async (c) => {
|
|
1513
1799
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1514
1800
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
@@ -2924,7 +3210,7 @@ export function createControlPlaneApi(options) {
|
|
|
2924
3210
|
if (v.ownerTenant === null) {
|
|
2925
3211
|
return c.json({ error: 'adopt-serving is a private-vertical operation' }, 409);
|
|
2926
3212
|
}
|
|
2927
|
-
const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug })).filter((s) => !s.forkedFrom && s.status === 'active');
|
|
3213
|
+
const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug })).filter((s) => !s.forkedFrom && s.kind !== 'preview' && s.status === 'active');
|
|
2928
3214
|
const adopted = [];
|
|
2929
3215
|
const alreadyAdopted = [];
|
|
2930
3216
|
try {
|
|
@@ -2970,13 +3256,26 @@ export function createControlPlaneApi(options) {
|
|
|
2970
3256
|
// opts into fork-before-promote (§4): on a migration-digest-crossing bind the
|
|
2971
3257
|
// pre-migration data is snapshotted first — orchestrated through the vertical
|
|
2972
3258
|
// when one resolves, in-process otherwise.
|
|
3259
|
+
//
|
|
3260
|
+
// A scope that routes by its bound version's own script (a fork, such as a forked test
|
|
3261
|
+
// environment `substrat scope bind` re-points on every merge) has its data carried into
|
|
3262
|
+
// the new version's script before the pointer moves (#1710). The snapshot is taken
|
|
3263
|
+
// after a successful carry, before binding, in the script the data is leaving. A scope
|
|
3264
|
+
// on the serving script carries nothing, because its route does not follow the version pointer.
|
|
2973
3265
|
app.post('/tenants/:tenantId/scopes/:scopeId/version', async (c) => {
|
|
2974
3266
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
2975
3267
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
2976
3268
|
const { versionId, snapshot } = bindScopeVersionBody.parse(await c.req.json());
|
|
2977
3269
|
const actor = c.get('actor');
|
|
3270
|
+
const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
|
|
3271
|
+
// A refusal throws a ControlPlaneError, which the app's error boundary relays with its
|
|
3272
|
+
// status and records as an ops failure. An unknown scope carries nothing, and the bind
|
|
3273
|
+
// below refuses it as it always has.
|
|
3274
|
+
const carry = async () => {
|
|
3275
|
+
if (scope)
|
|
3276
|
+
await carryOntoVersion(c, scope, versionId);
|
|
3277
|
+
};
|
|
2978
3278
|
if (snapshot) {
|
|
2979
|
-
const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
|
|
2980
3279
|
if (!scope) {
|
|
2981
3280
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
2982
3281
|
}
|
|
@@ -2984,22 +3283,26 @@ export function createControlPlaneApi(options) {
|
|
|
2984
3283
|
if (vertical) {
|
|
2985
3284
|
// Delegated path: the digest compare lives here (the in-process path does
|
|
2986
3285
|
// it below the seam). Snapshot only a migration-crossing bind.
|
|
3286
|
+
let migrationCrossing = false;
|
|
2987
3287
|
if (scope.vertical && scope.verticalVersionId) {
|
|
2988
3288
|
const [current, incoming] = await Promise.all([
|
|
2989
3289
|
admin.getVersion(actor, scope.verticalVersionId, scope.vertical),
|
|
2990
3290
|
admin.getVersion(actor, versionId, scope.vertical),
|
|
2991
3291
|
]);
|
|
2992
|
-
|
|
2993
|
-
await orchestratedSnapshot(c, tenantId, scope, {});
|
|
2994
|
-
}
|
|
3292
|
+
migrationCrossing = Boolean(current && incoming && current.migrationDigest !== incoming.migrationDigest);
|
|
2995
3293
|
}
|
|
3294
|
+
await carry();
|
|
3295
|
+
if (migrationCrossing)
|
|
3296
|
+
await orchestratedSnapshot(c, tenantId, scope, {});
|
|
2996
3297
|
await admin.bindScopeVersion(actor, tenantId, scopeId, versionId);
|
|
2997
3298
|
}
|
|
2998
3299
|
else {
|
|
3300
|
+
await carry();
|
|
2999
3301
|
await admin.bindScopeVersion(actor, tenantId, scopeId, versionId, { snapshot: true });
|
|
3000
3302
|
}
|
|
3001
3303
|
return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
|
|
3002
3304
|
}
|
|
3305
|
+
await carry();
|
|
3003
3306
|
await admin.bindScopeVersion(actor, tenantId, scopeId, versionId);
|
|
3004
3307
|
return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
|
|
3005
3308
|
});
|
|
@@ -3427,6 +3730,12 @@ export function createControlPlaneApi(options) {
|
|
|
3427
3730
|
// happen — while this one wants the declaration as a node.
|
|
3428
3731
|
schedules: parsed?.schedules ?? [],
|
|
3429
3732
|
outbound: parsed?.outbound ?? [],
|
|
3733
|
+
// The peer verticals this version declares it calls (#1706) — `outbound`'s sibling
|
|
3734
|
+
// one band over: the same kind of declared reach, at another of the tenant's apps
|
|
3735
|
+
// rather than at a third-party host. `null` is a FACT, not missing data: a version
|
|
3736
|
+
// pushed before the declaration existed is unenforced, which a reader must be able
|
|
3737
|
+
// to tell from a version that declares it calls nothing.
|
|
3738
|
+
calls: parsed?.calls ?? null,
|
|
3430
3739
|
});
|
|
3431
3740
|
});
|
|
3432
3741
|
app.get('/verticals/:slug/versions/:id/model', async (c) => {
|
|
@@ -4258,13 +4567,24 @@ export function createControlPlaneApi(options) {
|
|
|
4258
4567
|
scopeId: z.string().min(1).max(64).optional(),
|
|
4259
4568
|
vertical: z.string().min(1).max(200).optional(),
|
|
4260
4569
|
hours: z.coerce.number().int().min(1).max(72).default(24),
|
|
4570
|
+
since: z.string().datetime({ offset: true }).optional(),
|
|
4571
|
+
until: z.string().datetime({ offset: true }).optional(),
|
|
4261
4572
|
})
|
|
4262
4573
|
.parse({
|
|
4263
4574
|
tenantId,
|
|
4264
4575
|
scopeId: c.req.query('scopeId') || undefined,
|
|
4265
4576
|
vertical: c.req.query('vertical') || undefined,
|
|
4266
|
-
hours: c.req.query('hours'),
|
|
4577
|
+
hours: c.req.query('hours'), since: c.req.query('since'), until: c.req.query('until'),
|
|
4267
4578
|
});
|
|
4579
|
+
try {
|
|
4580
|
+
resolveObservabilityWindow(input);
|
|
4581
|
+
}
|
|
4582
|
+
catch (e) {
|
|
4583
|
+
throw new ControlPlaneError(400, e.message);
|
|
4584
|
+
}
|
|
4585
|
+
if (input.since !== undefined && !options.observability.absoluteTenantWindows) {
|
|
4586
|
+
throw new ControlPlaneError(501, 'absolute tenant telemetry windows are not supported by this backend');
|
|
4587
|
+
}
|
|
4268
4588
|
return c.json(await options.observability.tenantMetrics(input));
|
|
4269
4589
|
});
|
|
4270
4590
|
// The bucketed twin (#1447): the series a team-level chart plots, one line per app.
|
|
@@ -4288,8 +4608,19 @@ export function createControlPlaneApi(options) {
|
|
|
4288
4608
|
// zero rows rather than somebody else's, so no ownership check is needed here.
|
|
4289
4609
|
scopeIds: z.array(z.string().min(1).max(64)).min(1).max(TENANT_SERIES_SCOPE_CAP),
|
|
4290
4610
|
hours: z.coerce.number().int().min(1).max(72).default(24),
|
|
4611
|
+
since: z.string().datetime({ offset: true }).optional(),
|
|
4612
|
+
until: z.string().datetime({ offset: true }).optional(),
|
|
4291
4613
|
})
|
|
4292
|
-
.parse({ tenantId, scopeIds, hours: c.req.query('hours') });
|
|
4614
|
+
.parse({ tenantId, scopeIds, hours: c.req.query('hours'), since: c.req.query('since'), until: c.req.query('until') });
|
|
4615
|
+
try {
|
|
4616
|
+
resolveObservabilityWindow(input);
|
|
4617
|
+
}
|
|
4618
|
+
catch (e) {
|
|
4619
|
+
throw new ControlPlaneError(400, e.message);
|
|
4620
|
+
}
|
|
4621
|
+
if (input.since !== undefined && !options.observability.absoluteTenantWindows) {
|
|
4622
|
+
throw new ControlPlaneError(501, 'absolute tenant telemetry windows are not supported by this backend');
|
|
4623
|
+
}
|
|
4293
4624
|
return c.json(await options.observability.tenantMetricsSeries(input));
|
|
4294
4625
|
});
|
|
4295
4626
|
app.get('/observability/tenant-logs', async (c) => {
|
|
@@ -4696,11 +5027,21 @@ export function createControlPlaneApi(options) {
|
|
|
4696
5027
|
* between the two converges on retry. Shared by that route and by the create path, which
|
|
4697
5028
|
* reaps a HALF-BUILT leftover before re-forking (see `orchestratedPreview`). */
|
|
4698
5029
|
const reapPreview = async (c, preview) => {
|
|
5030
|
+
// A fork's own sign-in clients go FIRST (#1704): a failure here leaves the preview in
|
|
5031
|
+
// place, so the retry a PR-close job makes still finds it — after the row is gone, nothing
|
|
5032
|
+
// would name those clients again. A clean room never had one.
|
|
5033
|
+
await retireClientsOfReapedScope(previewAuthDeps(c), preview);
|
|
4699
5034
|
const vertical = await verticalForScope(c, preview);
|
|
4700
5035
|
if (vertical)
|
|
4701
5036
|
await vertical.deleteScope({ scopeId: preview.id });
|
|
4702
5037
|
await options.host.deleteSnapshot(c.get('actor'), preview.tenantId, preview.id);
|
|
4703
5038
|
};
|
|
5039
|
+
/** The preview-login seam (#1704, `preview-auth.ts`): the directory, and each issuer's deployment. */
|
|
5040
|
+
const previewAuthDeps = (c) => ({
|
|
5041
|
+
admin,
|
|
5042
|
+
actor: c.get('actor'),
|
|
5043
|
+
issuerClient: (scope) => verticalForScope(c, scope),
|
|
5044
|
+
});
|
|
4704
5045
|
/** Given a base hostname `<label>.<domain>`, mint (or find) the preview's `--<tag>`
|
|
4705
5046
|
* hostname `<label>--<tag>.<domain>` bound to `previewId`. Non-canonical, so it never
|
|
4706
5047
|
* demotes the prod surface. Shared by the fork and clean-room paths. */
|
|
@@ -4801,18 +5142,65 @@ export function createControlPlaneApi(options) {
|
|
|
4801
5142
|
const target = (await options.resolveVerticalVersion?.(slug, opts.versionId, actor)) ??
|
|
4802
5143
|
options.verticals?.[slug] ??
|
|
4803
5144
|
(await options.resolveVertical?.(slug, actor));
|
|
5145
|
+
// The preview's own login (#1704), once it serves this version. Delivered into `target` —
|
|
5146
|
+
// THIS push's version deployment — and never "whatever the scope is bound to now", so an
|
|
5147
|
+
// older push can never overwrite a newer push's config store. A failure lands an
|
|
5148
|
+
// ops-failure row like a failed restore (#559): the preview exists and serves the PR, but
|
|
5149
|
+
// has no working login. The message names no secret; none ever reaches it.
|
|
5150
|
+
const wireLogin = async (previewId, hostname, parent) => {
|
|
5151
|
+
try {
|
|
5152
|
+
return await wirePreviewAuth(previewAuthDeps(c), {
|
|
5153
|
+
tenantId,
|
|
5154
|
+
versionId: opts.versionId,
|
|
5155
|
+
previewId,
|
|
5156
|
+
tag: opts.tag,
|
|
5157
|
+
parent,
|
|
5158
|
+
previewHostname: hostname,
|
|
5159
|
+
appName: parent?.name ?? slug.split('/').at(-1),
|
|
5160
|
+
deliver: async (entries) => {
|
|
5161
|
+
if (!target)
|
|
5162
|
+
throw new ControlPlaneError(501, 'no deployment resolves for the PR version to deliver to');
|
|
5163
|
+
await retryTransient(() => target.configureInstance({ tenantId, scopeId: previewId, entries }));
|
|
5164
|
+
},
|
|
5165
|
+
});
|
|
5166
|
+
}
|
|
5167
|
+
catch (e) {
|
|
5168
|
+
if (e instanceof ControlPlaneError) {
|
|
5169
|
+
recordFailure({
|
|
5170
|
+
actor,
|
|
5171
|
+
operation: 'preview.create',
|
|
5172
|
+
stage: 'login',
|
|
5173
|
+
tenantId,
|
|
5174
|
+
scopeId: previewId,
|
|
5175
|
+
vertical: slug,
|
|
5176
|
+
version: opts.versionId,
|
|
5177
|
+
status: e.status,
|
|
5178
|
+
message: e.message,
|
|
5179
|
+
}, e);
|
|
5180
|
+
}
|
|
5181
|
+
throw e;
|
|
5182
|
+
}
|
|
5183
|
+
};
|
|
5184
|
+
const notesFor = (parent) => parent
|
|
5185
|
+
? [
|
|
5186
|
+
"Settings: a preview does not carry over the app's per-install settings (the dashboard's Env tab); " +
|
|
5187
|
+
"it runs on the deployment's defaults.",
|
|
5188
|
+
]
|
|
5189
|
+
: [];
|
|
4804
5190
|
// The `--<tag>` URL's base — the source's canonical hostname (fork) or the tenant-app
|
|
4805
5191
|
// convention (clean-room). Computed once so reuse and fresh mint the same URL.
|
|
4806
5192
|
const baseHostname = await previewBaseHostname(actor, source, tenantId, slug, surface);
|
|
4807
5193
|
// Idempotent on (tenant, vertical, tag): a PR *synchronize* rebinds the new version
|
|
4808
|
-
// onto the SAME preview
|
|
4809
|
-
//
|
|
5194
|
+
// onto the SAME preview, unless `refresh` asks for a fresh one. On a hosted vertical
|
|
5195
|
+
// every push is its own script, so the rebind first CARRIES the preview's data into
|
|
5196
|
+
// the new version's script (#1710). The preview's data then follows it from push to
|
|
5197
|
+
// push, and each push's migrations run forward over it (§4's rehearsal case).
|
|
4810
5198
|
const existing = (await admin.listScopes(actor, { tenantId, vertical: slug })).find((s) => s.kind === 'preview' && s.slug === previewSlug(slug, opts.tag));
|
|
4811
5199
|
// A preview only HAS data once its two-phase create finished: the directory row lands
|
|
4812
5200
|
// first as `provisioning` (K-31), the fork's export→restore runs, and `activateScope`
|
|
4813
5201
|
// is the last step. So a row still at `provisioning` is a create that DIED mid-fork —
|
|
4814
|
-
// its DO is empty. Reuse must never adopt one: reuse
|
|
4815
|
-
//
|
|
5202
|
+
// its DO is empty. Reuse must never adopt one: reuse carries whatever the preview holds
|
|
5203
|
+
// and never re-forks from the source, so adopting a half-built row hands back a
|
|
4816
5204
|
// permanently EMPTY preview and reports `reused: true` — success for a URL that shows
|
|
4817
5205
|
// a reviewer no data at all. That is exactly what a CI retry does (the generated
|
|
4818
5206
|
// workflow retries `preview create` on a transient), so the failure mode is the
|
|
@@ -4822,6 +5210,31 @@ export function createControlPlaneApi(options) {
|
|
|
4822
5210
|
// scope would otherwise collide with the old row's still-bound `--<tag>` hostname.
|
|
4823
5211
|
const stale = existing !== undefined && (opts.refresh || existing.status !== 'active');
|
|
4824
5212
|
if (existing && !stale) {
|
|
5213
|
+
// Data first, pointer last (#1710): move the preview's data into this push's script
|
|
5214
|
+
// BEFORE anything re-points it. A failed carry therefore leaves the preview bound to
|
|
5215
|
+
// its old script with its data, and records why, like a failed fork restore (#559).
|
|
5216
|
+
// `dropServingRef`: a preview provisioned before #527 inherited a serving_ref that is
|
|
5217
|
+
// cleared just below, so its route leaves the serving script too.
|
|
5218
|
+
let carried;
|
|
5219
|
+
try {
|
|
5220
|
+
carried = await carryOntoVersion(c, existing, opts.versionId, { dropServingRef: true });
|
|
5221
|
+
}
|
|
5222
|
+
catch (e) {
|
|
5223
|
+
if (e instanceof ControlPlaneError && e.status >= 500 && e.status !== 501) {
|
|
5224
|
+
recordFailure({
|
|
5225
|
+
actor,
|
|
5226
|
+
operation: 'preview.create',
|
|
5227
|
+
stage: 'carry',
|
|
5228
|
+
tenantId,
|
|
5229
|
+
scopeId: existing.id,
|
|
5230
|
+
vertical: slug,
|
|
5231
|
+
version: opts.versionId,
|
|
5232
|
+
status: e.status,
|
|
5233
|
+
message: e.message,
|
|
5234
|
+
}, e);
|
|
5235
|
+
}
|
|
5236
|
+
throw e;
|
|
5237
|
+
}
|
|
4825
5238
|
// Heal a preview provisioned before #527: clear any inherited serving_ref so routing
|
|
4826
5239
|
// follows the bound version (its per-version script), not the prod serving script.
|
|
4827
5240
|
if (existing.servingRef)
|
|
@@ -4831,7 +5244,34 @@ export function createControlPlaneApi(options) {
|
|
|
4831
5244
|
await admin.setScopeExpiresAt(actor, tenantId, existing.id, expiresAt);
|
|
4832
5245
|
const hostname = await bindPreviewHostname(actor, baseHostname, tenantId, existing.id, opts.tag, surface);
|
|
4833
5246
|
await assertServesBoundVersion(existing.id);
|
|
4834
|
-
|
|
5247
|
+
// The fork it was made from, not whichever prod scope this call picked: a reuse is the
|
|
5248
|
+
// same preview. Every push is a new version with an EMPTY config store, and the carry
|
|
5249
|
+
// above moves the scope's data but not the vertical's config store, so the login is
|
|
5250
|
+
// wired again on every reuse, never assumed to have survived.
|
|
5251
|
+
const parent = existing.forkedFrom
|
|
5252
|
+
? ((await admin.getScopeRecord(actor, tenantId, existing.forkedFrom)) ?? null)
|
|
5253
|
+
: null;
|
|
5254
|
+
const auth = await wireLogin(existing.id, hostname, parent);
|
|
5255
|
+
return {
|
|
5256
|
+
scopeId: existing.id,
|
|
5257
|
+
hostname,
|
|
5258
|
+
url: `https://${hostname}`,
|
|
5259
|
+
versionId: opts.versionId,
|
|
5260
|
+
reused: true,
|
|
5261
|
+
auth,
|
|
5262
|
+
// The carry's loss window, said where the person pushing reads it (the CLI prints
|
|
5263
|
+
// every note): a reviewer who clicked around during the push would otherwise just
|
|
5264
|
+
// find their change gone.
|
|
5265
|
+
notes: [
|
|
5266
|
+
...(carried
|
|
5267
|
+
? [
|
|
5268
|
+
`Data: the preview's data was copied into this push's version (${carried.tables} tables). ` +
|
|
5269
|
+
'Anything written to the preview while this push ran may be lost.',
|
|
5270
|
+
]
|
|
5271
|
+
: []),
|
|
5272
|
+
...notesFor(parent),
|
|
5273
|
+
],
|
|
5274
|
+
};
|
|
4835
5275
|
}
|
|
4836
5276
|
// Free the tag: the slug is unique per tenant and the `--<tag>` hostname is still bound
|
|
4837
5277
|
// to the old row, so the fresh fork below cannot be provisioned until this one is gone.
|
|
@@ -4921,7 +5361,16 @@ export function createControlPlaneApi(options) {
|
|
|
4921
5361
|
await admin.bindScopeVersion(actor, tenantId, previewId, opts.versionId);
|
|
4922
5362
|
const hostname = await bindPreviewHostname(actor, baseHostname, tenantId, previewId, opts.tag, surface);
|
|
4923
5363
|
await assertServesBoundVersion(previewId);
|
|
4924
|
-
|
|
5364
|
+
const auth = await wireLogin(previewId, hostname, source);
|
|
5365
|
+
return {
|
|
5366
|
+
scopeId: previewId,
|
|
5367
|
+
hostname,
|
|
5368
|
+
url: `https://${hostname}`,
|
|
5369
|
+
versionId: opts.versionId,
|
|
5370
|
+
reused: false,
|
|
5371
|
+
auth,
|
|
5372
|
+
notes: notesFor(source),
|
|
5373
|
+
};
|
|
4925
5374
|
};
|
|
4926
5375
|
// The owning tenant of a builder's OWN vertical — the gate every preview route shares. A
|
|
4927
5376
|
// preview forks THIS tenant's own scope and binds the version onto that fork (never an
|
|
@@ -5003,6 +5452,8 @@ export function createControlPlaneApi(options) {
|
|
|
5003
5452
|
expiresAt: s.expiresAt,
|
|
5004
5453
|
hostname: hosts[0]?.hostname ?? null,
|
|
5005
5454
|
url: hosts[0]?.hostname ? `https://${hosts[0].hostname}` : null,
|
|
5455
|
+
// What an EXTERNAL issuer would need registered for this preview to sign in (#1704).
|
|
5456
|
+
callbackUrl: hosts[0]?.hostname ? oidcCallbackUrl(hosts[0].hostname) : null,
|
|
5006
5457
|
});
|
|
5007
5458
|
}
|
|
5008
5459
|
return c.json(out);
|
|
@@ -5130,6 +5581,70 @@ export function createControlPlaneApi(options) {
|
|
|
5130
5581
|
};
|
|
5131
5582
|
app.delete('/tenants/:tenantId/scopes/:scopeId/system-grants', switchScheduleRoute('off'));
|
|
5132
5583
|
app.post('/tenants/:tenantId/scopes/:scopeId/system-grants', switchScheduleRoute('on'));
|
|
5584
|
+
// The status read (#1674): "is this module switched off on this scope?", without
|
|
5585
|
+
// reaching for the scope's own SQL console (the CHECKPOINT runbook's old CONFIRM step —
|
|
5586
|
+
// now this route). Same staff-only gate as the switch itself: a tenant credential passes
|
|
5587
|
+
// the `/tenants/<own>` confinement, so it is refused here too, by the same
|
|
5588
|
+
// `confinedTenant` check, not a kind test.
|
|
5589
|
+
app.get('/tenants/:tenantId/scopes/:scopeId/system-grants', async (c) => {
|
|
5590
|
+
if (confinedTenant(c.get('principal')) !== null) {
|
|
5591
|
+
return c.json({ error: 'forbidden: the schedule switch is staff-only' }, 403);
|
|
5592
|
+
}
|
|
5593
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
5594
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
5595
|
+
const actor = c.get('actor');
|
|
5596
|
+
// K-3 first, so a scope of another tenant reads as absent before anything is reached.
|
|
5597
|
+
if (!(await admin.getScopeRecord(actor, tenantId, scopeId))) {
|
|
5598
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
5599
|
+
}
|
|
5600
|
+
return c.json(await admin.systemGrantsStatus(actor, { tenantId, scopeId }));
|
|
5601
|
+
});
|
|
5602
|
+
// -- the peer kill switch (#1706) ------------------------------------------
|
|
5603
|
+
//
|
|
5604
|
+
// One calling vertical's access to ONE scope, off and back on — `revokeFromPeer` and
|
|
5605
|
+
// `restoreToPeer`. The schedule switch above with the subject swapped: `reason` required,
|
|
5606
|
+
// both halves audited intent-then-outcome, idempotent, and no re-provision re-seats what
|
|
5607
|
+
// OFF took, so the POST is the only way back. For a HOSTED scope the grants live in the
|
|
5608
|
+
// vertical's deployment and `peerSwitchDelegation` moves the switch there; a deployment
|
|
5609
|
+
// built before the far end existed answers 501 "redeploy" and nothing is switched.
|
|
5610
|
+
//
|
|
5611
|
+
// **Unlike the schedule switch, a tenant may pull this one**, and that is the point of it:
|
|
5612
|
+
// "may this other app of mine still call into here" is the tenant's question about its own
|
|
5613
|
+
// two apps, not a platform incident lever. A confined credential (#977) is held by the
|
|
5614
|
+
// dashboard on a tenant's behalf and may act on THAT tenant only; no vertical holds one at
|
|
5615
|
+
// all, since a vertical is CP-less, so the switched party cannot switch itself back on.
|
|
5616
|
+
const switchPeerRoute = (to) => async (c) => {
|
|
5617
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
5618
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
5619
|
+
const pin = confinedTenant(c.get('principal'));
|
|
5620
|
+
if (pin !== null && pin !== tenantId)
|
|
5621
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
5622
|
+
const body = peerSwitchBody.parse(await c.req.json());
|
|
5623
|
+
const actor = c.get('actor');
|
|
5624
|
+
// K-3 first, so a scope of another tenant reads as absent before anything is reached.
|
|
5625
|
+
if (!(await admin.getScopeRecord(actor, tenantId, scopeId))) {
|
|
5626
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
5627
|
+
}
|
|
5628
|
+
const input = { vertical: body.vertical, node: { tenantId, scopeId }, reason: body.reason };
|
|
5629
|
+
return c.json(to === 'off' ? await admin.revokeFromPeer(actor, input) : await admin.restoreToPeer(actor, input));
|
|
5630
|
+
};
|
|
5631
|
+
app.delete('/tenants/:tenantId/scopes/:scopeId/peer-grants', switchPeerRoute('off'));
|
|
5632
|
+
app.post('/tenants/:tenantId/scopes/:scopeId/peer-grants', switchPeerRoute('on'));
|
|
5633
|
+
// The status read (#1706): which of the tenant's other apps may call into this scope, and
|
|
5634
|
+
// where each stands — the read the dashboard's install disclosure and the console's scope
|
|
5635
|
+
// view are both built on. Same confinement as the switch, for the same reason.
|
|
5636
|
+
app.get('/tenants/:tenantId/scopes/:scopeId/peer-grants', async (c) => {
|
|
5637
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
5638
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
5639
|
+
const pin = confinedTenant(c.get('principal'));
|
|
5640
|
+
if (pin !== null && pin !== tenantId)
|
|
5641
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
5642
|
+
const actor = c.get('actor');
|
|
5643
|
+
if (!(await admin.getScopeRecord(actor, tenantId, scopeId))) {
|
|
5644
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
5645
|
+
}
|
|
5646
|
+
return c.json(await admin.peerGrantsStatus(actor, { tenantId, scopeId }));
|
|
5647
|
+
});
|
|
5133
5648
|
// Orgs: the portal-customer grouping (§4.1). Creating one mints no permission —
|
|
5134
5649
|
// members reach what the org was GRANTED, and granting stays off this surface.
|
|
5135
5650
|
app.post('/tenants/:tenantId/orgs', async (c) => {
|