@cotal-ai/auth 0.53.0 → 0.55.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 (49) hide show
  1. package/dist/auth-admin.d.ts +12 -1
  2. package/dist/auth-admin.d.ts.map +1 -1
  3. package/dist/auth-admin.js +25 -19
  4. package/dist/auth-admin.js.map +1 -1
  5. package/dist/callout.d.ts +1 -1
  6. package/dist/callout.d.ts.map +1 -1
  7. package/dist/callout.js +1 -1
  8. package/dist/callout.js.map +1 -1
  9. package/dist/commands.js +19 -4
  10. package/dist/commands.js.map +1 -1
  11. package/dist/idp.d.ts +5 -4
  12. package/dist/idp.d.ts.map +1 -1
  13. package/dist/idp.js +3 -2
  14. package/dist/idp.js.map +1 -1
  15. package/dist/index.d.ts +2 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/issuer.d.ts +2 -0
  20. package/dist/issuer.d.ts.map +1 -1
  21. package/dist/issuer.js +8 -2
  22. package/dist/issuer.js.map +1 -1
  23. package/dist/ledger-scanner.d.ts +2 -0
  24. package/dist/ledger-scanner.d.ts.map +1 -1
  25. package/dist/ledger-scanner.js +1 -0
  26. package/dist/ledger-scanner.js.map +1 -1
  27. package/dist/ledger.d.ts.map +1 -1
  28. package/dist/ledger.js +6 -3
  29. package/dist/ledger.js.map +1 -1
  30. package/dist/managed-agent-enrollment.d.ts +43 -0
  31. package/dist/managed-agent-enrollment.d.ts.map +1 -0
  32. package/dist/managed-agent-enrollment.js +67 -0
  33. package/dist/managed-agent-enrollment.js.map +1 -0
  34. package/dist/permissions.d.ts +1 -0
  35. package/dist/permissions.d.ts.map +1 -1
  36. package/dist/permissions.js +8 -14
  37. package/dist/permissions.js.map +1 -1
  38. package/dist/provider.d.ts.map +1 -1
  39. package/dist/provider.js +61 -7
  40. package/dist/provider.js.map +1 -1
  41. package/dist/service.d.ts +28 -0
  42. package/dist/service.d.ts.map +1 -1
  43. package/dist/service.js +314 -17
  44. package/dist/service.js.map +1 -1
  45. package/dist/token.d.ts +11 -8
  46. package/dist/token.d.ts.map +1 -1
  47. package/dist/token.js +19 -8
  48. package/dist/token.js.map +1 -1
  49. package/package.json +3 -3
package/dist/service.js CHANGED
@@ -31,9 +31,9 @@
31
31
  * the public face is the credential itself: the human arm presents an EdDSA IdP JWT verified
32
32
  * against the pinned JWKS/issuer/audience; the agent arm presents an actorToken whose sha256 must
33
33
  * match a FRESH ledger row. Origin rejection, JSON-only bodies, the 64 KB bound, and no-CORS-ever
34
- * hold verbatim; `view` requests other than `channel-writer` and `channel-purger` are REFUSED
35
- * (those two stay ledger-gated on `admin`; every other operator surface stays loopback-only); a
36
- * managed-agent secret exchange never mints a view on either face;
34
+ * hold verbatim. Public views are channel-writer/channel-purger (admin-gated) and manager-caller
35
+ * (instance-bound, with no added command scope). Other views stay loopback-only. The agent-secret
36
+ * arm may request only manager-caller on either face;
37
37
  * failures are bucketed per peer (`--exchange-trusted-proxy` opts into the last X-Forwarded-For
38
38
  * hop as the peer key; otherwise the socket remote address) in a bounded LRU, under a global
39
39
  * concurrent-admission cap and a hard request deadline — all of it pools SEPARATE from the
@@ -57,8 +57,8 @@ import { createServer } from "node:http";
57
57
  import { connect, credsAuthenticator } from "@nats-io/transport-node";
58
58
  import { jetstreamManager } from "@nats-io/jetstream";
59
59
  import { Kvm } from "@nats-io/kv";
60
- import { admissionMediatorGrants, assertDerivedOwnerToken, assertLifecycleToken, assertValidOwnerToken, authorizeTrustedServeSnapshot, commitSiblingIssuance, EpEnvelopeError, ensureAuthorityStores, epAuthBucket, isReachable, mintCreds, mintPublicUserJwt, newIdentity, rawDigest, reconcileEndpointGate, recordsBucket, remoteManagerActors, retirementFrontierStreams, serveIssuanceGateKv, standaloneConnectOpts, STANDING_RENEWABLE_TTL_SEC } from "@cotal-ai/core";
61
- import { findCotalRoot, userAuthStateDir, workspaceSecretStore } from "@cotal-ai/workspace";
60
+ import { admissionMediatorGrants, assertDerivedOwnerToken, assertLifecycleToken, assertValidOwnerToken, authorizeTrustedServeSnapshot, commitSiblingIssuance, EpEnvelopeError, ensureAuthorityStores, epAuthBucket, isReachable, managedRetirementOpId, mintCreds, mintPublicUserJwt, newIdentity, parseEndpointGate, parseServiceSpec, parseServiceStatus, rawDigest, readSvcRecordLeader, reconcileEndpointGate, recordSpecKey, recordStatusKey, RECORD_KINDS, recordsBucket, remoteManagerActors, retirementFrontierStreams, serveIssuanceGateKv, standaloneConnectOpts, STANDING_RENEWABLE_TTL_SEC } from "@cotal-ai/core";
61
+ import { findCotalRoot, loadManagerInstanceIdentity, userAuthStateDir, workspaceSecretStore } from "@cotal-ai/workspace";
62
62
  import { decodeJwt } from "jose";
63
63
  import { deriveOwnerForIdpSubject } from "./derive.js";
64
64
  import { startAuthCallout } from "./callout.js";
@@ -68,6 +68,7 @@ import { pinnedJwksResolver } from "./issuer.js";
68
68
  import { calloutPermissions } from "./permissions.js";
69
69
  import { issueRemoteManagerAuthority } from "./manager-authority.js";
70
70
  import { authorizeRemoteRetainedAgentValidation, completeRemoteRetainedAgentValidation, remoteManagerCurrentRegistrationProof } from "./retained-manager-validation.js";
71
+ import { authorizeRemoteManagedAgentEnrollment, authorizeRemoteManagedAgentPrepareRetirement } from "./managed-agent-enrollment.js";
71
72
  import { authorizeRemoteManagerGoalIndexScan, completeRemoteManagerGoalIndexScan } from "./manager-goal-index.js";
72
73
  import { authorizeRemoteManagerAdmin } from "./manager-admin-authorization.js";
73
74
  import { authorizeRemoteManagerMaintenance, completeRemoteManagerMaintenance } from "./manager-maintenance.js";
@@ -85,15 +86,22 @@ import { makeDeliveryAdminEvictor } from "./barrier-evict.js";
85
86
  import { resumeAgentRetirement, runAgentRetirementBarrier } from "./retirement-barrier.js";
86
87
  import { makeRetirementCleaners } from "./retirement-cleaner.js";
87
88
  import { makeDrainRepairers } from "./drain-repair.js";
88
- import { openAuthAdminListener } from "./auth-admin.js";
89
+ import { joinOrStartRetirement, openAuthAdminListener } from "./auth-admin.js";
89
90
  import { drainTargetForEndpoint, openAdmissionMediator } from "./admission-mediator.js";
90
- import { AGENT_BEARER_TTL_SEC, findInteractiveActor, ledgerAclResolver, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, } from "./ledger.js";
91
+ import { AGENT_BEARER_TTL_SEC, findInteractiveActor, findManagedActor, ledgerAclResolver, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, } from "./ledger.js";
91
92
  import { AUTH_PROVIDER_NAME, clearAuthServiceInfo, loadCalloutAuth, loadIssuer, loadOwnerSecret, loadPinnedIdp, loadServiceKeys, saveAuthServiceInfo, spaceIssuer, } from "./store.js";
92
93
  /** JWKS max-age seconds — the cache contract's knob. Exported so a rotation tool can compute the
93
94
  * retire floor (max-age + max bearer TTL) from it. */
94
95
  export const JWKS_MAX_AGE_SEC = 300;
95
96
  /** Loopback-only operator route used by `cotal actor grant/revoke` before rotating or deleting an interactive lifecycle. */
96
97
  export const INTERACTIVE_RETIRE_PATH = "/interactive-lifecycle/retire";
98
+ /** Loopback-only host route that finishes a managed lifecycle's terminal retirement after the host
99
+ * revoked its grant, when the remote manager that should have requested it is gone (#2070). */
100
+ export const MANAGED_RETIRE_PATH = "/managed-lifecycle/retire";
101
+ /** Loopback-only host route a platform calls BEFORE it enrolls or releases a managed agent for a
102
+ * remote manager (#1972 §2.2). The platform owns the writers; this door owns the decision. It
103
+ * derives the caller's scope from the local ledger and never accepts one from the caller. */
104
+ export const VERIFY_ENROLLMENT_PATH = "/manager-service-authority/verify-enrollment";
97
105
  /** Failed-exchange rate limit: at most this many REFUSED exchanges per rolling minute; further
98
106
  * attempts get 429 until the window drains. Successes are unthrottled (the CLI's normal path). */
99
107
  const FAILED_EXCHANGE_PER_MIN = 30;
@@ -307,6 +315,9 @@ export async function openAuthAuthorityPlane(opts) {
307
315
  await writer.close();
308
316
  throw e;
309
317
  }
318
+ // ONE in-flight map for the rail and the loopback managed-retire door: both run the same
319
+ // `managedRetirementOpId(uid)` operation, and a process must never execute it twice at once.
320
+ const retirementFlights = new Map();
310
321
  // The AUTH CONTROL RAIL (#29 piece 3, SPEC 13.2 CONTROL_AUTH_ADMIN): serve the generic
311
322
  // "retire a lifecycle" op over a dedicated minimal listener credential. Every executing right
312
323
  // stays with the plane's own registry + retirement deps (the drain rides the ONE sealed records
@@ -314,7 +325,7 @@ export async function openAuthAuthorityPlane(opts) {
314
325
  // the FRESH space-manager-lease holder check) and dispatches.
315
326
  let authAdmin;
316
327
  try {
317
- authAdmin = await openAuthAdminListener({ server, space, dataAccount, reg: barrierReg, retirement, log });
328
+ authAdmin = await openAuthAdminListener({ server, space, dataAccount, reg: barrierReg, retirement, barrierFlight: retirementFlights, log });
318
329
  }
319
330
  catch (e) {
320
331
  closing = true;
@@ -328,6 +339,76 @@ export async function openAuthAuthorityPlane(opts) {
328
339
  throw e;
329
340
  }
330
341
  const fileArm = ledgerAuthorizeConnect(opts.dir);
342
+ const recordsJsm = await jetstreamManager(remoteIssuer.nc);
343
+ const root = findCotalRoot();
344
+ const managerGate = async (owner, instanceId) => {
345
+ const localManager = loadManagerInstanceIdentity(root, space);
346
+ const gate = await serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId }).observe();
347
+ if (!gate)
348
+ return "unknown";
349
+ if (gate.state !== "open")
350
+ return "not open";
351
+ const remotePrincipal = `${owner}.${remoteManagerActors(instanceId).serve}`;
352
+ const local = localManager?.instanceId === instanceId && gate.principal === `local.${localManager.serveIdentity.id}`;
353
+ const remote = gate.principal === remotePrincipal;
354
+ if (!local && !remote)
355
+ return "not this owner's";
356
+ const rec = await readSvcRecordLeader(recordsJsm, space, recordSpecKey(RECORD_KINDS.svc, ["manager", instanceId]));
357
+ if (!rec || "deleted" in rec)
358
+ return "not registered";
359
+ const spec = parseServiceSpec(rec.value, { endpoint: "manager" });
360
+ if (spec.owner !== (local ? "local" : owner) || rec.revision !== gate.registrationRevision)
361
+ return "not registered";
362
+ const statusRec = await readSvcRecordLeader(recordsJsm, space, recordStatusKey(RECORD_KINDS.svc, ["manager", instanceId]));
363
+ if (!statusRec || "deleted" in statusRec)
364
+ return "not registered";
365
+ const status = parseServiceStatus(statusRec.value);
366
+ if (status.observedSpecRevision < rec.revision || status.epoch !== gate.processEpoch)
367
+ return "not registered";
368
+ return "candidate";
369
+ };
370
+ const selectManagerInstance = async (owner, requested) => {
371
+ refuseIfFenced();
372
+ if (requested !== undefined) {
373
+ const instanceId = assertLifecycleToken(requested, "managerInstanceId");
374
+ const verdict = await managerGate(owner, instanceId);
375
+ if (verdict !== "candidate")
376
+ throw new Error(`manager instance ${instanceId} is ${verdict}`);
377
+ return instanceId;
378
+ }
379
+ const remoteInstances = [];
380
+ const remoteLive = [];
381
+ let localCandidate;
382
+ const localManager = loadManagerInstanceIdentity(root, space);
383
+ for (const raw of await scanner.scanManagerGates()) {
384
+ if (raw.op !== undefined)
385
+ continue;
386
+ const parts = raw.key.split(".");
387
+ if (parts.length !== 3 || parts[0] !== "epgate" || parts[1] !== "manager")
388
+ throw new EpEnvelopeError("internal", `the manager gate scan returned the malformed key ${raw.key}`);
389
+ const instanceId = assertLifecycleToken(parts[2], "managerInstanceId");
390
+ const row = parseEndpointGate(raw.data, raw.key);
391
+ if (row.principal === `${owner}.${remoteManagerActors(instanceId).serve}`) {
392
+ remoteInstances.push(instanceId);
393
+ if (await managerGate(owner, instanceId) === "candidate")
394
+ remoteLive.push(instanceId);
395
+ }
396
+ else if (localManager?.instanceId === instanceId && row.principal === `local.${localManager.serveIdentity.id}`) {
397
+ if (await managerGate(owner, instanceId) === "candidate")
398
+ localCandidate = instanceId;
399
+ }
400
+ }
401
+ remoteLive.sort();
402
+ if (remoteLive.length === 1)
403
+ return remoteLive[0];
404
+ if (remoteLive.length > 1)
405
+ throw new Error(`manager-caller selection found ${remoteLive.length} live remote manager candidates for owner ${owner}; exactly one is required or name --manager-instance`);
406
+ if (remoteInstances.length > 0)
407
+ throw new Error(`the remote manager for owner ${owner} is unavailable (${remoteInstances.length} gate${remoteInstances.length === 1 ? "" : "s"}, none live); pass managerInstanceId to select an instance explicitly rather than falling through to the local manager`);
408
+ if (localCandidate !== undefined)
409
+ return localCandidate;
410
+ throw new Error(`manager-caller selection found no manager candidate for owner ${owner}`);
411
+ };
331
412
  // Every authority operation refuses once the plane is fenced: a half-dead plane keeps NO face up.
332
413
  // AUDIENCE SPLIT (ux): these refusals reach a CONNECTING AGENT during the brief fence→exit
333
414
  // window, not the operator — the agent cannot "restart the auth service", so it gets a retryable
@@ -346,6 +427,13 @@ export async function openAuthAuthorityPlane(opts) {
346
427
  refuseIfFenced();
347
428
  return ensureRootCredential(registry, { ...args, managerInstance: `auth-service:${space}` });
348
429
  },
430
+ selectManagerInstance,
431
+ authorizeManagerCaller: async (owner, instanceId) => {
432
+ refuseIfFenced();
433
+ const verdict = await managerGate(owner, assertLifecycleToken(instanceId, "managerInstanceId"));
434
+ if (verdict !== "candidate")
435
+ throw new Error(`manager instance ${instanceId} is ${verdict} at the permission mint`);
436
+ },
349
437
  retireInteractiveLifecycle: async (args) => {
350
438
  refuseIfFenced();
351
439
  const owner = assertDerivedOwnerToken(args.owner);
@@ -374,6 +462,36 @@ export async function openAuthAuthorityPlane(opts) {
374
462
  }, retirement);
375
463
  return { retired: true, lifecycleUid };
376
464
  },
465
+ retireManagedLifecycle: async (args) => {
466
+ refuseIfFenced();
467
+ const owner = assertDerivedOwnerToken(args.owner);
468
+ const actor = assertValidOwnerToken(args.actor);
469
+ const lifecycleUid = assertLifecycleToken(args.lifecycleUid);
470
+ // The `prepareAgentRetirement` contract: the host revokes the managed grant FIRST. Only a
471
+ // grant still live AT THIS uid blocks; a row at another uid is a successor the head decides.
472
+ if (findManagedActor(opts.dir, owner, actor)?.lifecycleUid === lifecycleUid)
473
+ throw new EpEnvelopeError("conflict", `managed actor "${owner}/${actor}" is still granted at lifecycle ${lifecycleUid}; revoke the grant before retiring it`);
474
+ const head = await readLifecycleHeadForOperation(barrierReg, owner, actor);
475
+ if (head === undefined)
476
+ return { retired: false, lifecycleUid, notStarted: true };
477
+ if (head.mapping.lifecycleUid !== lifecycleUid) {
478
+ if (head.mapping.state === "retired")
479
+ return { retired: false, lifecycleUid, notStarted: true };
480
+ throw new EpEnvelopeError("conflict", `managed lifecycle retirement for "${owner}/${actor}" names ${lifecycleUid}, but the authority head is ${head.mapping.state} at ${head.mapping.lifecycleUid}`);
481
+ }
482
+ if (head.mapping.state === "retired")
483
+ return { retired: true, lifecycleUid, alreadyRetired: true };
484
+ // The rail's opId, not the interactive digest: a host door call and a late participant rail call
485
+ // create or resume ONE barrier operation and join ONE in-process flight.
486
+ const opId = managedRetirementOpId(lifecycleUid);
487
+ const flight = joinOrStartRetirement(retirementFlights, barrierReg, {
488
+ owner, actor, lifecycleUid, opId, frontierStreams: retirementFrontierStreams(space),
489
+ }, retirement);
490
+ if (flight === undefined)
491
+ throw new EpEnvelopeError("conflict", `managed retirement operation ${opId} is already in flight for different coordinates`);
492
+ await flight;
493
+ return { retired: true, lifecycleUid };
494
+ },
377
495
  issueManagerServiceAuthority: async ({ owner, scope, request }) => {
378
496
  refuseIfFenced();
379
497
  return issueRemoteManagerAuthority({
@@ -569,6 +687,34 @@ export async function openAuthAuthorityPlane(opts) {
569
687
  },
570
688
  });
571
689
  },
690
+ verifyManagedAgentEnrollment: async ({ owner, scope, request }) => {
691
+ refuseIfFenced();
692
+ return authorizeRemoteManagedAgentEnrollment({
693
+ owner,
694
+ scope,
695
+ proofSecret: dataAccount.signingSeed,
696
+ space,
697
+ request,
698
+ observeManagerGate: async (instanceId) => {
699
+ const gate = serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId });
700
+ return gate.observe();
701
+ },
702
+ });
703
+ },
704
+ verifyManagedAgentPrepareRetirement: async ({ owner, scope, request }) => {
705
+ refuseIfFenced();
706
+ return authorizeRemoteManagedAgentPrepareRetirement({
707
+ owner,
708
+ scope,
709
+ proofSecret: dataAccount.signingSeed,
710
+ space,
711
+ request,
712
+ observeManagerGate: async (instanceId) => {
713
+ const gate = serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId });
714
+ return gate.observe();
715
+ },
716
+ });
717
+ },
572
718
  scanManagerGoalIndex: async ({ owner, scope, request }) => {
573
719
  refuseIfFenced();
574
720
  const authorized = await authorizeRemoteManagerGoalIndexScan({
@@ -789,7 +935,7 @@ export async function runAuthService(args, store) {
789
935
  space,
790
936
  token: { key: issuer.localKeySet(), issuer: issuer.issuer },
791
937
  authorizeActor: plane.authorizeConnect,
792
- permissionsFor: calloutPermissions(ledgerAclResolver(dir)),
938
+ permissionsFor: calloutPermissions(ledgerAclResolver(dir), plane.authorizeManagerCaller),
793
939
  log: (l) => console.error(l),
794
940
  });
795
941
  // The subscription must be ON the broker before readiness is signaled — an `up` that recorded a
@@ -816,16 +962,20 @@ export async function runAuthService(args, store) {
816
962
  managerServiceAuthority: plane.issueManagerServiceAuthority,
817
963
  maintainRemoteManager: plane.maintainRemoteManager,
818
964
  validateRetainedAgent: plane.validateRetainedAgent,
965
+ verifyManagedAgentEnrollment: plane.verifyManagedAgentEnrollment,
966
+ verifyManagedAgentPrepareRetirement: plane.verifyManagedAgentPrepareRetirement,
819
967
  scanManagerGoalIndex: plane.scanManagerGoalIndex,
820
968
  authorizeManagerAdmin: plane.authorizeManagerAdmin,
821
969
  secrets,
822
970
  retireInteractiveLifecycle: plane.retireInteractiveLifecycle,
971
+ retireManagedLifecycle: plane.retireManagedLifecycle,
823
972
  cap,
824
973
  failures,
825
974
  badCaps,
826
975
  space,
827
976
  dir,
828
977
  mintConnectCredential: plane.mintConnectCredential,
978
+ selectManagerInstance: plane.selectManagerInstance,
829
979
  };
830
980
  const http = createServer((req, res) => void handle(req, res, ctx));
831
981
  await new Promise((resolvePort, reject) => {
@@ -906,6 +1056,13 @@ export async function dispatchManagerAuthorityRequest(ctx, owner, body) {
906
1056
  if (typeof request.actor !== "string")
907
1057
  throw new Error("manager-service authority request requires an actor");
908
1058
  const row = ledgerAuthorizeGrant(ctx.dir)(owner, request.actor);
1059
+ // #1972: the managed-agent lifecycle operations mutate host storage (a database row, JetStream
1060
+ // durables, a ledger grant), and stock holds none of that composition. A host platform intercepts
1061
+ // these kinds on its own public route and calls the loopback verify-enrollment door for the
1062
+ // decision. Refusing here keeps them from falling through to `managerServiceAuthority`, which
1063
+ // would answer a manager-lifecycle phase for an agent-lifecycle request.
1064
+ if (request.kind === "manager-managed-agent-enrollment" || request.kind === "manager-managed-agent-prepare-retirement")
1065
+ throw new EpEnvelopeError("unimplemented", "managed agent enrollment and retirement preparation must be handled by host platform interception");
909
1066
  if (request.kind === "manager-retained-agent-validation") {
910
1067
  const retained = await ctx.validateRetainedAgent({
911
1068
  owner,
@@ -1029,13 +1186,15 @@ const ROUTES = new Map([
1029
1186
  ["/exchange", (req, res, ctx) => handleExchange(req, res, ctx, LOOPBACK_POLICY)],
1030
1187
  ["/manager-service-authority", (req, res, ctx) => handleManagerServiceAuthority(req, res, ctx, LOOPBACK_POLICY)],
1031
1188
  [INTERACTIVE_RETIRE_PATH, handleInteractiveLifecycleRetirement],
1189
+ [MANAGED_RETIRE_PATH, handleManagedLifecycleRetirement],
1190
+ [VERIFY_ENROLLMENT_PATH, handleVerifyManagedAgentEnrollment],
1032
1191
  ]);
1033
1192
  /** Route one HTTP request against the loopback route table. Errors are JSON `{ error }`. */
1034
1193
  async function handle(req, res, ctx) {
1035
1194
  try {
1036
1195
  const route = ROUTES.get(req.url ?? "");
1037
1196
  if (!route)
1038
- return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /interactive-lifecycle/retire" });
1197
+ return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /manager-service-authority/verify-enrollment, /interactive-lifecycle/retire, /managed-lifecycle/retire" });
1039
1198
  await route(req, res, ctx);
1040
1199
  }
1041
1200
  catch (e) {
@@ -1072,6 +1231,128 @@ async function handleInteractiveLifecycleRetirement(req, res, ctx) {
1072
1231
  const result = await ctx.retireInteractiveLifecycle({ owner, actor, lifecycleUid });
1073
1232
  return send(res, 200, result);
1074
1233
  }
1234
+ /** The host's terminal step for a managed lifecycle whose remote manager is gone: the same request
1235
+ * guards as the interactive door, but it requires the managed grant to be REVOKED first (the
1236
+ * `prepareAgentRetirement` contract) and runs the rail's `managedRetirementOpId(uid)` operation. */
1237
+ async function handleManagedLifecycleRetirement(req, res, ctx) {
1238
+ if (req.method !== "POST")
1239
+ return send(res, 405, { error: "POST only" });
1240
+ if (req.headers.origin !== undefined)
1241
+ return send(res, 403, { error: "browser-origin requests are not served here" });
1242
+ if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
1243
+ return send(res, 415, { error: "content-type must be application/json" });
1244
+ if (req.headers.authorization !== `Bearer ${ctx.cap}`)
1245
+ return send(res, 401, { error: "missing/invalid exchange capability - managed lifecycle retirement is a loopback host action" });
1246
+ const body = await readJsonBody(req);
1247
+ if (body === null || typeof body !== "object" || Array.isArray(body))
1248
+ return send(res, 400, { error: "managed lifecycle retirement needs { owner, actor, lifecycleUid }" });
1249
+ for (const key of Object.keys(body))
1250
+ if (!INTERACTIVE_RETIRE_KEYS.has(key))
1251
+ return send(res, 400, { error: `managed lifecycle retirement carries the unknown field "${key}"` });
1252
+ const raw = body;
1253
+ if (typeof raw.owner !== "string" || typeof raw.actor !== "string" || typeof raw.lifecycleUid !== "string")
1254
+ return send(res, 400, { error: "managed lifecycle retirement needs string { owner, actor, lifecycleUid }" });
1255
+ const owner = assertDerivedOwnerToken(raw.owner);
1256
+ const actor = assertValidOwnerToken(raw.actor);
1257
+ const lifecycleUid = assertLifecycleToken(raw.lifecycleUid);
1258
+ try {
1259
+ return send(res, 200, await ctx.retireManagedLifecycle({ owner, actor, lifecycleUid }));
1260
+ }
1261
+ catch (e) {
1262
+ if (e instanceof EpEnvelopeError && e.code === "conflict")
1263
+ return send(res, 409, { error: e.message });
1264
+ throw e;
1265
+ }
1266
+ }
1267
+ /** The envelope-error to HTTP mapping this door answers with. A code the catalog grows but this
1268
+ * table does not name becomes 403 rather than a 500: a refusal is a refusal, and a door that
1269
+ * answered 500 to a new permission code would read as a host fault to the platform calling it. */
1270
+ const VERIFY_ENROLLMENT_STATUS = {
1271
+ "unauthenticated": 401,
1272
+ "permission-denied": 403,
1273
+ "conflict": 409,
1274
+ "failed-precondition": 412,
1275
+ "bad-request": 400,
1276
+ };
1277
+ const VERIFY_ENROLLMENT_KEYS = new Set(["owner", "request"]);
1278
+ /**
1279
+ * #1972 §2.2 item 3: the host platform's decision door for a remote manager's managed-agent
1280
+ * enrollment or terminal-release preparation.
1281
+ *
1282
+ * The platform that owns the database, the durables, and the ledger terminates its own public
1283
+ * route, authenticates the human there, and POSTs the resulting owner token plus the participant's
1284
+ * verbatim request here. This process answers because it is the only one holding the manager gate
1285
+ * connection and the data-account signing seed: the proof check and the gate read happen inside it,
1286
+ * and no secret crosses back.
1287
+ *
1288
+ * The caller supplies an OWNER and a REQUEST, and nothing else. The caller's scope is derived from
1289
+ * this machine's ledger (`ledgerAuthorizeGrant`), never accepted from the body — a platform bug that
1290
+ * forwarded a participant-supplied scope array could otherwise hand `supervise` to any caller.
1291
+ */
1292
+ async function handleVerifyManagedAgentEnrollment(req, res, ctx) {
1293
+ if (req.method !== "POST")
1294
+ return send(res, 405, { error: "POST only" });
1295
+ if (req.headers.origin !== undefined)
1296
+ return send(res, 403, { error: "browser-origin requests are not served here" });
1297
+ if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
1298
+ return send(res, 415, { error: "content-type must be application/json" });
1299
+ if (req.headers.authorization !== `Bearer ${ctx.cap}`)
1300
+ return send(res, 401, { error: "missing/invalid exchange capability - managed agent enrollment verification is a loopback host action" });
1301
+ const body = await readJsonBody(req);
1302
+ if (body === null || typeof body !== "object" || Array.isArray(body))
1303
+ return send(res, 400, { error: "managed agent enrollment verification needs { owner, request }" });
1304
+ for (const key of Object.keys(body))
1305
+ if (!VERIFY_ENROLLMENT_KEYS.has(key))
1306
+ return send(res, 400, { error: `managed agent enrollment verification carries the unknown field "${key}"` });
1307
+ const raw = body;
1308
+ if (typeof raw.owner !== "string" || raw.request === null || typeof raw.request !== "object" || Array.isArray(raw.request))
1309
+ return send(res, 400, { error: "managed agent enrollment verification needs a string owner and an object request" });
1310
+ const request = raw.request;
1311
+ if (request.kind !== "manager-managed-agent-enrollment" && request.kind !== "manager-managed-agent-prepare-retirement")
1312
+ return send(res, 400, { error: 'managed agent enrollment verification serves only kind "manager-managed-agent-enrollment" or "manager-managed-agent-prepare-retirement"' });
1313
+ if (typeof request.actor !== "string")
1314
+ return send(res, 400, { error: "managed agent enrollment verification request requires an actor" });
1315
+ try {
1316
+ const owner = assertDerivedOwnerToken(raw.owner);
1317
+ // The SCOPE DERIVATION, internal by construction: the interactive row for this exact
1318
+ // (owner, actor) on this machine's ledger decides, so `supervise` cannot be asserted by the
1319
+ // caller. An ungranted actor throws the ledger's own operator-exact sentence.
1320
+ const row = ledgerAuthorizeGrant(ctx.dir)(owner, request.actor);
1321
+ const scope = row.scope ?? [];
1322
+ if (request.kind === "manager-managed-agent-enrollment") {
1323
+ const verified = await ctx.verifyManagedAgentEnrollment({
1324
+ owner,
1325
+ scope,
1326
+ request: raw.request,
1327
+ });
1328
+ return send(res, 200, {
1329
+ authorized: true,
1330
+ owner,
1331
+ actor: verified.target.actor,
1332
+ instanceId: verified.instanceId,
1333
+ serveEpoch: verified.serveEpoch,
1334
+ });
1335
+ }
1336
+ const verified = await ctx.verifyManagedAgentPrepareRetirement({
1337
+ owner,
1338
+ scope,
1339
+ request: raw.request,
1340
+ });
1341
+ return send(res, 200, {
1342
+ authorized: true,
1343
+ owner,
1344
+ actor: verified.target.actor,
1345
+ instanceId: verified.instanceId,
1346
+ serveEpoch: verified.serveEpoch,
1347
+ });
1348
+ }
1349
+ catch (e) {
1350
+ const reason = e instanceof Error ? e.message : String(e);
1351
+ console.error(`auth-service: refused managed agent enrollment verification: ${reason}`);
1352
+ const status = e instanceof EpEnvelopeError ? (VERIFY_ENROLLMENT_STATUS[e.code] ?? 403) : 403;
1353
+ return send(res, status, { error: reason });
1354
+ }
1355
+ }
1075
1356
  /** The exchange body, shared by every face; `policy` says how this face proves and attributes the
1076
1357
  * caller. Behavior on the loopback face is unchanged from the pre-route-table handler. */
1077
1358
  async function handleExchange(req, res, ctx, policy) {
@@ -1115,12 +1396,16 @@ async function handleExchange(req, res, ctx, policy) {
1115
1396
  const peer = policy.peerKey(req);
1116
1397
  const peerThrottled = policy.throttled(ctx, peer);
1117
1398
  const body = await readJsonBody(req);
1118
- const { idpToken, actor, actorToken, owner, ttlSec, view } = body;
1399
+ const { idpToken, actor, actorToken, owner, ttlSec, view, managerInstanceId } = body;
1119
1400
  if (ttlSec !== undefined && typeof ttlSec !== "number")
1120
1401
  return send(res, 400, { error: "ttlSec must be a number" });
1121
1402
  if (view !== undefined && typeof view !== "string")
1122
1403
  return send(res, 400, { error: "view must be a string when present" });
1123
- // Public face: only channel-writer / channel-purger ride this listener (still ledger-gated).
1404
+ if (managerInstanceId !== undefined && typeof managerInstanceId !== "string")
1405
+ return send(res, 400, { error: "managerInstanceId must be a string when present" });
1406
+ if (managerInstanceId !== undefined && view !== "manager-caller")
1407
+ return send(res, 400, { error: 'managerInstanceId is valid only with view "manager-caller"' });
1408
+ // Public face: channel-writer / channel-purger and the narrowing manager-caller view.
1124
1409
  // admin, purger, deployer, and manager-service stay loopback-only, whatever the credential.
1125
1410
  // A refused view is a refused exchange: record, audit, then 429 when the peer is already
1126
1411
  // throttled, same order as the neighbouring denial sites. A served view stays unthrottled.
@@ -1138,9 +1423,9 @@ async function handleExchange(req, res, ctx, policy) {
1138
1423
  if (idpToken !== undefined && actorToken !== undefined)
1139
1424
  return send(res, 400, { error: "exchange takes idpToken (human) OR owner+actorToken (agent), never both" });
1140
1425
  if (actorToken !== undefined) {
1141
- // Elevated views are for signed-in HUMANS only: an agent's secret exchange never mints one,
1142
- // whatever its ledger row carries (v1 — agents hold no god views).
1143
- if (view !== undefined)
1426
+ // The actor-secret exception is manager-caller only: it adds no command capability.
1427
+ // Elevated views still require a signed-in human, whatever this agent's row carries.
1428
+ if (view !== undefined && view !== "manager-caller")
1144
1429
  return send(res, 400, { error: "the managed (agent-secret) exchange never mints elevated views - views ride a signed-in human exchange" });
1145
1430
  if (typeof owner !== "string" || !owner || typeof actor !== "string" || !actor || typeof actorToken !== "string" || !actorToken)
1146
1431
  return send(res, 400, { error: "agent exchange needs { owner: string, actor: string, actorToken: string, ttlSec?: number }" });
@@ -1152,6 +1437,9 @@ async function handleExchange(req, res, ctx, policy) {
1152
1437
  // ensured (minted release-last on first exchange) BEFORE the bearer bytes are signed,
1153
1438
  // and rides act.credentialId — the connect arm requires it against the LIVE cred row.
1154
1439
  const credentialId = await ctx.mintConnectCredential({ owner, actor, lifecycleUid: grant.lifecycleUid });
1440
+ const selectedManagerInstanceId = view === "manager-caller"
1441
+ ? await ctx.selectManagerInstance(owner, managerInstanceId)
1442
+ : undefined;
1155
1443
  const token = await ctx.issuer.issue({
1156
1444
  owner,
1157
1445
  space: ctx.space,
@@ -1164,10 +1452,11 @@ async function handleExchange(req, res, ctx, policy) {
1164
1452
  // successor's broker authority.
1165
1453
  lifecycleUid: grant.lifecycleUid,
1166
1454
  credentialId,
1455
+ ...(view === "manager-caller" ? { view, managerInstanceId: selectedManagerInstanceId } : {}),
1167
1456
  ttlSec: Math.min(ttlSec ?? AGENT_BEARER_TTL_SEC, AGENT_BEARER_TTL_SEC),
1168
1457
  });
1169
1458
  const { exp } = decodeJwt(token);
1170
- return send(res, 200, { token, owner, exp });
1459
+ return send(res, 200, { token, owner, exp, ...(selectedManagerInstanceId ? { managerInstanceId: selectedManagerInstanceId } : {}) });
1171
1460
  }
1172
1461
  catch (e) {
1173
1462
  policy.recordFailure(ctx, peer);
@@ -1183,7 +1472,15 @@ async function handleExchange(req, res, ctx, policy) {
1183
1472
  try {
1184
1473
  // The bridge validates `view` against the closed enum and the fresh ledger grant — an
1185
1474
  // unknown or under-scoped view is a refused exchange (audited + throttled like any other).
1186
- const r = await ctx.bridge.exchange(idpToken, { actor, ttlSec, view: view });
1475
+ const selectedManagerInstanceId = view === "manager-caller"
1476
+ ? await ctx.selectManagerInstance(deriveOwnerForIdpSubject(ctx.ownerSecret, ctx.bridgeIdp.issuer, (await verifyIdpToken(idpToken, ctx.bridgeIdp)).sub), managerInstanceId)
1477
+ : undefined;
1478
+ const r = await ctx.bridge.exchange(idpToken, {
1479
+ actor,
1480
+ ttlSec,
1481
+ view: view,
1482
+ managerInstanceId: selectedManagerInstanceId,
1483
+ });
1187
1484
  return send(res, 200, r);
1188
1485
  }
1189
1486
  catch (e) {