@substrat-run/control-plane-api 0.118.0 → 0.119.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.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { Hono } from 'hono';
2
- import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, connectionFilter, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, 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';
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
3
  import { attributeFailure } from './failure-attribution.js';
4
4
  import { migrationProgress, ulid } from '@substrat-run/kernel';
5
5
  import { TENANT_HEADER, confinedTenant } from './auth.js';
6
6
  import { mintTenantToken } from './tenant-token.js';
7
7
  import { connectionGrantsForScope } from './vertical-client.js';
8
+ import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
8
9
  import { versionReachedAt } from './scope-deployment.js';
9
10
  import { reconcileConnectionGrants } from './connection-grants.js';
10
11
  import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
@@ -298,6 +299,32 @@ const opsFailuresQuery = z.object({
298
299
  // Bounded by default exactly as /admin-log, and for the same reason.
299
300
  ...listPageQuery.shape,
300
301
  });
302
+ // The fleet connection-health read (#1690). `status` is the DERIVED health state, not the
303
+ // stored `Connection.status`. Paged like every HTTP list read; the cursor is a connection id.
304
+ const connectionHealthQuery = z.object({
305
+ status: connectionHealthState.optional(),
306
+ provider: connectionProvider.optional(),
307
+ tenantId: tenantIdSchema.optional(),
308
+ // Free text, matched case-insensitively against the row's own fields BEFORE paging, so
309
+ // a search finds a row on any page rather than only the ones a console has loaded.
310
+ q: z.string().trim().min(1).max(200).optional(),
311
+ limit: listPageQuery.shape.limit,
312
+ cursor: listPageQuery.shape.cursor,
313
+ // The walk is ascending by connection id, only. A `desc` is refused rather than
314
+ // silently ignored: a caller would otherwise read ascending pages as descending ones.
315
+ order: z.literal('asc').optional(),
316
+ });
317
+ /** The dead-letter window (#1690) — a week, the same horizon as the stale window. */
318
+ const CONNECTOR_DEAD_LETTER_WINDOW_DAYS = 7;
319
+ /** The per-provider read bound: past it the count is a floor, and says so (`capped`). */
320
+ export const CONNECTOR_DEAD_LETTER_CAP = 200;
321
+ /**
322
+ * `/platform-requests/backlog` (#1690 §2) rides the SAME window and per-kind bound as the
323
+ * connector dead-letter count above — one incident horizon, one honestly-capped read, for
324
+ * every fleet-wide "how much has given up lately" number this API answers.
325
+ */
326
+ const PLATFORM_REQUEST_BACKLOG_WINDOW_DAYS = CONNECTOR_DEAD_LETTER_WINDOW_DAYS;
327
+ const PLATFORM_REQUEST_BACKLOG_CAP = CONNECTOR_DEAD_LETTER_CAP;
301
328
  // The issues read (#1233). No cursor by design: grouping IS the compression —
302
329
  // cardinality is the number of distinct failure shapes — and `limit` bounds it.
303
330
  const issuesQuery = z.object({
@@ -343,6 +370,8 @@ const tenantRoleAssignmentBody = z
343
370
  * spells out; `.strict()` so a body that tried to name one is refused, not stripped.
344
371
  */
345
372
  const systemSwitchBody = systemSwitch.pick({ moduleId: true, reason: true }).strict();
373
+ /** The peer kill switch's body (#1706) — the tenant and scope come from the path. */
374
+ const peerSwitchBody = peerSwitch.pick({ vertical: true, reason: true }).strict();
346
375
  /** A new org under an addressed tenant (#1343) — `tenantId` comes from the path. */
347
376
  const tenantOrgBody = createOrgInput.omit({ tenantId: true });
348
377
  const sweepRunsQuery = z.object({
@@ -1031,6 +1060,180 @@ export function createControlPlaneApi(options) {
1031
1060
  });
1032
1061
  return c.json(await admin.listConnections(c.get('actor'), filter));
1033
1062
  });
1063
+ // -- connection health, fleet-wide (#1690) -----------------------------------
1064
+ //
1065
+ // The staff console's Health → Connections read: every tenant's connections, each with
1066
+ // its derived health (`deriveConnectionHealth`, contracts) and refresh-expiry warning,
1067
+ // plus connector dead letters counted per provider. Read-only, and staff/service only
1068
+ // by construction: it is neither in BUILDER_ROUTES nor in the tenant credential's
1069
+ // TENANT_ROUTES, so both default-deny it — and it lives outside `/tenants/:id` so no
1070
+ // future tenant-path pin can reach it by pattern.
1071
+ //
1072
+ // Secrets: the read goes through the AUDITED `listConnections` (the access log records
1073
+ // the filter and a count, never rows — §3.4's rule for the log), whose `Connection`
1074
+ // cannot carry a credential; and each row is then projected through
1075
+ // `toConnectionHealthEntry`'s allow-list rather than spread, so a widened row upstream
1076
+ // does not ride out to a browser.
1077
+ //
1078
+ // Cost: `listConnections` has no page of its own, so this loads the (filtered) fleet's
1079
+ // connection rows on every request and pages in memory. Unfiltered that is a full scan
1080
+ // of `_substrat_connections`, a table in the low hundreds today. Past ~2,000 live rows
1081
+ // (a directory read and a response-time cost paid on every console load) it wants a
1082
+ // keyset page pushed into the adapter, which the derived-status filter makes
1083
+ // non-trivial, because health is not a column. No cache: a stale health view is the
1084
+ // one thing this page must not show. Filter-then-page is deliberate: paging first would hand back a short or
1085
+ // empty page while a cursor still existed.
1086
+ app.get('/connections/health', async (c) => {
1087
+ const q = connectionHealthQuery.parse({
1088
+ status: c.req.query('status'),
1089
+ provider: c.req.query('provider'),
1090
+ tenantId: c.req.query('tenantId'),
1091
+ q: c.req.query('q'),
1092
+ limit: c.req.query('limit'),
1093
+ cursor: c.req.query('cursor'),
1094
+ order: c.req.query('order'),
1095
+ });
1096
+ const now = new Date();
1097
+ const rows = await admin.listConnections(c.get('actor'), {
1098
+ ...(q.tenantId ? { tenantId: q.tenantId } : {}),
1099
+ ...(q.provider ? { provider: q.provider } : {}),
1100
+ });
1101
+ const all = rows.map((r) => toConnectionHealthEntry(r, now)).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1102
+ const summary = {
1103
+ total: all.length,
1104
+ healthy: 0,
1105
+ erroring: 0,
1106
+ stale: 0,
1107
+ 'never-used': 0,
1108
+ expiring: 0,
1109
+ expired: 0,
1110
+ };
1111
+ for (const e of all) {
1112
+ summary[e.health] += 1;
1113
+ if (e.expiryWarning === 'soon')
1114
+ summary.expiring += 1;
1115
+ if (e.expiryWarning === 'expired')
1116
+ summary.expired += 1;
1117
+ }
1118
+ // #1716: ids are Crockford-base32 ULIDs, drawn from a small alphabet that a short
1119
+ // human needle collides with by chance (`a2` inside a random 16-char tail, ~1.5%
1120
+ // of the time) — worse, two ids minted in the same millisecond share a monotonic
1121
+ // prefix, so one hit there matches every row born that millisecond. The console's
1122
+ // box promises "provider, vertical, account, or error" (ConnectionsHealth.tsx) —
1123
+ // never ids — so only THOSE fields get substring matching. A tenant already has the
1124
+ // exact `?tenantId=` filter, so `e.tenantId` gets no needle match at all. Pasting a
1125
+ // connection id still finds its row: `q` equal to the WHOLE id, case-insensitively,
1126
+ // is an exact match a fragment can never collide with.
1127
+ const needle = q.q?.toLowerCase();
1128
+ const matching = all.filter((e) => (!q.status || e.health === q.status) &&
1129
+ (!needle ||
1130
+ e.id.toLowerCase() === needle ||
1131
+ [e.provider, e.vertical, e.label, e.externalAccountRef, e.lastError].some((f) => f?.toLowerCase().includes(needle))));
1132
+ const page = pageSlice(matching, { limit: q.limit, cursor: q.cursor }, (e) => e.id);
1133
+ // Connector dead letters. A hosted vertical is CP-less, so its connector deliveries
1134
+ // run as `connector:<provider>` platform intents, and a terminal or given-up intent
1135
+ // lands an ops-failure row (platform-drain.ts) — directory-side, so this is one
1136
+ // bounded query per provider rather than a walk over every scope. What it cannot see:
1137
+ // an IN-PROCESS executor drain's dead letters (a host that runs connectors itself —
1138
+ // dev, self-host), which journal only in the scope.
1139
+ const since = new Date(now.getTime() - CONNECTOR_DEAD_LETTER_WINDOW_DAYS * 86_400_000).toISOString();
1140
+ const providers = q.provider ? [q.provider] : [...new Set(rows.map((r) => r.provider))].sort();
1141
+ const deadLetters = await Promise.all(providers.map(async (provider) => {
1142
+ const found = await admin.listOpsFailures(c.get('actor'), {
1143
+ operation: `intent.${connectorDispatchKind(provider)}`,
1144
+ ...(q.tenantId ? { tenantId: q.tenantId } : {}),
1145
+ since,
1146
+ limit: CONNECTOR_DEAD_LETTER_CAP + 1,
1147
+ });
1148
+ return {
1149
+ provider,
1150
+ count: Math.min(found.length, CONNECTOR_DEAD_LETTER_CAP),
1151
+ capped: found.length > CONNECTOR_DEAD_LETTER_CAP,
1152
+ };
1153
+ }));
1154
+ const body = {
1155
+ ...page,
1156
+ summary,
1157
+ deadLetters,
1158
+ deadLettersSince: since,
1159
+ staleAfterDays: CONNECTION_STALE_DAYS,
1160
+ expiryWarningDays: CONNECTION_EXPIRY_WARNING_DAYS,
1161
+ asOf: now.toISOString(),
1162
+ };
1163
+ return c.json(body);
1164
+ });
1165
+ // -- connector calls, fleet-wide, over time (#1691) ---------------------------
1166
+ //
1167
+ // The trend behind the health line above: calls and errors per provider, bucketed,
1168
+ // from the connector-call dataset the host writes on every settled call (kernel
1169
+ // `connector-calls.ts`). Staff/service only by the same construction as
1170
+ // `/connections/health` — in neither BUILDER_ROUTES nor TENANT_ROUTES, so both
1171
+ // credentials default-deny it. 501 when the reader has no dataset named: an empty
1172
+ // series would draw as "no calls". Up to a week, because "is this provider getting
1173
+ // worse this week" is the question it exists for.
1174
+ app.get('/connections/calls', async (c) => {
1175
+ if (!options.observability?.connectorCallsSeries) {
1176
+ return c.json({ error: 'connector-call analytics are not configured on this control plane' }, 501);
1177
+ }
1178
+ const input = z
1179
+ .object({
1180
+ hours: z.coerce.number().int().min(1).max(168).default(24),
1181
+ // A provider slug — and the character set the AE literal accepts, so a bad one is
1182
+ // this route's 400 rather than the reader's 500.
1183
+ provider: z.string().regex(/^[A-Za-z0-9_\-.]{1,64}$/).optional(),
1184
+ })
1185
+ .parse({ hours: c.req.query('hours'), provider: c.req.query('provider') || undefined });
1186
+ const buckets = await options.observability.connectorCallsSeries(input);
1187
+ return c.json({ hours: input.hours, buckets });
1188
+ });
1189
+ // -- platform-request backlog, fleet-wide (#1690 §2) -----------------------
1190
+ //
1191
+ // "Is the platform's own intent drain keeping up" — composed from the SAME ops-failure
1192
+ // record the dead-letter count above reads, generalized from one provider family to
1193
+ // every known intent kind. Staff/service only by the same construction as
1194
+ // `/connections/health` — absent from BUILDER_ROUTES and TENANT_ROUTES, so both
1195
+ // credentials default-deny it.
1196
+ //
1197
+ // This is a count of terminal FAILURES, never a queue depth (see `platformRequestBacklog`'s
1198
+ // doc): a still-`pending` intent lives in the vertical's own scope DO and nothing here
1199
+ // walks the fleet to find it. A `0` means "nothing has given up lately", not "nothing is
1200
+ // waiting" — the console tile is worded to keep that distinction, not just this comment.
1201
+ app.get('/platform-requests/backlog', async (c) => {
1202
+ const now = new Date();
1203
+ const since = new Date(now.getTime() - PLATFORM_REQUEST_BACKLOG_WINDOW_DAYS * 86_400_000).toISOString();
1204
+ // The fixed kinds the platform itself registers, plus one `connector:<provider>` kind
1205
+ // per provider that has (or had) a live connection anywhere in the fleet — the same
1206
+ // derivation the dead-letter count above uses, since that kind only exists for a
1207
+ // provider some connection actually names.
1208
+ const providers = [...new Set((await admin.listConnections(c.get('actor'), { includeRevoked: true })).map((r) => r.provider))];
1209
+ const kinds = [
1210
+ PROVISION_SIBLING_KIND,
1211
+ ARCHIVE_SCOPE_KIND,
1212
+ PROVISION_TENANT_KIND,
1213
+ SET_ENTITLEMENTS_KIND,
1214
+ MODEL_USAGE_KIND,
1215
+ SWEEP_RUNS_KIND,
1216
+ ...providers.map(connectorDispatchKind),
1217
+ ];
1218
+ const counts = await Promise.all(kinds.map(async (kind) => {
1219
+ const found = await admin.listOpsFailures(c.get('actor'), {
1220
+ operation: `intent.${kind}`,
1221
+ since,
1222
+ limit: PLATFORM_REQUEST_BACKLOG_CAP + 1,
1223
+ });
1224
+ return {
1225
+ count: Math.min(found.length, PLATFORM_REQUEST_BACKLOG_CAP),
1226
+ capped: found.length > PLATFORM_REQUEST_BACKLOG_CAP,
1227
+ };
1228
+ }));
1229
+ const body = {
1230
+ total: counts.reduce((sum, k) => sum + k.count, 0),
1231
+ capped: counts.some((k) => k.capped),
1232
+ since,
1233
+ windowDays: PLATFORM_REQUEST_BACKLOG_WINDOW_DAYS,
1234
+ };
1235
+ return c.json(body);
1236
+ });
1034
1237
  // The same upsert semantics as `/internal/connections/upsert` (§3.5.2) — create under a
1035
1238
  // fresh id, or rotate the one live row in place so its grant tuples survive — behind
1036
1239
  // platform-actor auth instead of the vertical-harness PLATFORM_SECRET. The tenant is
@@ -1509,6 +1712,84 @@ export function createControlPlaneApi(options) {
1509
1712
  await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
1510
1713
  return { servingRef: serving.ref, versionId: serving.versionId, tables: restored.tables };
1511
1714
  };
1715
+ /**
1716
+ * Carry a scope's data onto the script a version bind is about to route it to (#1710).
1717
+ * The caller binds only after this returns.
1718
+ *
1719
+ * A scope that is not on the serving script routes by its bound version's own
1720
+ * per-version script (`COALESCE(servingRef, deploymentRef)`), and a Durable Object
1721
+ * namespace belongs to its script. So a bind to a version with a DIFFERENT
1722
+ * `deploymentRef` points the scope at an empty namespace. Previews live there by design
1723
+ * (#527), and so does a forked scope that `scope bind` re-points. This moves the data
1724
+ * first, in adopt-serving's order: export from the script the router resolves the scope
1725
+ * to TODAY, then restore into the incoming version's script.
1726
+ *
1727
+ * After validating the incoming version, it reads no scope data and carries nothing
1728
+ * when the route does not move: the incoming
1729
+ * version has no script of its own (co-located), the two refs are the same script, the
1730
+ * scope stays pinned to the serving script (the version pointer never touches its
1731
+ * route), or the scope's data is not in a named script. It also carries nothing on a
1732
+ * host with no per-version resolver, because such a host has no per-version namespaces
1733
+ * to move between. `dropServingRef` is for a caller that clears the pin in the same act:
1734
+ * the route then does move, off the serving script.
1735
+ *
1736
+ * A failure strands nothing. The pointer has not moved and the source copy is untouched,
1737
+ * and the restore is one transaction in the target DO (drop-then-replay). So a failed
1738
+ * carry leaves the scope serving its data from the old script, and a retry carries again
1739
+ * over any partial copy. A retry after a bind that landed finds the same script on both
1740
+ * ends and carries nothing.
1741
+ *
1742
+ * What it does not do:
1743
+ * - Carry writes that land on the source between the export and the bind. Those are lost.
1744
+ * - Delete the source copy. That copy is unreachable once the caller binds, and it stays
1745
+ * in the old script, because nothing reaps it yet (#1722). Deleting it here would race
1746
+ * a concurrent push that is still exporting from it.
1747
+ * - Cross lineages. A version of another vertical reads as absent here, and moving a scope
1748
+ * between lineages is `rebindScopeOntoVertical`'s job, behind its migration gate.
1749
+ */
1750
+ const carryOntoVersion = async (c, scope, versionId, opts = {}) => {
1751
+ const resolveVersion = options.resolveVerticalVersion;
1752
+ if (!scope.vertical)
1753
+ return null;
1754
+ const actor = c.get('actor');
1755
+ const incoming = await admin.getVersion(actor, versionId, scope.vertical);
1756
+ // The bind refuses a version that is not admitted, except onto a preview. Check that
1757
+ // BEFORE the copy. Otherwise unreviewed code would receive the scope's data through its
1758
+ // own restore handler, only for the bind to refuse afterwards.
1759
+ if (!incoming) {
1760
+ throw new ControlPlaneError(404, `unknown version ${versionId} for vertical '${scope.vertical}'`);
1761
+ }
1762
+ if (incoming.admission !== 'admitted' && scope.kind !== 'preview') {
1763
+ throw new ControlPlaneError(409, `version ${versionId} is not admitted`);
1764
+ }
1765
+ if (!resolveVersion || (scope.servingRef && !opts.dropServingRef))
1766
+ return null;
1767
+ const to = incoming.deploymentRef ?? null;
1768
+ const bound = scope.verticalVersionId
1769
+ ? await admin.getVersion(actor, scope.verticalVersionId, scope.vertical)
1770
+ : undefined;
1771
+ const from = scope.servingRef ?? bound?.deploymentRef ?? null;
1772
+ if (!to || !from || from === to)
1773
+ return null;
1774
+ // Resolve each end at exactly the script named above. No fallback to the prod channel:
1775
+ // a guessed source would be exported, found empty, and carried as if it were the data.
1776
+ const source = scope.servingRef
1777
+ ? await options.resolveVerticalRef?.(scope.servingRef)
1778
+ : await resolveVersion(scope.vertical, scope.verticalVersionId, actor);
1779
+ const dest = await resolveVersion(scope.vertical, versionId, actor);
1780
+ if (!source || !dest) {
1781
+ // Binding anyway would point the scope at an empty store and leave its data behind,
1782
+ // so refuse and name the end that did not resolve. 502, like any unreachable
1783
+ // dependency here (`vertical-client.ts` `reach`, `preview-auth.ts`): this is a wiring or
1784
+ // transient failure, not a deployment that is too old to do it (which is what 501 means).
1785
+ const missing = !source ? `the script holding its data ('${from}')` : `version ${versionId}'s script ('${to}')`;
1786
+ throw new ControlPlaneError(502, `cannot move scope ${scope.id} from '${from}' to '${to}': ${missing} could not be reached, ` +
1787
+ `so the version was not bound (#1710)`);
1788
+ }
1789
+ const dump = await retryTransient(() => source.exportScope(scope.id));
1790
+ const restored = await retryTransient(() => dest.restoreScope(scope.tenantId, scope.id, dump));
1791
+ return { from, to, tables: restored.tables };
1792
+ };
1512
1793
  app.get('/tenants/:tenantId/scopes/:scopeId/tables', async (c) => {
1513
1794
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1514
1795
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -2970,13 +3251,26 @@ export function createControlPlaneApi(options) {
2970
3251
  // opts into fork-before-promote (§4): on a migration-digest-crossing bind the
2971
3252
  // pre-migration data is snapshotted first — orchestrated through the vertical
2972
3253
  // when one resolves, in-process otherwise.
3254
+ //
3255
+ // A scope that routes by its bound version's own script (a fork, such as a forked test
3256
+ // environment `substrat scope bind` re-points on every merge) has its data carried into
3257
+ // the new version's script before the pointer moves (#1710). The snapshot is taken
3258
+ // after a successful carry, before binding, in the script the data is leaving. A scope
3259
+ // on the serving script carries nothing, because its route does not follow the version pointer.
2973
3260
  app.post('/tenants/:tenantId/scopes/:scopeId/version', async (c) => {
2974
3261
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
2975
3262
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
2976
3263
  const { versionId, snapshot } = bindScopeVersionBody.parse(await c.req.json());
2977
3264
  const actor = c.get('actor');
3265
+ const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
3266
+ // A refusal throws a ControlPlaneError, which the app's error boundary relays with its
3267
+ // status and records as an ops failure. An unknown scope carries nothing, and the bind
3268
+ // below refuses it as it always has.
3269
+ const carry = async () => {
3270
+ if (scope)
3271
+ await carryOntoVersion(c, scope, versionId);
3272
+ };
2978
3273
  if (snapshot) {
2979
- const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
2980
3274
  if (!scope) {
2981
3275
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
2982
3276
  }
@@ -2984,22 +3278,26 @@ export function createControlPlaneApi(options) {
2984
3278
  if (vertical) {
2985
3279
  // Delegated path: the digest compare lives here (the in-process path does
2986
3280
  // it below the seam). Snapshot only a migration-crossing bind.
3281
+ let migrationCrossing = false;
2987
3282
  if (scope.vertical && scope.verticalVersionId) {
2988
3283
  const [current, incoming] = await Promise.all([
2989
3284
  admin.getVersion(actor, scope.verticalVersionId, scope.vertical),
2990
3285
  admin.getVersion(actor, versionId, scope.vertical),
2991
3286
  ]);
2992
- if (current && incoming && current.migrationDigest !== incoming.migrationDigest) {
2993
- await orchestratedSnapshot(c, tenantId, scope, {});
2994
- }
3287
+ migrationCrossing = Boolean(current && incoming && current.migrationDigest !== incoming.migrationDigest);
2995
3288
  }
3289
+ await carry();
3290
+ if (migrationCrossing)
3291
+ await orchestratedSnapshot(c, tenantId, scope, {});
2996
3292
  await admin.bindScopeVersion(actor, tenantId, scopeId, versionId);
2997
3293
  }
2998
3294
  else {
3295
+ await carry();
2999
3296
  await admin.bindScopeVersion(actor, tenantId, scopeId, versionId, { snapshot: true });
3000
3297
  }
3001
3298
  return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
3002
3299
  }
3300
+ await carry();
3003
3301
  await admin.bindScopeVersion(actor, tenantId, scopeId, versionId);
3004
3302
  return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
3005
3303
  });
@@ -3427,6 +3725,12 @@ export function createControlPlaneApi(options) {
3427
3725
  // happen — while this one wants the declaration as a node.
3428
3726
  schedules: parsed?.schedules ?? [],
3429
3727
  outbound: parsed?.outbound ?? [],
3728
+ // The peer verticals this version declares it calls (#1706) — `outbound`'s sibling
3729
+ // one band over: the same kind of declared reach, at another of the tenant's apps
3730
+ // rather than at a third-party host. `null` is a FACT, not missing data: a version
3731
+ // pushed before the declaration existed is unenforced, which a reader must be able
3732
+ // to tell from a version that declares it calls nothing.
3733
+ calls: parsed?.calls ?? null,
3430
3734
  });
3431
3735
  });
3432
3736
  app.get('/verticals/:slug/versions/:id/model', async (c) => {
@@ -4696,11 +5000,21 @@ export function createControlPlaneApi(options) {
4696
5000
  * between the two converges on retry. Shared by that route and by the create path, which
4697
5001
  * reaps a HALF-BUILT leftover before re-forking (see `orchestratedPreview`). */
4698
5002
  const reapPreview = async (c, preview) => {
5003
+ // A fork's own sign-in clients go FIRST (#1704): a failure here leaves the preview in
5004
+ // place, so the retry a PR-close job makes still finds it — after the row is gone, nothing
5005
+ // would name those clients again. A clean room never had one.
5006
+ await retireClientsOfReapedScope(previewAuthDeps(c), preview);
4699
5007
  const vertical = await verticalForScope(c, preview);
4700
5008
  if (vertical)
4701
5009
  await vertical.deleteScope({ scopeId: preview.id });
4702
5010
  await options.host.deleteSnapshot(c.get('actor'), preview.tenantId, preview.id);
4703
5011
  };
5012
+ /** The preview-login seam (#1704, `preview-auth.ts`): the directory, and each issuer's deployment. */
5013
+ const previewAuthDeps = (c) => ({
5014
+ admin,
5015
+ actor: c.get('actor'),
5016
+ issuerClient: (scope) => verticalForScope(c, scope),
5017
+ });
4704
5018
  /** Given a base hostname `<label>.<domain>`, mint (or find) the preview's `--<tag>`
4705
5019
  * hostname `<label>--<tag>.<domain>` bound to `previewId`. Non-canonical, so it never
4706
5020
  * demotes the prod surface. Shared by the fork and clean-room paths. */
@@ -4801,18 +5115,65 @@ export function createControlPlaneApi(options) {
4801
5115
  const target = (await options.resolveVerticalVersion?.(slug, opts.versionId, actor)) ??
4802
5116
  options.verticals?.[slug] ??
4803
5117
  (await options.resolveVertical?.(slug, actor));
5118
+ // The preview's own login (#1704), once it serves this version. Delivered into `target` —
5119
+ // THIS push's version deployment — and never "whatever the scope is bound to now", so an
5120
+ // older push can never overwrite a newer push's config store. A failure lands an
5121
+ // ops-failure row like a failed restore (#559): the preview exists and serves the PR, but
5122
+ // has no working login. The message names no secret; none ever reaches it.
5123
+ const wireLogin = async (previewId, hostname, parent) => {
5124
+ try {
5125
+ return await wirePreviewAuth(previewAuthDeps(c), {
5126
+ tenantId,
5127
+ versionId: opts.versionId,
5128
+ previewId,
5129
+ tag: opts.tag,
5130
+ parent,
5131
+ previewHostname: hostname,
5132
+ appName: parent?.name ?? slug.split('/').at(-1),
5133
+ deliver: async (entries) => {
5134
+ if (!target)
5135
+ throw new ControlPlaneError(501, 'no deployment resolves for the PR version to deliver to');
5136
+ await retryTransient(() => target.configureInstance({ tenantId, scopeId: previewId, entries }));
5137
+ },
5138
+ });
5139
+ }
5140
+ catch (e) {
5141
+ if (e instanceof ControlPlaneError) {
5142
+ recordFailure({
5143
+ actor,
5144
+ operation: 'preview.create',
5145
+ stage: 'login',
5146
+ tenantId,
5147
+ scopeId: previewId,
5148
+ vertical: slug,
5149
+ version: opts.versionId,
5150
+ status: e.status,
5151
+ message: e.message,
5152
+ }, e);
5153
+ }
5154
+ throw e;
5155
+ }
5156
+ };
5157
+ const notesFor = (parent) => parent
5158
+ ? [
5159
+ "Settings: a preview does not carry over the app's per-install settings (the dashboard's Env tab); " +
5160
+ "it runs on the deployment's defaults.",
5161
+ ]
5162
+ : [];
4804
5163
  // The `--<tag>` URL's base — the source's canonical hostname (fork) or the tenant-app
4805
5164
  // convention (clean-room). Computed once so reuse and fresh mint the same URL.
4806
5165
  const baseHostname = await previewBaseHostname(actor, source, tenantId, slug, surface);
4807
5166
  // Idempotent on (tenant, vertical, tag): a PR *synchronize* rebinds the new version
4808
- // onto the SAME preview — successive pushes roll their migrations forward on one copy
4809
- // (§4's rehearsal case) — unless `refresh` asks for a fresh one.
5167
+ // onto the SAME preview, unless `refresh` asks for a fresh one. On a hosted vertical
5168
+ // every push is its own script, so the rebind first CARRIES the preview's data into
5169
+ // the new version's script (#1710). The preview's data then follows it from push to
5170
+ // push, and each push's migrations run forward over it (§4's rehearsal case).
4810
5171
  const existing = (await admin.listScopes(actor, { tenantId, vertical: slug })).find((s) => s.kind === 'preview' && s.slug === previewSlug(slug, opts.tag));
4811
5172
  // A preview only HAS data once its two-phase create finished: the directory row lands
4812
5173
  // first as `provisioning` (K-31), the fork's export→restore runs, and `activateScope`
4813
5174
  // 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 only rebinds the version and the
4815
- // hostname, it never copies data, so adopting a half-built row hands back a
5175
+ // its DO is empty. Reuse must never adopt one: reuse carries whatever the preview holds
5176
+ // and never re-forks from the source, so adopting a half-built row hands back a
4816
5177
  // permanently EMPTY preview and reports `reused: true` — success for a URL that shows
4817
5178
  // a reviewer no data at all. That is exactly what a CI retry does (the generated
4818
5179
  // workflow retries `preview create` on a transient), so the failure mode is the
@@ -4822,6 +5183,31 @@ export function createControlPlaneApi(options) {
4822
5183
  // scope would otherwise collide with the old row's still-bound `--<tag>` hostname.
4823
5184
  const stale = existing !== undefined && (opts.refresh || existing.status !== 'active');
4824
5185
  if (existing && !stale) {
5186
+ // Data first, pointer last (#1710): move the preview's data into this push's script
5187
+ // BEFORE anything re-points it. A failed carry therefore leaves the preview bound to
5188
+ // its old script with its data, and records why, like a failed fork restore (#559).
5189
+ // `dropServingRef`: a preview provisioned before #527 inherited a serving_ref that is
5190
+ // cleared just below, so its route leaves the serving script too.
5191
+ let carried;
5192
+ try {
5193
+ carried = await carryOntoVersion(c, existing, opts.versionId, { dropServingRef: true });
5194
+ }
5195
+ catch (e) {
5196
+ if (e instanceof ControlPlaneError && e.status >= 500 && e.status !== 501) {
5197
+ recordFailure({
5198
+ actor,
5199
+ operation: 'preview.create',
5200
+ stage: 'carry',
5201
+ tenantId,
5202
+ scopeId: existing.id,
5203
+ vertical: slug,
5204
+ version: opts.versionId,
5205
+ status: e.status,
5206
+ message: e.message,
5207
+ }, e);
5208
+ }
5209
+ throw e;
5210
+ }
4825
5211
  // Heal a preview provisioned before #527: clear any inherited serving_ref so routing
4826
5212
  // follows the bound version (its per-version script), not the prod serving script.
4827
5213
  if (existing.servingRef)
@@ -4831,7 +5217,34 @@ export function createControlPlaneApi(options) {
4831
5217
  await admin.setScopeExpiresAt(actor, tenantId, existing.id, expiresAt);
4832
5218
  const hostname = await bindPreviewHostname(actor, baseHostname, tenantId, existing.id, opts.tag, surface);
4833
5219
  await assertServesBoundVersion(existing.id);
4834
- return { scopeId: existing.id, hostname, url: `https://${hostname}`, versionId: opts.versionId, reused: true };
5220
+ // The fork it was made from, not whichever prod scope this call picked: a reuse is the
5221
+ // same preview. Every push is a new version with an EMPTY config store, and the carry
5222
+ // above moves the scope's data but not the vertical's config store, so the login is
5223
+ // wired again on every reuse, never assumed to have survived.
5224
+ const parent = existing.forkedFrom
5225
+ ? ((await admin.getScopeRecord(actor, tenantId, existing.forkedFrom)) ?? null)
5226
+ : null;
5227
+ const auth = await wireLogin(existing.id, hostname, parent);
5228
+ return {
5229
+ scopeId: existing.id,
5230
+ hostname,
5231
+ url: `https://${hostname}`,
5232
+ versionId: opts.versionId,
5233
+ reused: true,
5234
+ auth,
5235
+ // The carry's loss window, said where the person pushing reads it (the CLI prints
5236
+ // every note): a reviewer who clicked around during the push would otherwise just
5237
+ // find their change gone.
5238
+ notes: [
5239
+ ...(carried
5240
+ ? [
5241
+ `Data: the preview's data was copied into this push's version (${carried.tables} tables). ` +
5242
+ 'Anything written to the preview while this push ran may be lost.',
5243
+ ]
5244
+ : []),
5245
+ ...notesFor(parent),
5246
+ ],
5247
+ };
4835
5248
  }
4836
5249
  // Free the tag: the slug is unique per tenant and the `--<tag>` hostname is still bound
4837
5250
  // to the old row, so the fresh fork below cannot be provisioned until this one is gone.
@@ -4921,7 +5334,16 @@ export function createControlPlaneApi(options) {
4921
5334
  await admin.bindScopeVersion(actor, tenantId, previewId, opts.versionId);
4922
5335
  const hostname = await bindPreviewHostname(actor, baseHostname, tenantId, previewId, opts.tag, surface);
4923
5336
  await assertServesBoundVersion(previewId);
4924
- return { scopeId: previewId, hostname, url: `https://${hostname}`, versionId: opts.versionId, reused: false };
5337
+ const auth = await wireLogin(previewId, hostname, source);
5338
+ return {
5339
+ scopeId: previewId,
5340
+ hostname,
5341
+ url: `https://${hostname}`,
5342
+ versionId: opts.versionId,
5343
+ reused: false,
5344
+ auth,
5345
+ notes: notesFor(source),
5346
+ };
4925
5347
  };
4926
5348
  // The owning tenant of a builder's OWN vertical — the gate every preview route shares. A
4927
5349
  // preview forks THIS tenant's own scope and binds the version onto that fork (never an
@@ -5003,6 +5425,8 @@ export function createControlPlaneApi(options) {
5003
5425
  expiresAt: s.expiresAt,
5004
5426
  hostname: hosts[0]?.hostname ?? null,
5005
5427
  url: hosts[0]?.hostname ? `https://${hosts[0].hostname}` : null,
5428
+ // What an EXTERNAL issuer would need registered for this preview to sign in (#1704).
5429
+ callbackUrl: hosts[0]?.hostname ? oidcCallbackUrl(hosts[0].hostname) : null,
5006
5430
  });
5007
5431
  }
5008
5432
  return c.json(out);
@@ -5130,6 +5554,70 @@ export function createControlPlaneApi(options) {
5130
5554
  };
5131
5555
  app.delete('/tenants/:tenantId/scopes/:scopeId/system-grants', switchScheduleRoute('off'));
5132
5556
  app.post('/tenants/:tenantId/scopes/:scopeId/system-grants', switchScheduleRoute('on'));
5557
+ // The status read (#1674): "is this module switched off on this scope?", without
5558
+ // reaching for the scope's own SQL console (the CHECKPOINT runbook's old CONFIRM step —
5559
+ // now this route). Same staff-only gate as the switch itself: a tenant credential passes
5560
+ // the `/tenants/<own>` confinement, so it is refused here too, by the same
5561
+ // `confinedTenant` check, not a kind test.
5562
+ app.get('/tenants/:tenantId/scopes/:scopeId/system-grants', async (c) => {
5563
+ if (confinedTenant(c.get('principal')) !== null) {
5564
+ return c.json({ error: 'forbidden: the schedule switch is staff-only' }, 403);
5565
+ }
5566
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
5567
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
5568
+ const actor = c.get('actor');
5569
+ // K-3 first, so a scope of another tenant reads as absent before anything is reached.
5570
+ if (!(await admin.getScopeRecord(actor, tenantId, scopeId))) {
5571
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5572
+ }
5573
+ return c.json(await admin.systemGrantsStatus(actor, { tenantId, scopeId }));
5574
+ });
5575
+ // -- the peer kill switch (#1706) ------------------------------------------
5576
+ //
5577
+ // One calling vertical's access to ONE scope, off and back on — `revokeFromPeer` and
5578
+ // `restoreToPeer`. The schedule switch above with the subject swapped: `reason` required,
5579
+ // both halves audited intent-then-outcome, idempotent, and no re-provision re-seats what
5580
+ // OFF took, so the POST is the only way back. For a HOSTED scope the grants live in the
5581
+ // vertical's deployment and `peerSwitchDelegation` moves the switch there; a deployment
5582
+ // built before the far end existed answers 501 "redeploy" and nothing is switched.
5583
+ //
5584
+ // **Unlike the schedule switch, a tenant may pull this one**, and that is the point of it:
5585
+ // "may this other app of mine still call into here" is the tenant's question about its own
5586
+ // two apps, not a platform incident lever. A confined credential (#977) is held by the
5587
+ // dashboard on a tenant's behalf and may act on THAT tenant only; no vertical holds one at
5588
+ // all, since a vertical is CP-less, so the switched party cannot switch itself back on.
5589
+ const switchPeerRoute = (to) => async (c) => {
5590
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
5591
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
5592
+ const pin = confinedTenant(c.get('principal'));
5593
+ if (pin !== null && pin !== tenantId)
5594
+ return c.json({ error: 'forbidden' }, 403);
5595
+ const body = peerSwitchBody.parse(await c.req.json());
5596
+ const actor = c.get('actor');
5597
+ // K-3 first, so a scope of another tenant reads as absent before anything is reached.
5598
+ if (!(await admin.getScopeRecord(actor, tenantId, scopeId))) {
5599
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5600
+ }
5601
+ const input = { vertical: body.vertical, node: { tenantId, scopeId }, reason: body.reason };
5602
+ return c.json(to === 'off' ? await admin.revokeFromPeer(actor, input) : await admin.restoreToPeer(actor, input));
5603
+ };
5604
+ app.delete('/tenants/:tenantId/scopes/:scopeId/peer-grants', switchPeerRoute('off'));
5605
+ app.post('/tenants/:tenantId/scopes/:scopeId/peer-grants', switchPeerRoute('on'));
5606
+ // The status read (#1706): which of the tenant's other apps may call into this scope, and
5607
+ // where each stands — the read the dashboard's install disclosure and the console's scope
5608
+ // view are both built on. Same confinement as the switch, for the same reason.
5609
+ app.get('/tenants/:tenantId/scopes/:scopeId/peer-grants', async (c) => {
5610
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
5611
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
5612
+ const pin = confinedTenant(c.get('principal'));
5613
+ if (pin !== null && pin !== tenantId)
5614
+ return c.json({ error: 'forbidden' }, 403);
5615
+ const actor = c.get('actor');
5616
+ if (!(await admin.getScopeRecord(actor, tenantId, scopeId))) {
5617
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5618
+ }
5619
+ return c.json(await admin.peerGrantsStatus(actor, { tenantId, scopeId }));
5620
+ });
5133
5621
  // Orgs: the portal-customer grouping (§4.1). Creating one mints no permission —
5134
5622
  // members reach what the org was GRANTED, and granting stays off this surface.
5135
5623
  app.post('/tenants/:tenantId/orgs', async (c) => {