@substrat-run/control-plane-api 0.139.0 → 0.140.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.
Files changed (45) hide show
  1. package/dist/api.d.ts +14 -1
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +221 -63
  4. package/dist/api.js.map +1 -1
  5. package/dist/audited-change.d.ts +139 -0
  6. package/dist/audited-change.d.ts.map +1 -0
  7. package/dist/audited-change.js +214 -0
  8. package/dist/audited-change.js.map +1 -0
  9. package/dist/connect-links.d.ts +29 -0
  10. package/dist/connect-links.d.ts.map +1 -0
  11. package/dist/connect-links.js +139 -0
  12. package/dist/connect-links.js.map +1 -0
  13. package/dist/connect-url.d.ts +20 -1
  14. package/dist/connect-url.d.ts.map +1 -1
  15. package/dist/connect-url.js +1 -1
  16. package/dist/connect-url.js.map +1 -1
  17. package/dist/connection-relay.d.ts +24 -0
  18. package/dist/connection-relay.d.ts.map +1 -1
  19. package/dist/connection-relay.js +31 -12
  20. package/dist/connection-relay.js.map +1 -1
  21. package/dist/email-intent.d.ts +71 -0
  22. package/dist/email-intent.d.ts.map +1 -0
  23. package/dist/email-intent.js +186 -0
  24. package/dist/email-intent.js.map +1 -0
  25. package/dist/index.d.ts +7 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +6 -3
  28. package/dist/index.js.map +1 -1
  29. package/dist/platform-drain.d.ts +12 -2
  30. package/dist/platform-drain.d.ts.map +1 -1
  31. package/dist/platform-drain.js +15 -3
  32. package/dist/platform-drain.js.map +1 -1
  33. package/dist/reconcile.d.ts +17 -2
  34. package/dist/reconcile.d.ts.map +1 -1
  35. package/dist/reconcile.js +35 -1
  36. package/dist/reconcile.js.map +1 -1
  37. package/dist/staff-client.d.ts +3 -1
  38. package/dist/staff-client.d.ts.map +1 -1
  39. package/dist/staff-client.js +2 -0
  40. package/dist/staff-client.js.map +1 -1
  41. package/dist/vertical-client.d.ts +25 -1
  42. package/dist/vertical-client.d.ts.map +1 -1
  43. package/dist/vertical-client.js +89 -22
  44. package/dist/vertical-client.js.map +1 -1
  45. package/package.json +6 -6
package/dist/api.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { Hono } from 'hono';
2
- import { z } from '@substrat-run/contracts';
2
+ import { type ConnectionCertificate, z } from '@substrat-run/contracts';
3
3
  import type { Connection, ConnectionActivity, ConnectionActivitySource, ConnectionCredential, ConnectionProbe, PlatformActorId } from '@substrat-run/contracts';
4
4
  import type { CrossVerticalOptions, HostAdmin, ScopeHost } from '@substrat-run/kernel';
5
5
  import type { PlatformActorAuth, BuilderAuth, Principal, TenantServiceAuth } from './auth.js';
6
6
  import { type VerticalClient } from './vertical-client.js';
7
+ import { type ConnectionCandidatePrepare } from './connection-relay.js';
7
8
  import type { DeployVerticalFn, FetchVerticalAssetFn, FetchVerticalModulesFn } from './deploy.js';
8
9
  import type { PatchScriptBindingsFn } from './wfp.js';
9
10
  import { type ObservabilityReader } from './observability.js';
@@ -44,6 +45,18 @@ export interface ConnectionInspector {
44
45
  * provider is checked before it writes, and a REFUSED credential never lands.
45
46
  */
46
47
  probeCandidate?: (secret: Record<string, string>) => Promise<ConnectionProbe>;
48
+ /**
49
+ * Finish a candidate credential before it is probed and stored (#2100) — for a provider
50
+ * where part of the credential is the platform's to make (a generated keypair). Gets the
51
+ * live connection's secret when the upsert rotates one, so what must survive an edit can.
52
+ * An `expiresAt` of `null` clears the connection's expiry (`ConnectionCandidatePrepare`).
53
+ */
54
+ prepareCandidate?: ConnectionCandidatePrepare;
55
+ /**
56
+ * The PUBLIC certificate the connection authenticates with, for the tenant to register on
57
+ * the provider's side (#2100). `null` when this connection uses none (a client secret).
58
+ */
59
+ certificate?: (host: ScopeHost, connection: Connection) => Promise<ConnectionCertificate | null>;
47
60
  }
48
61
  export interface ControlPlaneApiOptions {
49
62
  /**
package/dist/api.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
package/dist/api.js CHANGED
@@ -1,15 +1,16 @@
1
1
  import { resolveObservabilityWindow } from './observability-window.js';
2
2
  import { Hono } from 'hono';
3
- import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ownerTransferInput, OWNER_TRANSFER_AUDIT_ERROR_MAX, memberInviteInput, memberRoleInput, 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, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, platformScriptCollision, platformScriptRefusal, PLATFORM_SCRIPT_NAMES, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, errorCodeOf, capabilityFilterQuery, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, } from '@substrat-run/contracts';
3
+ import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, channelName, connectLinkId, connectionActivity, connectionActivitySource, connectionCredential, connectionCertificate, 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, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, platformScriptCollision, platformScriptRefusal, PLATFORM_SCRIPT_NAMES, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, errorCodeOf, capabilityFilterQuery, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, } from '@substrat-run/contracts';
4
4
  import { attributeFailure } from './failure-attribution.js';
5
- import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isPrimaryScope, migrationProgress, ulid, } from '@substrat-run/kernel';
5
+ import { auditedChange, withAuditedOutcomes } from './audited-change.js';
6
+ import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isPrimaryScope, migrationProgress, ulid, auditWarningOf, } from '@substrat-run/kernel';
6
7
  import { TENANT_HEADER, confinedTenant } from './auth.js';
7
8
  import { mintTenantToken } from './tenant-token.js';
8
9
  import { connectionGrantsForScope } from './vertical-client.js';
9
10
  import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
10
11
  import { versionReachedAt } from './scope-deployment.js';
11
12
  import { reconcileConnectionGrants } from './connection-grants.js';
12
- import { reconcileThenReassert, switchCarryFor } from './reconcile.js';
13
+ import { reconcileThenReassert, reviewedEntityGrants, switchCarryFor } from './reconcile.js';
13
14
  import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
14
15
  import { ControlPlaneError } from '@substrat-run/control-plane-client';
15
16
  import { provisionSiblingScope } from './platform-drain.js';
@@ -595,7 +596,16 @@ const TENANT_ROUTES = [
595
596
  { method: 'DELETE', re: /\/tenants\/[^/]+\/connections\/[^/]+$/, pin: 'path' },
596
597
  { method: 'GET', re: /\/tenants\/[^/]+\/connections\/[^/]+\/activity$/, pin: 'path' },
597
598
  { method: 'GET', re: /\/tenants\/[^/]+\/connections\/[^/]+\/credential$/, pin: 'path' },
599
+ // #2100: the PUBLIC certificate a connection signs in with — same pin as the credential view.
600
+ { method: 'GET', re: /\/tenants\/[^/]+\/connections\/[^/]+\/certificate$/, pin: 'path' },
598
601
  { method: 'POST', re: /\/tenants\/[^/]+\/connections\/[^/]+\/verify$/, pin: 'path' },
602
+ // A vertical's mailed connect links (connections.md §3.5.4): the consent landing's liveness
603
+ // read, the callback's spend and its undo, and the integrations card's list and revoke.
604
+ { method: 'GET', re: /\/tenants\/[^/]+\/connect-links$/, pin: 'path' },
605
+ { method: 'GET', re: /\/tenants\/[^/]+\/connect-links\/[^/]+$/, pin: 'path' },
606
+ { method: 'POST', re: /\/tenants\/[^/]+\/connect-links\/[^/]+\/consume$/, pin: 'path' },
607
+ { method: 'POST', re: /\/tenants\/[^/]+\/connect-links\/[^/]+\/restore$/, pin: 'path' },
608
+ { method: 'DELETE', re: /\/tenants\/[^/]+\/connect-links\/[^/]+$/, pin: 'path' },
599
609
  // Members: identity links mirrored from the dashboard, orgs, role assignments.
600
610
  { method: 'GET', re: /\/tenants\/[^/]+\/identities$/, pin: 'path' },
601
611
  { method: 'PUT', re: /\/tenants\/[^/]+\/identities$/, pin: 'path' },
@@ -633,6 +643,8 @@ const TENANT_ROUTES = [
633
643
  { method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/migrations$/, pin: 'path' },
634
644
  { method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/owner-seat$/, pin: 'path' },
635
645
  { method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/peer-grants$/, pin: 'path' },
646
+ { method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/peer-bindings$/, pin: 'path' },
647
+ { method: 'PUT', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/peer-bindings$/, pin: 'path' },
636
648
  // The peer switch (#1706): on is POST, off is DELETE, on the tenant's own scope.
637
649
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/peer-grants$/, pin: 'path' },
638
650
  { method: 'DELETE', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/peer-grants$/, pin: 'path' },
@@ -1588,7 +1600,7 @@ export function createControlPlaneApi(options) {
1588
1600
  return c.json({ error: 'body tenantId disagrees with the route tenant' }, 400);
1589
1601
  }
1590
1602
  try {
1591
- return c.json(await relayConnectionUpsert(c.var.host, c.get('actor'), { ...body, tenantId }, { probeCandidate }));
1603
+ return c.json(await relayConnectionUpsert(c.var.host, c.get('actor'), { ...body, tenantId }, { probeCandidate, prepareCandidate }));
1592
1604
  }
1593
1605
  catch (err) {
1594
1606
  if (err instanceof ConnectionRelayError && err.status < 500) {
@@ -1618,6 +1630,80 @@ export function createControlPlaneApi(options) {
1618
1630
  await c.var.admin.revokeConnection(c.get('actor'), row.id);
1619
1631
  return c.body(null, 204);
1620
1632
  });
1633
+ // -- a vertical's mailed connect links, tenant-scoped (connections.md §3.5.4) ---------
1634
+ //
1635
+ // The dashboard's half of a link a VERTICAL minted (`/internal/connections/connect-links`):
1636
+ // its consent landing asks whether the row still stands, its callback spends the row
1637
+ // before storing and restores it if the store fails, and its integrations card lists and
1638
+ // revokes them. The tenant is the path's; the scope is named on every call and is half
1639
+ // the store's key, so another scope's link — or another tenant's — reads as absent (K-3).
1640
+ // Not in BUILDER_ROUTES: a builder has no business spending a tenant's consent round.
1641
+ const connectLinkScope = z.object({ scopeId: scopeIdSchema });
1642
+ app.get('/tenants/:tenantId/connect-links', async (c) => {
1643
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1644
+ const q = connectLinkScope
1645
+ .extend({ provider: connectionProvider.optional(), outstanding: z.literal('1').optional() })
1646
+ .parse({
1647
+ scopeId: c.req.query('scopeId'),
1648
+ provider: c.req.query('provider') || undefined,
1649
+ outstanding: c.req.query('outstanding') || undefined,
1650
+ });
1651
+ return c.json(await c.var.admin.listConnectLinks(c.get('actor'), {
1652
+ tenantId,
1653
+ scopeId: q.scopeId,
1654
+ ...(q.provider ? { provider: q.provider } : {}),
1655
+ ...(q.outstanding ? { outstandingOnly: true } : {}),
1656
+ }));
1657
+ });
1658
+ app.get('/tenants/:tenantId/connect-links/:linkId', async (c) => {
1659
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1660
+ const { scopeId } = connectLinkScope.parse({ scopeId: c.req.query('scopeId') });
1661
+ const link = await c.var.admin.getConnectLink(c.get('actor'), {
1662
+ tenantId,
1663
+ scopeId,
1664
+ id: connectLinkId.parse(c.req.param('linkId')),
1665
+ });
1666
+ return link ? c.json(link) : c.json({ error: 'unknown connect link' }, 404);
1667
+ });
1668
+ // The callback's spend. A refusal is a 200 carrying the typed reason, not an error status:
1669
+ // "already used" is the answer to the question asked, and the callback renders each one.
1670
+ app.post('/tenants/:tenantId/connect-links/:linkId/consume', async (c) => {
1671
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1672
+ const body = connectLinkScope
1673
+ .extend({
1674
+ provider: connectionProvider,
1675
+ accountRef: z.string().max(256).optional(),
1676
+ accountLabel: z.string().max(256).optional(),
1677
+ })
1678
+ .parse(await c.req.json());
1679
+ return c.json(await c.var.admin.consumeConnectLink(c.get('actor'), {
1680
+ ...body,
1681
+ tenantId,
1682
+ id: connectLinkId.parse(c.req.param('linkId')),
1683
+ }));
1684
+ });
1685
+ app.post('/tenants/:tenantId/connect-links/:linkId/restore', async (c) => {
1686
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1687
+ const { scopeId } = connectLinkScope.parse(await c.req.json());
1688
+ const restored = await c.var.admin.restoreConnectLink(c.get('actor'), {
1689
+ tenantId,
1690
+ scopeId,
1691
+ id: connectLinkId.parse(c.req.param('linkId')),
1692
+ });
1693
+ return c.json({ restored });
1694
+ });
1695
+ // Idempotent, as the store is: a revoked or spent link answers 204 again. 404 only for a
1696
+ // link this scope never held.
1697
+ app.delete('/tenants/:tenantId/connect-links/:linkId', async (c) => {
1698
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1699
+ const { scopeId } = connectLinkScope.parse({ scopeId: c.req.query('scopeId') });
1700
+ const link = await c.var.admin.revokeConnectLink(c.get('actor'), {
1701
+ tenantId,
1702
+ scopeId,
1703
+ id: connectLinkId.parse(c.req.param('linkId')),
1704
+ });
1705
+ return link ? c.body(null, 204) : c.json({ error: 'unknown connect link' }, 404);
1706
+ });
1621
1707
  /**
1622
1708
  * The tenant-scoped connection lookup the two inspection routes share. Same K-3
1623
1709
  * existence hiding as the revoke above — a foreign tenant's connection id reads
@@ -1630,6 +1716,8 @@ export function createControlPlaneApi(options) {
1630
1716
  * in which case the upsert behaves exactly as it always did, storing unverified.
1631
1717
  */
1632
1718
  const probeCandidate = async (provider, secret) => options.connectionInspectors?.[provider]?.probeCandidate?.(secret);
1719
+ /** #2100: the provider's own half of a credential, made before the probe sees it. */
1720
+ const prepareCandidate = (provider) => options.connectionInspectors?.[provider]?.prepareCandidate;
1633
1721
  const inspectableConnection = async (c) => {
1634
1722
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1635
1723
  const id = c.req.param('id');
@@ -1703,6 +1791,20 @@ export function createControlPlaneApi(options) {
1703
1791
  }
1704
1792
  return c.json(connectionCredential.parse(await credential(c.var.host, row)));
1705
1793
  });
1794
+ /**
1795
+ * The public certificate a connection signs in with (#2100) — what the tenant downloads and
1796
+ * uploads to the provider. Public by construction: the private key never leaves the sealed
1797
+ * credential, and only a provider whose keypair the platform generates has one at all.
1798
+ */
1799
+ app.get('/tenants/:tenantId/connections/:id/certificate', async (c) => {
1800
+ const row = await inspectableConnection(c);
1801
+ if (!row)
1802
+ return c.json({ error: 'unknown connection' }, 404);
1803
+ const certificate = await options.connectionInspectors?.[row.provider]?.certificate?.(c.var.host, row);
1804
+ if (!certificate)
1805
+ return c.json({ error: 'this connection signs in with no certificate' }, 404);
1806
+ return c.json(connectionCertificate.parse(certificate));
1807
+ });
1706
1808
  // -- the scope directory (§3.2/§4.2) ---------------------------------------
1707
1809
  app.get('/scopes', async (c) => {
1708
1810
  const p = c.get('principal');
@@ -2825,6 +2927,9 @@ export function createControlPlaneApi(options) {
2825
2927
  ? await c.var.admin.verticalServing(actor, scope.vertical).catch(() => null)
2826
2928
  : null;
2827
2929
  const ranAs = versionReachedAt(reached.via, scope, serving);
2930
+ // #2071: the shapes this reconcile tops holders up to, from the reviewed registry of the
2931
+ // version it reaches — never anything the vertical's own code names.
2932
+ const entityGrants = scope.vertical && ranAs ? await reviewedEntityGrants(c.var.admin, actor, scope.vertical, ranAs) : undefined;
2828
2933
  try {
2829
2934
  // #1674: the recorded OFF positions go back after the reconcile, before the receipt.
2830
2935
  const result = await reconcileThenReassert(c.var.admin, actor, { tenantId, scopeId }, (carry) => vertical.reconcileInstance({
@@ -2834,6 +2939,7 @@ export function createControlPlaneApi(options) {
2834
2939
  identityLinks,
2835
2940
  connectionGrants,
2836
2941
  connectionKeys,
2942
+ ...(entityGrants ? { entityGrants } : {}),
2837
2943
  ...carry,
2838
2944
  // Handed over exactly as at provision: a store minted HERE has never been migrated
2839
2945
  // by the vertical, so the reconcile must carry it into the same ready-gate — a bound
@@ -3256,28 +3362,46 @@ export function createControlPlaneApi(options) {
3256
3362
  return { refusal: c.json({ error: await diagnoseUnboundScope(actor, scope) }, 501) };
3257
3363
  return { tenantId, scopeId, actor, vertical, person: memberPerson(principal) };
3258
3364
  };
3259
- /** Run one audited member change: intent, the vertical's call, then the outcome row. */
3260
- const auditedMemberChange = async (c, entry, run, applied) => {
3261
- const actor = c.get('actor');
3262
- const base = { ...entry, operationId: ulid() };
3263
- await c.var.admin.recordMemberChange(actor, { ...base, phase: 'intent' });
3264
- let result;
3265
- try {
3266
- result = await run();
3267
- }
3268
- catch (e) {
3269
- const status = e instanceof ControlPlaneError ? e.status : 500;
3270
- const error = (e instanceof Error ? e.message : String(e)).slice(0, OWNER_TRANSFER_AUDIT_ERROR_MAX);
3271
- await c.var.admin
3272
- .recordMemberChange(actor, { ...base, phase: status < 500 ? 'refused' : 'failed', error })
3273
- .catch(() => undefined);
3274
- if (e instanceof ControlPlaneError) {
3275
- return { ok: false, response: c.json({ error: e.message }, e.status) };
3365
+ /**
3366
+ * Answer for one `auditedChange` (#2064). A refusal or failure answers with the vertical's own
3367
+ * status (a non-`ControlPlaneError` throw is rethrown). A change that went through answers
3368
+ * `ok`, with its `operationId`, whether or not its outcome row could be written. When it could
3369
+ * not, the answer also carries `auditWarning`, which says so. That is a success on purpose:
3370
+ * the change happened, so no client should read a retryable error and make it twice (a second
3371
+ * invite, a second hand-over). The missing row is logged, and the scheduled settle closes it.
3372
+ */
3373
+ const auditedAnswer = (c, done, what, ok) => {
3374
+ const { operationId } = done;
3375
+ if ('error' in done) {
3376
+ if (done.error instanceof ControlPlaneError) {
3377
+ return c.json({ error: done.error.message, operationId }, done.error.status);
3276
3378
  }
3277
- throw e;
3379
+ throw done.error;
3278
3380
  }
3279
- await c.var.admin.recordMemberChange(actor, { ...base, phase: 'applied', ...applied(result) });
3280
- return { ok: true, result };
3381
+ if ('unrecorded' in done) {
3382
+ return ok(done.result, {
3383
+ operationId,
3384
+ auditWarning: auditWarningOf(what, done.unrecorded),
3385
+ });
3386
+ }
3387
+ return ok(done.result, { operationId });
3388
+ };
3389
+ /** Run one audited member change: intent, the vertical's call, then the outcome row. */
3390
+ const auditedMemberChange = (c, entry, run, applied) => {
3391
+ const actor = c.get('actor');
3392
+ const operationId = ulid();
3393
+ return auditedChange({
3394
+ flow: 'member-change',
3395
+ operationId,
3396
+ run,
3397
+ // A 4xx is the vertical's refusal: nothing was written there.
3398
+ refused: (e) => e instanceof ControlPlaneError && e.status < 500,
3399
+ record: (row) => c.var.admin.recordMemberChange(actor, {
3400
+ ...entry,
3401
+ operationId,
3402
+ ...(row.phase === 'applied' ? { phase: 'applied', ...applied(row.result) } : row),
3403
+ }),
3404
+ });
3281
3405
  };
3282
3406
  const NO_PERSON = 'a member change needs the signed-in person it is made for — this credential names nobody';
3283
3407
  app.get('/tenants/:tenantId/scopes/:scopeId/members', async (c) => {
@@ -3306,7 +3430,7 @@ export function createControlPlaneApi(options) {
3306
3430
  }
3307
3431
  const { tenantId, scopeId, person } = target;
3308
3432
  const done = await auditedMemberChange(c, { tenantId, scopeId, change: 'invite', caller: person, roleKey: body.roleKey }, () => target.vertical.inviteMember({ tenantId, scopeId, caller: person, origin, roleKey: body.roleKey, email: body.email ?? null }), (link) => ({ principal: link.principal }));
3309
- return done.ok ? c.json(done.result, 201) : done.response;
3433
+ return auditedAnswer(c, done, 'the invite', (link, audit) => c.json({ ...link, ...audit }, 201));
3310
3434
  });
3311
3435
  app.post('/tenants/:tenantId/scopes/:scopeId/members/:principal/role', async (c) => {
3312
3436
  const target = await memberTarget(c);
@@ -3318,7 +3442,7 @@ export function createControlPlaneApi(options) {
3318
3442
  const body = memberRoleInput.parse(await c.req.json());
3319
3443
  const { tenantId, scopeId, person } = target;
3320
3444
  const done = await auditedMemberChange(c, { tenantId, scopeId, change: 'role', caller: person, principal: member, from: body.from, to: body.to }, () => target.vertical.changeMemberRole({ tenantId, scopeId, caller: person, principal: member, from: body.from, to: body.to }), () => ({}));
3321
- return done.ok ? c.json({ principal: member, from: body.from, to: body.to }) : done.response;
3445
+ return auditedAnswer(c, done, 'the role change', (_, audit) => c.json({ principal: member, from: body.from, to: body.to, ...audit }));
3322
3446
  });
3323
3447
  app.post('/tenants/:tenantId/scopes/:scopeId/members/:principal/remove', async (c) => {
3324
3448
  const target = await memberTarget(c);
@@ -3329,7 +3453,7 @@ export function createControlPlaneApi(options) {
3329
3453
  const member = principalIdSchema.parse(c.req.param('principal'));
3330
3454
  const { tenantId, scopeId, person } = target;
3331
3455
  const done = await auditedMemberChange(c, { tenantId, scopeId, change: 'remove', caller: person, principal: member }, () => target.vertical.removeMember({ tenantId, scopeId, caller: person, principal: member }), (removal) => ({ revoked: removal.revoked }));
3332
- return done.ok ? c.json(done.result) : done.response;
3456
+ return auditedAnswer(c, done, 'the removal', (removal, audit) => c.json({ ...removal, ...audit }));
3333
3457
  });
3334
3458
  // The owner HAND-OVER (#1665). The move runs in the VERTICAL, behind `/internal/owner-transfer`
3335
3459
  // (vertical-host says in what order and why). This route decides who may ask, and audits every
@@ -3360,41 +3484,19 @@ export function createControlPlaneApi(options) {
3360
3484
  // An abandon (`abandon: true`) is audited the same way, every row marked, so the log tells
3361
3485
  // a hand-over closed without finishing from one that finished.
3362
3486
  const base = { tenantId, scopeId, operationId, from: body.from, to: body.to, ...(body.abandon ? { abandon: true } : {}) };
3363
- await c.var.admin.recordOwnerTransfer(actor, { ...base, phase: 'intent' });
3364
- let moved;
3365
- try {
3366
- moved = await vertical.transferOwner({ tenantId, scopeId, ...body });
3367
- }
3368
- catch (e) {
3369
- const refused = e instanceof ControlPlaneError && e.status === 409;
3370
- const error = (e instanceof Error ? e.message : String(e)).slice(0, OWNER_TRANSFER_AUDIT_ERROR_MAX);
3371
- await c.var.admin
3372
- .recordOwnerTransfer(actor, { ...base, phase: refused ? 'refused' : 'failed', error })
3373
- .catch(() => undefined);
3374
- if (e instanceof ControlPlaneError) {
3375
- return c.json({ error: e.message, operationId }, e.status);
3376
- }
3377
- throw e;
3378
- }
3379
- try {
3380
- await c.var.admin.recordOwnerTransfer(actor, {
3381
- ...base,
3382
- phase: 'applied',
3383
- outcome: moved.outcome,
3384
- fromRevoked: moved.fromRevoked,
3385
- });
3386
- }
3387
- catch (e) {
3388
- // The owner HAS moved; only the outcome row is missing. Say exactly that, so nobody retries
3389
- // a hand-over believing it failed, and name the operation whose intent row stands alone.
3390
- const why = e instanceof Error ? e.message : String(e);
3391
- return c.json({
3392
- error: `the hand-over completed, but its outcome could not be written to the admin log: ${why}`,
3393
- operationId,
3394
- ...moved,
3395
- }, 500);
3396
- }
3397
- return c.json({ operationId, ...moved });
3487
+ const done = await auditedChange({
3488
+ flow: 'owner-transfer',
3489
+ operationId,
3490
+ run: () => vertical.transferOwner({ tenantId, scopeId, ...body }),
3491
+ // Only the vertical's 409 is a refusal: anything else may have stopped part-way.
3492
+ refused: (e) => e instanceof ControlPlaneError && e.status === 409,
3493
+ record: (row) => c.var.admin.recordOwnerTransfer(actor, row.phase === 'applied'
3494
+ ? { ...base, phase: 'applied', outcome: row.result.outcome, fromRevoked: row.result.fromRevoked }
3495
+ : { ...base, ...row }),
3496
+ });
3497
+ // A missing outcome row is said in `auditWarning` on a success, so nobody retries a hand-over
3498
+ // believing it failed, under the operation whose intent row stands alone.
3499
+ return auditedAnswer(c, done, 'the hand-over', (moved, audit) => c.json({ ...audit, ...moved }));
3398
3500
  });
3399
3501
  // Deliver per-instance CONFIG to the scope's own storage (vertical-auth-detach.md
3400
3502
  // §2.2) — the missing "delivery" step behind the dashboard's Env tab. Same K-3
@@ -6224,6 +6326,9 @@ export function createControlPlaneApi(options) {
6224
6326
  // `emailSender` grant itself is never touched by a push. Refreshes on every re-push
6225
6327
  // like the rest of the install spec, exactly because it grants nothing.
6226
6328
  ...(manifest.sendsEmail ? { sendsEmail: true } : {}),
6329
+ // Where the scope lifecycle is held (#1713): `'router'` keeps the vertical's scopes out of
6330
+ // the lifecycle delivery. Refreshes on every re-push like the rest of the install spec.
6331
+ ...(manifest.lifecycle ? { lifecycle: manifest.lifecycle } : {}),
6227
6332
  // The declared surfaces (K-26) ride like envSpec: registry metadata for the
6228
6333
  // hostname-binding picker, never behavior. Not part of any admission digest.
6229
6334
  ...(manifest.surfaces ? { surfaces: manifest.surfaces } : {}),
@@ -7667,6 +7772,56 @@ export function createControlPlaneApi(options) {
7667
7772
  }
7668
7773
  return c.json(await c.var.admin.peerGrantsStatus(actor, { tenantId, scopeId }));
7669
7774
  });
7775
+ // #1720: a caller's explicit target is a tenant decision. The dashboard checks its own
7776
+ // app-management permission; this route confines the tenant and both directory endpoints.
7777
+ const peerBindingBody = z.object({ vertical: verticalSlug, targetScopeId: scopeIdSchema.nullable() }).strict();
7778
+ app.get('/tenants/:tenantId/scopes/:scopeId/peer-bindings', async (c) => {
7779
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
7780
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
7781
+ const vertical = c.req.query('vertical');
7782
+ const pin = confinedTenant(c.get('principal'));
7783
+ if (pin !== null && pin !== tenantId)
7784
+ return c.json({ error: 'forbidden' }, 403);
7785
+ const actor = c.get('actor');
7786
+ if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId)))
7787
+ return c.json({ error: 'app not found' }, 404);
7788
+ return c.json(vertical === undefined
7789
+ ? await c.var.admin.peerBindings(actor, tenantId, scopeId)
7790
+ : await c.var.admin.peerBinding(actor, tenantId, scopeId, verticalSlug.parse(vertical)) ?? null);
7791
+ });
7792
+ app.put('/tenants/:tenantId/scopes/:scopeId/peer-bindings', async (c) => {
7793
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
7794
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
7795
+ const pin = confinedTenant(c.get('principal'));
7796
+ // The target choice is the tenant's alone. Staff may inspect it through GET, but cannot
7797
+ // choose a tenant's site on its behalf.
7798
+ if (pin !== tenantId)
7799
+ return c.json({ error: 'forbidden' }, 403);
7800
+ const body = peerBindingBody.parse(await c.req.json());
7801
+ const actor = c.get('actor');
7802
+ if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId)))
7803
+ return c.json({ error: 'app not found' }, 404);
7804
+ const operationId = ulid();
7805
+ const base = { tenantId, scopeId, operationId, vertical: body.vertical, targetScopeId: body.targetScopeId };
7806
+ const done = await auditedChange({
7807
+ flow: 'peer-binding', operationId,
7808
+ run: async () => {
7809
+ try {
7810
+ return await c.var.admin.setPeerBinding(actor, tenantId, scopeId, body.vertical, body.targetScopeId);
7811
+ }
7812
+ catch (e) {
7813
+ if (errorCodeOf(e) === 'not_found')
7814
+ throw new ControlPlaneError(404, e instanceof Error ? e.message : String(e));
7815
+ throw e;
7816
+ }
7817
+ },
7818
+ refused: (e) => e instanceof ControlPlaneError && e.status < 500,
7819
+ record: (row) => c.var.admin.recordPeerBindingChange(actor, row.phase === 'applied'
7820
+ ? { ...base, phase: 'applied', changed: row.result.changed, previousScopeId: row.result.previous }
7821
+ : { ...base, ...row }),
7822
+ });
7823
+ return auditedAnswer(c, done, 'the peer binding', (result, audit) => c.json({ ...result, ...audit }));
7824
+ });
7670
7825
  // Orgs: the portal-customer grouping (§4.1). Creating one mints no permission —
7671
7826
  // members reach what the org was GRANTED, and granting stays off this surface.
7672
7827
  app.post('/tenants/:tenantId/orgs', async (c) => {
@@ -7693,9 +7848,12 @@ export function createControlPlaneApi(options) {
7693
7848
  order: c.req.query('order'),
7694
7849
  });
7695
7850
  const entries = await c.var.admin.auditLog(c.get('actor'), filter);
7851
+ // #2064: each audited change's row says the outcome its operation stands at — the latest
7852
+ // outcome row wins — beside the raw row, which is returned as written.
7853
+ const resolved = await withAuditedOutcomes(c.var.admin, c.get('actor'), entries);
7696
7854
  // The cursor IS the last entry's id (ULID order is chronological), so the
7697
7855
  // page carries its own continuation and the console never assembles one.
7698
- return c.json(pageOf(entries, filter.limit, (e) => e.id));
7856
+ return c.json(pageOf(resolved, filter.limit, (e) => e.id));
7699
7857
  });
7700
7858
  // The recorded operational failures (#559) — the console's failures view, and the
7701
7859
  // "what does this `reference = <id>` belong to" lookup. Newest first by default,