@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.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. Already-adopted scopes short-circuit.
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 — i.e. the serve
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
- if (current && incoming && current.migrationDigest !== incoming.migrationDigest) {
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 — successive pushes roll their migrations forward on one copy
4809
- // (§4's rehearsal case) — unless `refresh` asks for a fresh one.
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 only rebinds the version and the
4815
- // hostname, it never copies data, so adopting a half-built row hands back a
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
- return { scopeId: existing.id, hostname, url: `https://${hostname}`, versionId: opts.versionId, reused: true };
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
- return { scopeId: previewId, hostname, url: `https://${hostname}`, versionId: opts.versionId, reused: false };
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) => {