@cotal-ai/auth 0.90.1 → 0.91.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 (80) hide show
  1. package/dist/authority-client.d.ts.map +1 -1
  2. package/dist/authority-client.js +4 -1
  3. package/dist/authority-client.js.map +1 -1
  4. package/dist/chat-parent-pin.d.ts +43 -0
  5. package/dist/chat-parent-pin.d.ts.map +1 -0
  6. package/dist/chat-parent-pin.js +115 -0
  7. package/dist/chat-parent-pin.js.map +1 -0
  8. package/dist/chat-session-authority.d.ts +120 -0
  9. package/dist/chat-session-authority.d.ts.map +1 -0
  10. package/dist/chat-session-authority.js +924 -0
  11. package/dist/chat-session-authority.js.map +1 -0
  12. package/dist/chat-session-row.d.ts +117 -0
  13. package/dist/chat-session-row.d.ts.map +1 -0
  14. package/dist/chat-session-row.js +358 -0
  15. package/dist/chat-session-row.js.map +1 -0
  16. package/dist/chat-session-store.d.ts +126 -0
  17. package/dist/chat-session-store.d.ts.map +1 -0
  18. package/dist/chat-session-store.js +392 -0
  19. package/dist/chat-session-store.js.map +1 -0
  20. package/dist/commands.d.ts +3 -1
  21. package/dist/commands.d.ts.map +1 -1
  22. package/dist/commands.js +497 -122
  23. package/dist/commands.js.map +1 -1
  24. package/dist/continuity.js +1 -1
  25. package/dist/continuity.js.map +1 -1
  26. package/dist/daemon-flags.d.ts +42 -0
  27. package/dist/daemon-flags.d.ts.map +1 -0
  28. package/dist/daemon-flags.js +12 -0
  29. package/dist/daemon-flags.js.map +1 -0
  30. package/dist/handle-inspect.d.ts +101 -0
  31. package/dist/handle-inspect.d.ts.map +1 -0
  32. package/dist/handle-inspect.js +50 -0
  33. package/dist/handle-inspect.js.map +1 -0
  34. package/dist/handle-verification.d.ts +75 -0
  35. package/dist/handle-verification.d.ts.map +1 -0
  36. package/dist/handle-verification.js +135 -0
  37. package/dist/handle-verification.js.map +1 -0
  38. package/dist/idp.d.ts +23 -1
  39. package/dist/idp.d.ts.map +1 -1
  40. package/dist/idp.js +54 -8
  41. package/dist/idp.js.map +1 -1
  42. package/dist/index.d.ts +3 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +3 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/ledger.d.ts +58 -5
  47. package/dist/ledger.d.ts.map +1 -1
  48. package/dist/ledger.js +126 -6
  49. package/dist/ledger.js.map +1 -1
  50. package/dist/lifecycle-registry.d.ts +1 -70
  51. package/dist/lifecycle-registry.d.ts.map +1 -1
  52. package/dist/lifecycle-registry.js +4 -35
  53. package/dist/lifecycle-registry.js.map +1 -1
  54. package/dist/login.d.ts +9 -0
  55. package/dist/login.d.ts.map +1 -1
  56. package/dist/login.js +53 -8
  57. package/dist/login.js.map +1 -1
  58. package/dist/permissions.js +4 -0
  59. package/dist/permissions.js.map +1 -1
  60. package/dist/provider.d.ts +23 -0
  61. package/dist/provider.d.ts.map +1 -1
  62. package/dist/provider.js +244 -44
  63. package/dist/provider.js.map +1 -1
  64. package/dist/service-adapter.d.ts +6 -0
  65. package/dist/service-adapter.d.ts.map +1 -0
  66. package/dist/service-adapter.js +120 -0
  67. package/dist/service-adapter.js.map +1 -0
  68. package/dist/service.d.ts +61 -2
  69. package/dist/service.d.ts.map +1 -1
  70. package/dist/service.js +385 -79
  71. package/dist/service.js.map +1 -1
  72. package/dist/store.d.ts +10 -0
  73. package/dist/store.d.ts.map +1 -1
  74. package/dist/store.js +25 -8
  75. package/dist/store.js.map +1 -1
  76. package/dist/token.d.ts +1 -1
  77. package/dist/token.d.ts.map +1 -1
  78. package/dist/token.js +4 -1
  79. package/dist/token.js.map +1 -1
  80. package/package.json +11 -7
package/dist/service.js CHANGED
@@ -62,7 +62,7 @@ import { resolve } from "node:path";
62
62
  import { connect, credsAuthenticator } from "@nats-io/transport-node";
63
63
  import { jetstream, jetstreamManager } from "@nats-io/jetstream";
64
64
  import { Kvm } from "@nats-io/kv";
65
- import { CotalEndpoint, deprovisionAgent, isConcreteChannel, provisionAgentDurables, contractDigest, contractRefToHex, contractStoreContext, endpointToken, fetchContractArtifact, verifyClusterManifest, verifyClusterRoot, admissionBucket, admissionMediatorGrants, assertDerivedOwnerToken, assertLifecycleToken, assertValidOwnerToken, authorizeTrustedServeSnapshot, commitSiblingIssuance, credsClaims, EpEnvelopeError, ensureAuthorityStores, epAuthBucket, isReachable, jwtFromCreds, managedRetirementOpId, mintCreds, mintPublicUserJwt, newIdentity, newTakeoverId, observeHostedRunAttempt, openRecordsBucket, parseEndpointGate, parseServiceSpec, parseServiceStatus, rawDigest, parseSecretStoreIdentity, readCheckpointStatus, readRunAdmission, readRunRevocation, readRunRecord, readSvcRecordLeader, reconcileEndpointGate, replayRunJournal, sameSecretStoreIdentity, recordSpecKey, recordStatusKey, RECORD_KINDS, recordsBucket, remoteManagerActors, retirementFrontierStreams, runDriverCaller, serveIssuanceGateKv, standaloneConnectOpts, STANDING_RENEWABLE_TTL_SEC, withIssuerSession, acceptedReadGrant, actorLedgerSource, connectionAcceptedToken, importNativeSubjectPermissions, mintGeneration, parseActorLedgerSource, writeAcceptedRow, invokeCommand, resolveService, contractArtifactCanonicalBytes, DEV_OWNER, endpointRegistrationBarrier, mintLifecycleUid, principalKey, provisionEndpointGateOpen, publishContractArtifact, registerServiceInstance, registerServingInstance, epgateKey, isCasLoss } from "@cotal-ai/core";
65
+ import { CotalEndpoint, deprovisionAgent, dlvDurable, dlvStream, dmDurable, dmStream, isConcreteChannel, provisionAgentDurables, contractDigest, contractRefToHex, contractStoreContext, endpointToken, fetchContractArtifact, verifyClusterManifest, verifyClusterRoot, admissionBucket, admissionMediatorGrants, assertDerivedOwnerToken, assertLifecycleToken, assertValidOwnerToken, authorizeTrustedServeSnapshot, commitSiblingIssuance, credsClaims, EpEnvelopeError, ensureAuthorityStores, epAuthBucket, isReachable, jwtFromCreds, managedRetirementOpId, mintCreds, mintPublicUserJwt, newIdentity, newTakeoverId, observeHostedRunAttempt, openRecordsBucket, parseEndpointGate, parseServiceSpec, parseServiceStatus, rawDigest, parseSecretStoreIdentity, readCheckpointStatus, readRunAdmission, readRunRevocation, readRunRecord, readSvcRecordLeader, reconcileEndpointGate, replayRunJournal, sameSecretStoreIdentity, recordSpecKey, recordStatusKey, RECORD_KINDS, recordsBucket, remoteManagerActors, retirementFrontierStreams, runDriverCaller, serveIssuanceGateKv, standaloneConnectOpts, STANDING_RENEWABLE_TTL_SEC, withIssuerSession, acceptedReadGrant, actorLedgerSource, connectionAcceptedToken, importNativeSubjectPermissions, mintGeneration, parseActorLedgerSource, writeAcceptedRow, invokeCommand, resolveService, contractArtifactCanonicalBytes, DEV_OWNER, endpointRegistrationBarrier, mintLifecycleUid, principalKey, provisionEndpointGateOpen, publishContractArtifact, registerServiceInstance, registerServingInstance, epgateKey, isCasLoss, sameManagedGrantCustody } from "@cotal-ai/core";
66
66
  import { findCotalRoot, loadManagerInstanceIdentity, userAuthStateDir, workspaceSecretStore, createAuthInstanceIdentity, loadAuthInstanceIdentity } from "@cotal-ai/workspace";
67
67
  import { decodeJwt } from "jose";
68
68
  import { deriveOwnerForIdpSubject, platformControlOwner } from "./derive.js";
@@ -85,6 +85,7 @@ import { validateRetainedManagedAgent } from "./continuity.js";
85
85
  import { reconstructRemoteManagerServeGrant, remoteManagerServeGrantFromCluster, remoteManagerSurface } from "./manager-contract.js";
86
86
  import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, platformReadinessGrants, remoteManagerIssuerGrants, remoteManagerRegistrationProof, registrationExecutorGrants, servedRunRequestSubjects } from "./authority-client.js";
87
87
  import { authorizeConnectCredential } from "./connect-reader.js";
88
+ import { chatAwareAclResolver, openChatSessionAuthority } from "./chat-session-authority.js";
88
89
  import { makeNativeIssuanceRecorder, pruneNativeIssuance } from "./native-issuance.js";
89
90
  import { ensureRootCredential } from "./root-credential.js";
90
91
  import { activateLifecycleAtUid, observeGate, openLifecycleRegistry, readLifecycleHeadForOperation } from "./lifecycle-registry.js";
@@ -100,13 +101,16 @@ import { serializedFor } from "./serialized.js";
100
101
  import { joinOrStartRetirement, openAuthAdminListener } from "./auth-admin.js";
101
102
  import { AUTH_SERVICE_ENDPOINT, authClusterArtifacts, authContractArtifactValues } from "./auth-service-contract.js";
102
103
  import { drainTargetForEndpoint, openAdmissionMediator } from "./admission-mediator.js";
103
- import { AGENT_BEARER_TTL_SEC, assertWithinSpawnerGrant, findInteractiveActor, findManagedActor, findActorUnified, grantManagedActor, ledgerAclResolver, ledgerActorSourceIsLive, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, revokeManagedActorAt, } from "./ledger.js";
104
+ import { AGENT_BEARER_TTL_SEC, assertWithinSpawnerGrant, findInteractiveActor, findManagedActor, findActorUnified, ledgerAclResolver, ledgerActorSourceIsLive, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, revokeManagedActorIf, withManagedGrantTransaction, } from "./ledger.js";
104
105
  import { AUTH_PROVIDER_NAME, claimAuthInstanceIdentity, clearAuthServiceInfo, dataAccountAuth, loadCalloutAuth, loadIssuer, loadOwnerSecret, loadPinnedIdp, loadServiceKeys, saveAuthServiceInfo, spaceIssuer, } from "./store.js";
105
106
  /** JWKS max-age seconds — the cache contract's knob. Exported so a rotation tool can compute the
106
107
  * retire floor (max-age + max bearer TTL) from it. */
107
108
  export const JWKS_MAX_AGE_SEC = 300;
108
109
  /** Loopback-only operator route used by `cotal actor grant/revoke` before rotating or deleting an interactive lifecycle. */
109
110
  export const INTERACTIVE_RETIRE_PATH = "/interactive-lifecycle/retire";
111
+ /** The user-mode chat-session routes, on the loopback face (capability-gated) and the public face.
112
+ * Each takes a fresh IdP proof; `bearer` and `retire` also take the session's holder proof. */
113
+ export const CHAT_SESSION_PATHS = Object.freeze({ open: "/chat-session/open", bearer: "/chat-session/bearer", retire: "/chat-session/retire" });
110
114
  /** Loopback-only host route that finishes a managed lifecycle's terminal retirement after the host
111
115
  * revoked its grant, when the remote manager that should have requested it is gone (#2070). */
112
116
  export const MANAGED_RETIRE_PATH = "/managed-lifecycle/retire";
@@ -227,6 +231,14 @@ async function supervisorGeneration(session, space, owner, instanceId, lifecycle
227
231
  throw new EpEnvelopeError("permission-denied", "platform supervisor accepted row names another owner or lifecycle");
228
232
  return accepted.ref;
229
233
  }
234
+ function runtimeSelection(options) {
235
+ if (!options || typeof options !== "object" || Array.isArray(options))
236
+ throw new Error("auth-service runtime options must be an object");
237
+ const selection = options.interactiveManagerSelection;
238
+ if (selection !== undefined && selection !== "local-primary")
239
+ throw new Error('interactiveManagerSelection must be "local-primary" when present');
240
+ return selection;
241
+ }
230
242
  /**
231
243
  * Open the authority plane — the PRODUCTION connect/exchange composition (exported so the live
232
244
  * deny-new smoke exercises exactly what the daemon runs). Boot order is the readiness contract:
@@ -256,6 +268,7 @@ async function openAuthAuthorityPlaneWithCustody(opts, onConnection) {
256
268
  }
257
269
  /** The body of the plane opener, which owns the unwind of a failed construction. */
258
270
  async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
271
+ const interactiveManagerSelection = runtimeSelection(opts);
259
272
  const { server, space, dataAccount, log } = opts;
260
273
  const standingTtl = opts.standingRenewableTtlSeconds ?? STANDING_RENEWABLE_TTL_SEC;
261
274
  if (!Number.isSafeInteger(standingTtl) || standingTtl < 5 || standingTtl > STANDING_RENEWABLE_TTL_SEC) {
@@ -435,11 +448,20 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
435
448
  unwind.push(() => authAdmin.close());
436
449
  const recordToolResultConsent = makeToolResultConsentRecorder(registryStores(registry).authKv, () => refuseIfFenced(), space);
437
450
  const fileArm = ledgerAuthorizeConnect(opts.dir);
451
+ // A chat principal (its alias is in the chat row space) is checked by the chat arm: its active
452
+ // row, its parent pin and its holder index. Every other principal by the grant-file arm.
453
+ const connectArm = async (t) => {
454
+ if (chat.isChatPrincipal(t.owner, t.act.actor))
455
+ await chat.authorizeConnect(t);
456
+ else
457
+ fileArm(t);
458
+ };
438
459
  // The callout's issuance evidence: create-only natkey row, then the connect authority re-read
439
- // in the same stream before the minted JWT may leave.
460
+ // in the same stream before the minted JWT may leave. For a chat session this re-read is the
461
+ // post-sign fence: its own head and root credential, its parent pin and its holder index.
440
462
  const recordIssuance = makeNativeIssuanceRecorder(registry, async (t) => {
441
463
  refuseIfFenced();
442
- fileArm(t);
464
+ await connectArm(t);
443
465
  await authorizeConnectCredential(reader.current(), t, Date.now);
444
466
  });
445
467
  // Expired evidence goes at boot (a failure refuses the open) and then on the plane's own timer;
@@ -561,6 +583,24 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
561
583
  throw new Error(`manager instance ${instanceId} is ${verdict}`);
562
584
  return instanceId;
563
585
  }
586
+ // A trusted host may choose the local Manager up front for human defaults. Explicit pins
587
+ // above and managed-agent calls keep their existing selection; no remote is a substitute.
588
+ if (caller === "human" && interactiveManagerSelection === "local-primary") {
589
+ let local;
590
+ try {
591
+ local = loadLocalManager();
592
+ }
593
+ catch {
594
+ throw new Error("interactive local-primary manager identity could not be read");
595
+ }
596
+ if (!local)
597
+ throw new Error("interactive local-primary manager identity is absent");
598
+ const instanceId = assertLifecycleToken(local.instanceId, "managerInstanceId");
599
+ const verdict = await managerGate(owner, instanceId);
600
+ if (verdict !== "candidate")
601
+ throw new Error(`interactive local-primary manager instance ${instanceId} is ${verdict}`);
602
+ return instanceId;
603
+ }
564
604
  const { instances } = await listManagerInstances(owner);
565
605
  const remoteInstances = instances.filter((i) => i.placement === "remote").map((i) => i.instanceId);
566
606
  const remoteLive = instances.filter((i) => i.placement === "remote" && i.state === "candidate").map((i) => i.instanceId);
@@ -626,12 +666,94 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
626
666
  const creds = await mintCreds(issuerAuth(), newIdentity(), "deprovisioner", { deprovisionTarget: { principal: targetId, lifecycleUid, memberChannels } });
627
667
  await deprovisionAgent({ servers: server, space, targetId, lifecycleUid, memberChannels, creds });
628
668
  };
669
+ // A chat session's footprint (admission a7): its own bind-only DM and DELIVER durables and the
670
+ // read-ACL row the delivery daemon authorizes its history and durable joins from, written by a
671
+ // short-lived provisioner and read back on that same connection (part of a8).
672
+ const provisionChatFootprint = async ({ owner, actor, lifecycleUid, read }) => {
673
+ const identity = newIdentity();
674
+ let conn;
675
+ const provisioner = new CotalEndpoint({
676
+ space,
677
+ servers: server,
678
+ onConnection: (nc) => { conn = nc; onConnection?.(nc, `cotal:chat-provisioner:${space}`); },
679
+ channels: [],
680
+ creds: await mintCreds(issuerAuth(), identity, "provisioner"),
681
+ card: { id: identity.id, name: "provisioner", role: "provisioner", kind: "endpoint" },
682
+ registerPresence: false,
683
+ watchPresence: false,
684
+ watchChannels: false,
685
+ consume: false,
686
+ });
687
+ await provisioner.start();
688
+ try {
689
+ await provisionAgentDurables(provisioner, { owner, actor, lifecycleUid }, { subscribe: [], allowSubscribe: read });
690
+ if (conn === undefined)
691
+ throw new EpEnvelopeError("unavailable", "the chat provisioner connection is unavailable for the read-back", undefined, "unknown");
692
+ const jsm = await jetstreamManager(conn);
693
+ await jsm.consumers.info(dmStream(space), dmDurable(owner, actor, lifecycleUid));
694
+ await jsm.consumers.info(dlvStream(space), dlvDurable(owner, actor, lifecycleUid));
695
+ const acl = await provisioner.aclForOwner(principalKey(owner, actor).key, lifecycleUid);
696
+ if (acl === undefined || JSON.stringify(acl) !== JSON.stringify(read))
697
+ throw new EpEnvelopeError("unavailable", `the chat session's read-ACL row did not read back for ${lifecycleUid}`, undefined, "unknown");
698
+ }
699
+ finally {
700
+ await provisioner.stop();
701
+ }
702
+ };
703
+ const chat = openChatSessionAuthority({
704
+ space,
705
+ dir: opts.dir,
706
+ registry,
707
+ managerInstance: `auth-service:${space}`,
708
+ retireLifecycle: async ({ owner, actor, lifecycleUid, opId }) => {
709
+ const flight = joinOrStartRetirement(retirementFlights, barrierReg, { owner, actor, lifecycleUid, opId, frontierStreams: retirementFrontierStreams(space) }, retirement);
710
+ if (flight === undefined)
711
+ throw new EpEnvelopeError("conflict", `chat retirement operation ${opId} is already in flight for different coordinates`);
712
+ await flight;
713
+ },
714
+ provisionFootprint: provisionChatFootprint,
715
+ deprovisionFootprint: ({ owner, actor, lifecycleUid, memberChannels }) => deprovisionManagedFootprint(owner, actor, lifecycleUid, memberChannels),
716
+ now: Date.now,
717
+ log,
718
+ });
719
+ // One bounded chat sweep pass per interval. A failed pass is logged and the next one retries; it
720
+ // never fences the plane, because every chat authority decision re-reads its own records.
721
+ const chatSweepMs = opts.chatSweepMs ?? 60_000;
722
+ if (!Number.isSafeInteger(chatSweepMs) || chatSweepMs < 100 || chatSweepMs > 3_600_000)
723
+ throw new Error(`chatSweepMs must be an integer between 100 and 3600000 (got ${chatSweepMs})`);
724
+ let chatSweepTimer;
725
+ let chatSweepFlight = Promise.resolve();
726
+ const scheduleChatSweep = () => {
727
+ if (closing || fatalReason !== undefined)
728
+ return;
729
+ chatSweepTimer = setTimeout(() => {
730
+ chatSweepFlight = chat.sweepPass().catch((e) => {
731
+ if (!closing)
732
+ log(`auth-plane: chat sweep pass failed: ${e instanceof Error ? e.message : String(e)}`);
733
+ }).then(scheduleChatSweep);
734
+ }, chatSweepMs);
735
+ };
736
+ const stopChatSweep = async () => { clearTimeout(chatSweepTimer); await chatSweepFlight; };
737
+ unwind.push(async () => { closing = true; await stopChatSweep(); });
738
+ scheduleChatSweep();
629
739
  return {
630
740
  authorizeConnect: async (t) => {
631
741
  refuseIfFenced();
632
- fileArm(t);
742
+ await connectArm(t);
633
743
  await authorizeConnectCredential(reader.current(), t, Date.now);
634
744
  },
745
+ openChatSession: async (args) => {
746
+ refuseIfFenced();
747
+ return chat.admit(args);
748
+ },
749
+ chatSessionBearer: async (args) => {
750
+ refuseIfFenced();
751
+ return chat.exchange(args);
752
+ },
753
+ retireChatSession: async (args) => {
754
+ refuseIfFenced();
755
+ return chat.retire(args);
756
+ },
635
757
  recordIssuance: (i) => {
636
758
  refuseIfFenced();
637
759
  return recordIssuance(i);
@@ -696,11 +818,18 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
696
818
  }, principal, claim);
697
819
  },
698
820
  retireInteractiveLifecycle: async (args) => {
699
- refuseIfFenced();
700
- const owner = assertDerivedOwnerToken(args.owner);
701
- const actor = assertValidOwnerToken(args.actor);
702
- const lifecycleUid = assertLifecycleToken(args.lifecycleUid);
703
- const head = await readLifecycleHeadForOperation(barrierReg, owner, actor);
821
+ // Everything before the barrier only reads, so a refusal there retired nothing.
822
+ let owner, actor, lifecycleUid;
823
+ try {
824
+ refuseIfFenced();
825
+ owner = assertDerivedOwnerToken(args.owner);
826
+ actor = assertValidOwnerToken(args.actor);
827
+ lifecycleUid = assertLifecycleToken(args.lifecycleUid);
828
+ }
829
+ catch (e) {
830
+ throw notExecuted(e, "bad-request");
831
+ }
832
+ const head = await readLifecycleHeadForOperation(barrierReg, owner, actor).catch((e) => { throw notExecuted(e, "unavailable"); });
704
833
  if (head === undefined)
705
834
  return { retired: false, lifecycleUid, notStarted: true };
706
835
  if (head.mapping.lifecycleUid !== lifecycleUid) {
@@ -709,7 +838,7 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
709
838
  // An active/retiring foreign uid is different: the ledger and authority disagree, so fail.
710
839
  if (head.mapping.state === "retired")
711
840
  return { retired: false, lifecycleUid, notStarted: true };
712
- throw new EpEnvelopeError("conflict", `interactive lifecycle retirement for "${owner}/${actor}" names ${lifecycleUid}, but the authority head is ${head.mapping.state} at ${head.mapping.lifecycleUid}`);
841
+ throw new EpEnvelopeError("conflict", `interactive lifecycle retirement for "${owner}/${actor}" names ${lifecycleUid}, but the authority head is ${head.mapping.state} at ${head.mapping.lifecycleUid}`, undefined, "not-executed");
713
842
  }
714
843
  if (head.mapping.state === "retired")
715
844
  return { retired: true, lifecycleUid, alreadyRetired: true };
@@ -1157,31 +1286,48 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
1157
1286
  const t = r.target;
1158
1287
  const subscribe = t.subscribe ?? [];
1159
1288
  const { lifecycleUid, row } = await serializedForAlias(opts.dir, owner, t.actor, async () => {
1160
- // The token digest names one enrollment. A held row with this request's digest is that
1161
- // enrollment retried, so it answers the same uid; any other held row is a lifecycle that
1162
- // still runs, and taking its grant would strand it.
1163
- const held = findManagedActor(opts.dir, owner, t.actor);
1164
- if (held !== undefined && held.tokenHash !== t.tokenHash)
1165
- throw new EpEnvelopeError("conflict", `managed agent "${t.actor}" is enrolled at lifecycle ${held.lifecycleUid}; prepare that lifecycle's retirement before enrolling a successor`);
1166
- // A fresh uid is never one a retirement tombstoned, and only the host can make that promise.
1167
- const lifecycleUid = held?.lifecycleUid ?? mintLifecycleUid();
1168
- // The grant first: its envelope walk against the supervising actor refuses an over-wide
1169
- // request before any durable exists. A retry walks the held grant again: its spawner chain
1170
- // may have narrowed since the first answer, and provisioning would restore what it took away.
1171
- if (held !== undefined)
1172
- assertWithinSpawnerGrant(opts.dir, held, "spawn");
1173
- const row = held ?? grantManagedActor(opts.dir, {
1174
- owner,
1175
- actor: t.actor,
1176
- scope: t.capabilities ?? [],
1177
- allowSubscribe: t.allowSubscribe ?? [],
1178
- allowPublish: t.allowPublish ?? [],
1179
- ...(t.role !== undefined ? { role: t.role } : {}),
1180
- ...(t.label !== undefined ? { label: t.label } : {}),
1181
- parent: principalKey(owner, r.actor).key,
1182
- lifecycleUid,
1183
- tokenHash: t.tokenHash,
1289
+ // The custody this enrollment attests: the instance the authorized request names, checked
1290
+ // against its current registration above. Never a field of the participant's target.
1291
+ const custody = { kind: "remote-manager", instanceId: r.instanceId };
1292
+ // ONE synchronous transaction for the read, the decision and the write, so an
1293
+ // enrollment in another process (or a provider grant) cannot land between them.
1294
+ const decided = withManagedGrantTransaction(opts.dir, (tx) => {
1295
+ const current = tx.read(owner, t.actor);
1296
+ const held = current?.kind === "managed-agent" ? current : undefined;
1297
+ // The token digest names one enrollment. A held row with this request's digest is that
1298
+ // enrollment retried, so it answers the same uid; any other held row is a lifecycle that
1299
+ // still runs, and taking its grant would strand it.
1300
+ if (held !== undefined && held.tokenHash !== t.tokenHash)
1301
+ throw new EpEnvelopeError("conflict", `managed agent "${t.actor}" is enrolled at lifecycle ${held.lifecycleUid}; prepare that lifecycle's retirement before enrolling a successor`);
1302
+ // A retry is this enrollment only if the held row records this exact custody. A row with
1303
+ // none, or with another, is never upgraded or adopted here.
1304
+ if (held !== undefined && (held.custody === undefined || !sameManagedGrantCustody(held.custody, custody)))
1305
+ throw new EpEnvelopeError("conflict", `managed agent "${t.actor}" is held at lifecycle ${held.lifecycleUid} under another custody; it was not adopted by this enrollment`);
1306
+ // A retry walks the held grant again: its spawner chain may have narrowed since the
1307
+ // first answer, and provisioning would restore what it took away.
1308
+ if (held !== undefined) {
1309
+ assertWithinSpawnerGrant(opts.dir, held, "spawn");
1310
+ return { lifecycleUid: held.lifecycleUid, row: held, wrote: false };
1311
+ }
1312
+ // A fresh uid is never one a retirement tombstoned, and only the host can make that
1313
+ // promise. The grant's envelope walk refuses an over-wide request before any durable exists.
1314
+ const lifecycleUid = mintLifecycleUid();
1315
+ const row = tx.grantManaged({
1316
+ owner,
1317
+ actor: t.actor,
1318
+ scope: t.capabilities ?? [],
1319
+ allowSubscribe: t.allowSubscribe ?? [],
1320
+ allowPublish: t.allowPublish ?? [],
1321
+ ...(t.role !== undefined ? { role: t.role } : {}),
1322
+ ...(t.label !== undefined ? { label: t.label } : {}),
1323
+ parent: principalKey(owner, r.actor).key,
1324
+ lifecycleUid,
1325
+ tokenHash: t.tokenHash,
1326
+ custody,
1327
+ });
1328
+ return { lifecycleUid, row, wrote: true };
1184
1329
  });
1330
+ const { lifecycleUid, row } = decided;
1185
1331
  try {
1186
1332
  const identity = newIdentity();
1187
1333
  const provisioner = new CotalEndpoint({
@@ -1207,9 +1353,10 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
1207
1353
  catch (e) {
1208
1354
  // A retried enrollment's first answer may already have reached the participant, so its
1209
1355
  // grant stays. A grant this call wrote gave the participant no uid, so it can never be
1210
- // retired: it and any durable it got must go now.
1211
- if (held === undefined) {
1212
- revokeManagedActorAt(opts.dir, owner, t.actor, lifecycleUid);
1356
+ // retired: it and any durable it got must go now. The revoke removes only the row this
1357
+ // call wrote: same uid AND same custody, read under the grant lock.
1358
+ if (decided.wrote) {
1359
+ revokeManagedActorIf(opts.dir, owner, t.actor, { lifecycleUid, custody });
1213
1360
  await deprovisionManagedFootprint(owner, t.actor, lifecycleUid, []).catch((err) => opts.log(`auth-service: refused enrollment of ${owner}/${t.actor} left durables at ${lifecycleUid}: ${err instanceof Error ? err.message : String(err)}`));
1214
1361
  }
1215
1362
  throw e;
@@ -1249,8 +1396,17 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
1249
1396
  const row = findManagedActor(opts.dir, owner, actor);
1250
1397
  if (row?.lifecycleUid === lifecycleUid) {
1251
1398
  // Footprint before grant: while the row stands, a retry still knows which channels to purge.
1399
+ // The footprint work is async, so it runs outside the grant lock.
1252
1400
  await deprovisionManagedFootprint(owner, actor, lifecycleUid, row.allowSubscribe.filter(isConcreteChannel));
1253
- revokeManagedActorAt(opts.dir, owner, actor, lifecycleUid);
1401
+ // The decision and the write are one transaction: the row removed is the one read above
1402
+ // only if it still names this uid and the custody it carried then. A row written before
1403
+ // custody existed is removed by uid alone, as before.
1404
+ withManagedGrantTransaction(opts.dir, (tx) => {
1405
+ if (row.custody !== undefined)
1406
+ tx.revokeManagedIf(owner, actor, { lifecycleUid, custody: row.custody });
1407
+ else
1408
+ tx.revokeManagedAt(owner, actor, lifecycleUid);
1409
+ });
1254
1410
  }
1255
1411
  });
1256
1412
  return {
@@ -1509,6 +1665,7 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
1509
1665
  // for a later close retry; successful scanner teardown is not run a second time.
1510
1666
  closing = true;
1511
1667
  await stopPrune();
1668
+ await stopChatSweep();
1512
1669
  const scannerFlight = closeScanners ??= (async () => {
1513
1670
  await authAdmin?.close();
1514
1671
  const results = await Promise.allSettled([reader.close(), recordsScanner.close(), scanner.close()]);
@@ -1609,19 +1766,8 @@ async function resumeOpenOperations(reg, evictPrincipal, retirement, log) {
1609
1766
  }
1610
1767
  }
1611
1768
  }
1612
- /** Run the auth service. Flags: `--space` (required), `--server` (broker URL, required), `--port`
1613
- * (loopback HTTP port; default ephemeral). All persisted material must already exist (the
1614
- * provider's `prepareServer` ran at `cotal up`) — a missing piece is a fail-loud config error
1615
- * naming the fix, never a silent partial service; this daemon LOADS the four secret kinds and
1616
- * never generates them.
1617
- *
1618
- * `store` is the hosted composition's injection point for the space's SECRET material (callout
1619
- * account, issuer keys, owner secret, service key projection) — when given, it is those kinds'
1620
- * ONLY source (no flag or path can point this daemon at other secret material; an absent key is
1621
- * the hard error below). Absent, the daemon composes the local workspace store over `.cotal/`.
1622
- * NON-SEAM state (the actor ledger, the IdP pin, the discovery file) still lives in the local
1623
- * state dir either way — hosting those is a later, separate seam. */
1624
- export async function runAuthService(args, store) {
1769
+ /** Pure native daemon input validation, shared with its explicit service launch plan. */
1770
+ export function authServiceConfiguration(args) {
1625
1771
  const v = args.values;
1626
1772
  const space = v.space;
1627
1773
  if (!space)
@@ -1652,9 +1798,27 @@ export async function runAuthService(args, store) {
1652
1798
  const badFace = publicFace && checkPublicFace(publicFace);
1653
1799
  if (badFace)
1654
1800
  throw new Error(badFace);
1801
+ return { space, server, port, publicFace };
1802
+ }
1803
+ /** Run the auth service. Flags: `--space` (required), `--server` (broker URL, required), `--port`
1804
+ * (loopback HTTP port; default ephemeral). All persisted material must already exist (the
1805
+ * provider's `prepareServer` ran at `cotal up`) — a missing piece is a fail-loud config error
1806
+ * naming the fix, never a silent partial service; this daemon LOADS the four secret kinds and
1807
+ * never generates them.
1808
+ *
1809
+ * `store` is the hosted composition's injection point for the space's SECRET material (callout
1810
+ * account, issuer keys, owner secret, service key projection) — when given, it is those kinds'
1811
+ * ONLY source (no flag or path can point this daemon at other secret material; an absent key is
1812
+ * the hard error below). Absent, the daemon composes the local workspace store over `.cotal/`.
1813
+ * NON-SEAM state (the actor ledger, the IdP pin, the discovery file) still lives in the local
1814
+ * state dir either way — hosting those is a later, separate seam. */
1815
+ export async function runAuthService(args, store, options = {}) {
1816
+ const interactiveManagerSelection = runtimeSelection(options);
1817
+ const lifecycle = "root" in options ? options : undefined;
1818
+ const { space, server, port, publicFace } = authServiceConfiguration(args);
1655
1819
  // The provider's space-scoped state dir for NON-SEAM material (ledger, IdP pin, discovery). The
1656
1820
  // layout fact is workspace-owned (userAuthStateDir); this daemon never touches `.cotal/auth/auth.json`.
1657
- const root = findCotalRoot();
1821
+ const root = lifecycle?.root ?? findCotalRoot();
1658
1822
  const dir = userAuthStateDir(root, space);
1659
1823
  const secrets = store ?? workspaceSecretStore(root);
1660
1824
  // Scrub any stale discovery file FIRST — a dead prior daemon's entry must never satisfy a
@@ -1686,25 +1850,43 @@ export async function runAuthService(args, store) {
1686
1850
  // The CLI composition's local manager is this workspace root's persisted instance, re-read per
1687
1851
  // selection exactly as before. A hosted context passes no local manager at all.
1688
1852
  localManager: () => loadManagerInstanceIdentity(root, space),
1853
+ ...(interactiveManagerSelection !== undefined ? { interactiveManagerSelection } : {}),
1689
1854
  });
1690
1855
  // All planes bound — NOW write the discovery file (its existence is the readiness signal).
1691
1856
  const { url, publicUrl, cap } = started.handle;
1692
1857
  saveAuthServiceInfo(dir, { url, pid: process.pid, cap, ...(publicUrl !== undefined ? { publicUrl } : {}) });
1693
- console.log(`✓ auth service up (space ${space}) - callout on ${server}, exchange/JWKS at ${url}${publicUrl !== undefined ? `, public exchange at ${publicUrl}` : ""}`);
1858
+ if (!lifecycle?.quiet)
1859
+ console.log(`✓ auth service up (space ${space}) - callout on ${server}, exchange/JWKS at ${url}${publicUrl !== undefined ? `, public exchange at ${publicUrl}` : ""}`);
1694
1860
  // The CLI wrapper, not the context, owns process signals and exit codes.
1695
- const stop = async () => {
1861
+ let stopping;
1862
+ const stop = () => stopping ??= (async () => {
1696
1863
  clearAuthServiceInfo(dir); // a dead service must not satisfy the next start's readiness poll
1697
1864
  try {
1698
1865
  await started.handle.close();
1866
+ await lifecycle?.onClosed();
1699
1867
  }
1700
1868
  catch (e) {
1701
- console.error(`✗ auth-service: ${e.message} - exiting`);
1869
+ console.error(lifecycle ? "✗ native auth-service shutdown did not confirm completion; custody was retained" : `✗ auth-service: ${e.message} - exiting`);
1702
1870
  process.exit(1);
1703
1871
  }
1704
1872
  process.exit(0);
1705
- };
1873
+ })();
1706
1874
  process.on("SIGINT", () => void stop());
1707
1875
  process.on("SIGTERM", () => void stop());
1876
+ if (lifecycle) {
1877
+ try {
1878
+ await lifecycle.onReady();
1879
+ }
1880
+ catch {
1881
+ clearAuthServiceInfo(dir);
1882
+ try {
1883
+ await started.handle.close();
1884
+ await lifecycle.onClosed();
1885
+ }
1886
+ catch { /* Unknown close keeps custody. */ }
1887
+ throw new Error("the native auth-service startup receipt was not published; no new launch was retried");
1888
+ }
1889
+ }
1708
1890
  // A dropped broker connection is fatal-loud, not a zombie: the supervising `up`/`down` lifecycle
1709
1891
  // owns restarts, and a callout that silently stopped answering would hang every user connect.
1710
1892
  // A FENCED plane is equally fatal (SPEC 13.13): its scanners are no longer whole, every authority
@@ -1713,13 +1895,14 @@ export async function runAuthService(args, store) {
1713
1895
  const ended = await started.ended;
1714
1896
  clearAuthServiceInfo(dir);
1715
1897
  if (ended.kind === "fenced") {
1716
- console.error(`✗ auth-service: ${ended.cause} - exiting`);
1898
+ console.error(lifecycle ? "✗ native auth-service authority was fenced; custody was retained" : `✗ auth-service: ${ended.cause} - exiting`);
1717
1899
  process.exit(1);
1718
1900
  }
1719
1901
  if (ended.cause !== undefined) {
1720
- console.error(`✗ auth-service: broker connection closed (${ended.cause}) - exiting`);
1902
+ console.error(lifecycle ? "✗ native auth-service broker connection closed; custody was retained" : `✗ auth-service: broker connection closed (${ended.cause}) - exiting`);
1721
1903
  process.exit(1);
1722
1904
  }
1905
+ await lifecycle?.onClosed();
1723
1906
  process.exit(0);
1724
1907
  }
1725
1908
  /** Start one account-scoped auth-service context. Every input is explicit: the state dir, the
@@ -1811,6 +1994,7 @@ async function startAuthContext(o) {
1811
1994
  dataAccount: { pub: keys.dataAccount.pub, signingSeed: keys.dataAccount.signingSeed },
1812
1995
  log: (l) => console.error(l),
1813
1996
  localManager: o.localManager,
1997
+ ...(o.interactiveManagerSelection !== undefined ? { interactiveManagerSelection: o.interactiveManagerSelection } : {}),
1814
1998
  ...(o.standingRenewableTtlSeconds !== undefined ? { standingRenewableTtlSeconds: o.standingRenewableTtlSeconds } : {}),
1815
1999
  }, onConnection);
1816
2000
  let nc;
@@ -1844,7 +2028,9 @@ async function startAuthContext(o) {
1844
2028
  token: { key: issuer.localKeySet(), issuer: issuer.issuer },
1845
2029
  authorizeActor: plane.authorizeConnect,
1846
2030
  recordIssuance: plane.recordIssuance,
1847
- permissionsFor: calloutPermissions(ledgerAclResolver(dir), plane.authorizeManagerCaller, plane.verifySession, plane.issueUserCaller),
2031
+ // A chat principal resolves from its closed row (the agent leaf, no role); every other
2032
+ // principal from the grant ledger.
2033
+ permissionsFor: calloutPermissions(chatAwareAclResolver(dir, ledgerAclResolver(dir)), plane.authorizeManagerCaller, plane.verifySession, plane.issueUserCaller),
1848
2034
  log: (l) => console.error(l),
1849
2035
  });
1850
2036
  // The subscription must be ON the broker before readiness is signaled — an `up` that recorded a
@@ -1859,6 +2045,7 @@ async function startAuthContext(o) {
1859
2045
  issuer,
1860
2046
  authorizeActor: ledgerAuthorizeGrant(dir),
1861
2047
  mintConnectCredential: plane.mintConnectCredential,
2048
+ ...(idp.dmReaderAudience !== undefined ? { dmReaderAudience: idp.dmReaderAudience } : {}),
1862
2049
  });
1863
2050
  const cap = randomBytes(32).toString("hex"); // per-start exchange capability (rotates with the daemon)
1864
2051
  // Set once the public face binds. An enrolled child's bearer exchanges there without the
@@ -1905,6 +2092,9 @@ async function startAuthContext(o) {
1905
2092
  selectManagerInstance: plane.selectManagerInstance,
1906
2093
  listManagerInstances: plane.listManagerInstances,
1907
2094
  verifySession: plane.verifySession,
2095
+ openChatSession: plane.openChatSession,
2096
+ chatSessionBearer: plane.chatSessionBearer,
2097
+ retireChatSession: plane.retireChatSession,
1908
2098
  };
1909
2099
  const loopback = createServer((req, res) => void handle(req, res, ctx));
1910
2100
  http = loopback;
@@ -2283,6 +2473,13 @@ class RequestBodyError extends Error {
2283
2473
  this.status = status;
2284
2474
  }
2285
2475
  }
2476
+ /** `e`, raised before any write, as a refusal that states so (SPEC 13.3). An envelope error keeps
2477
+ * its code; any other error takes `code`. */
2478
+ function notExecuted(e, code) {
2479
+ return e instanceof EpEnvelopeError
2480
+ ? new EpEnvelopeError(e.code, e.message, e.details, "not-executed")
2481
+ : new EpEnvelopeError(code, e instanceof Error ? e.message : String(e), undefined, "not-executed");
2482
+ }
2286
2483
  function sendRequestError(res, e) {
2287
2484
  if (e instanceof RequestBodyError)
2288
2485
  return send(res, e.status, { error: e.message });
@@ -2311,13 +2508,16 @@ const ROUTES = new Map([
2311
2508
  [VERIFY_ENROLLMENT_PATH, handleVerifyManagedAgentEnrollment],
2312
2509
  [TOOL_RESULT_HUMAN_PROOF_PATH, handleToolResultHumanProof],
2313
2510
  [TOOL_RESULT_CONSENT_PATH, handleToolResultConsent],
2511
+ [CHAT_SESSION_PATHS.open, (req, res, ctx) => handleChatSession(req, res, ctx, LOOPBACK_POLICY, "open")],
2512
+ [CHAT_SESSION_PATHS.bearer, (req, res, ctx) => handleChatSession(req, res, ctx, LOOPBACK_POLICY, "bearer")],
2513
+ [CHAT_SESSION_PATHS.retire, (req, res, ctx) => handleChatSession(req, res, ctx, LOOPBACK_POLICY, "retire")],
2314
2514
  ]);
2315
2515
  /** Route one HTTP request against the loopback route table. Errors are JSON `{ error }`. */
2316
2516
  async function handle(req, res, ctx) {
2317
2517
  try {
2318
2518
  const route = ROUTES.get(req.url ?? "");
2319
2519
  if (!route)
2320
- return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /manager-instances, /manager-service-authority/verify-enrollment, /interactive-lifecycle/retire, /managed-lifecycle/retire" });
2520
+ return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /manager-instances, /manager-service-authority/verify-enrollment, /interactive-lifecycle/retire, /managed-lifecycle/retire, /chat-session/open, /chat-session/bearer, /chat-session/retire" });
2321
2521
  await route(req, res, ctx);
2322
2522
  }
2323
2523
  catch (e) {
@@ -2325,34 +2525,50 @@ async function handle(req, res, ctx) {
2325
2525
  }
2326
2526
  }
2327
2527
  const INTERACTIVE_RETIRE_KEYS = new Set(["owner", "actor", "lifecycleUid"]);
2528
+ /** The interactive retirement door. Every refusal before the barrier starts retired nothing and
2529
+ * answers `outcome: "not-executed"` (SPEC 13.3), with the plane's native `code` when the plane
2530
+ * raised it. A failure inside the barrier answers 400 with no outcome, which reads as unknown. */
2328
2531
  async function handleInteractiveLifecycleRetirement(req, res, ctx) {
2532
+ const before = { outcome: "not-executed" };
2329
2533
  if (req.method !== "POST")
2330
- return send(res, 405, { error: "POST only" });
2534
+ return send(res, 405, { error: "POST only", ...before });
2331
2535
  if (req.headers.origin !== undefined)
2332
- return send(res, 403, { error: "browser-origin requests are not served here" });
2536
+ return send(res, 403, { error: "browser-origin requests are not served here", ...before });
2333
2537
  if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
2334
- return send(res, 415, { error: "content-type must be application/json" });
2538
+ return send(res, 415, { error: "content-type must be application/json", ...before });
2335
2539
  if (req.headers.authorization !== `Bearer ${ctx.cap}`)
2336
- return send(res, 401, { error: "missing/invalid exchange capability - interactive lifecycle retirement is a loopback operator action" });
2540
+ return send(res, 401, { error: "missing/invalid exchange capability - interactive lifecycle retirement is a loopback operator action", ...before });
2337
2541
  const body = await readJsonBody(req);
2338
2542
  if (body === null || typeof body !== "object" || Array.isArray(body))
2339
- return send(res, 400, { error: "interactive lifecycle retirement needs { owner, actor, lifecycleUid }" });
2543
+ return send(res, 400, { error: "interactive lifecycle retirement needs { owner, actor, lifecycleUid }", ...before });
2340
2544
  for (const key of Object.keys(body))
2341
2545
  if (!INTERACTIVE_RETIRE_KEYS.has(key))
2342
- return send(res, 400, { error: `interactive lifecycle retirement carries the unknown field "${key}"` });
2546
+ return send(res, 400, { error: `interactive lifecycle retirement carries the unknown field "${key}"`, ...before });
2343
2547
  const raw = body;
2344
2548
  if (typeof raw.owner !== "string" || typeof raw.actor !== "string" || typeof raw.lifecycleUid !== "string")
2345
- return send(res, 400, { error: "interactive lifecycle retirement needs string { owner, actor, lifecycleUid }" });
2346
- const owner = assertDerivedOwnerToken(raw.owner);
2347
- const actor = assertValidOwnerToken(raw.actor);
2348
- const lifecycleUid = assertLifecycleToken(raw.lifecycleUid);
2549
+ return send(res, 400, { error: "interactive lifecycle retirement needs string { owner, actor, lifecycleUid }", ...before });
2550
+ let owner, actor, lifecycleUid;
2551
+ try {
2552
+ owner = assertDerivedOwnerToken(raw.owner);
2553
+ actor = assertValidOwnerToken(raw.actor);
2554
+ lifecycleUid = assertLifecycleToken(raw.lifecycleUid);
2555
+ }
2556
+ catch (e) {
2557
+ return send(res, 400, { error: e instanceof Error ? e.message : String(e), ...before });
2558
+ }
2349
2559
  const row = findInteractiveActor(ctx.dir, owner, actor);
2350
2560
  if (!row)
2351
- return send(res, 404, { error: `interactive actor "${owner}/${actor}" is not granted` });
2561
+ return send(res, 404, { error: `interactive actor "${owner}/${actor}" is not granted`, ...before });
2352
2562
  if (row.lifecycleUid !== lifecycleUid)
2353
- return send(res, 409, { error: `interactive actor "${owner}/${actor}" is current at lifecycle ${row.lifecycleUid ?? "<missing>"}, not ${lifecycleUid}` });
2354
- const result = await ctx.retireInteractiveLifecycle({ owner, actor, lifecycleUid });
2355
- return send(res, 200, result);
2563
+ return send(res, 409, { error: `interactive actor "${owner}/${actor}" is current at lifecycle ${row.lifecycleUid ?? "<missing>"}, not ${lifecycleUid}`, ...before });
2564
+ try {
2565
+ return send(res, 200, await ctx.retireInteractiveLifecycle({ owner, actor, lifecycleUid }));
2566
+ }
2567
+ catch (e) {
2568
+ if (e instanceof EpEnvelopeError && e.outcome === "not-executed")
2569
+ return send(res, REFUSAL_STATUS[e.code] ?? 500, { error: e.message, code: e.code, outcome: e.outcome });
2570
+ throw e;
2571
+ }
2356
2572
  }
2357
2573
  /** The host's terminal step for a managed lifecycle whose remote manager is gone: the same request
2358
2574
  * guards as the interactive door, but it requires the managed grant to be REVOKED first (the
@@ -2487,15 +2703,15 @@ async function handleVerifyManagedAgentEnrollment(req, res, ctx) {
2487
2703
  }
2488
2704
  /** Refuse a missing/invalid loopback capability, audited and counted in its own window (see
2489
2705
  * BAD_CAP_PER_MIN): 401 with `reason` until the window is full, then 429. */
2490
- function refuseCapability(res, ctx, reason) {
2706
+ function refuseCapability(res, ctx, reason, extra = {}) {
2491
2707
  const now = Date.now();
2492
2708
  while (ctx.badCaps.length && now - ctx.badCaps[0] > 60_000)
2493
2709
  ctx.badCaps.shift();
2494
2710
  ctx.badCaps.push(now);
2495
2711
  console.error("auth-service: rejected an exchange with a missing/invalid capability");
2496
2712
  if (ctx.badCaps.length > BAD_CAP_PER_MIN)
2497
- return send(res, 429, { error: "too many invalid-capability attempts - wait a minute and retry" });
2498
- send(res, 401, { error: reason });
2713
+ return send(res, 429, { error: "too many invalid-capability attempts - wait a minute and retry", ...extra });
2714
+ send(res, 401, { error: reason, ...extra });
2499
2715
  }
2500
2716
  /** The exchange body, shared by every face; `policy` says how this face proves and attributes the
2501
2717
  * caller. Behavior on the loopback face is unchanged from the pre-route-table handler. */
@@ -2646,6 +2862,93 @@ async function handleExchange(req, res, ctx, policy) {
2646
2862
  return send(res, 401, { error: reason });
2647
2863
  }
2648
2864
  }
2865
+ /** Each chat-session route's CLOSED body: an unknown or missing field refuses before any read. */
2866
+ const CHAT_SESSION_FIELDS = {
2867
+ open: { required: ["idpToken", "holderHash", "read", "post", "ttlSec"], optional: ["label"] },
2868
+ bearer: { required: ["idpToken", "actor", "holderProof"], optional: [] },
2869
+ retire: { required: ["idpToken", "actor", "lifecycleUid", "holderProof"], optional: [] },
2870
+ };
2871
+ /** HTTP status for a door refusal's native code; the body carries the code itself. */
2872
+ const REFUSAL_STATUS = {
2873
+ "bad-request": 400, unauthenticated: 401, "permission-denied": 403, "not-found": 404, conflict: 409,
2874
+ "failed-precondition": 412, "resource-exhausted": 429, unimplemented: 501, unavailable: 503,
2875
+ };
2876
+ /**
2877
+ * The chat-session routes, on both faces. The owner is derived from a fresh IdP proof exactly as
2878
+ * `/exchange` derives it; the body never names one. `open` admits, `bearer` exchanges the holder
2879
+ * proof for one short bearer, `retire` retires. Every refusal answers `{ error, code, outcome }`:
2880
+ * `not-executed` when nothing was written, `executed` when this request wrote before refusing, and
2881
+ * `unknown` when a write may have landed (a retry with the SAME holder resolves it). The holder proof
2882
+ * and the bearer never reach a log line.
2883
+ */
2884
+ async function handleChatSession(req, res, ctx, policy, op) {
2885
+ // Every answer before the dispatch is refused with nothing read or written.
2886
+ const before = { outcome: "not-executed" };
2887
+ if (req.method !== "POST")
2888
+ return send(res, 405, { error: "POST only", code: "bad-request", ...before });
2889
+ if (req.headers.origin !== undefined)
2890
+ return send(res, 403, { error: "browser-origin requests are not served here", code: "permission-denied", ...before });
2891
+ if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
2892
+ return send(res, 415, { error: "content-type must be application/json", code: "bad-request", ...before });
2893
+ if (policy.requireCapability && (req.headers.authorization ?? "") !== `Bearer ${ctx.cap}`)
2894
+ return refuseCapability(res, ctx, "missing/invalid exchange capability - read it from the space's auth-service.json", { code: "unauthenticated", ...before });
2895
+ const peer = policy.peerKey(req);
2896
+ const peerThrottled = policy.throttled(ctx, peer);
2897
+ const body = await readJsonBody(req);
2898
+ const malformed = (why) => send(res, 400, { error: `chat-session ${op}: ${why}`, code: "bad-request", ...before });
2899
+ if (body === null || typeof body !== "object" || Array.isArray(body))
2900
+ return malformed("the body must be a JSON object");
2901
+ const b = body;
2902
+ const fields = CHAT_SESSION_FIELDS[op];
2903
+ const extra = Object.keys(b).find((k) => !fields.required.includes(k) && !fields.optional.includes(k));
2904
+ if (extra !== undefined)
2905
+ return malformed(`unknown field "${extra}"`);
2906
+ const missing = fields.required.find((k) => !Object.hasOwn(b, k));
2907
+ if (missing !== undefined)
2908
+ return malformed(`missing field "${missing}"`);
2909
+ if (typeof b.idpToken !== "string" || b.idpToken === "")
2910
+ return malformed("idpToken must be a non-empty string");
2911
+ if (op === "open" && (typeof b.holderHash !== "string" || !Array.isArray(b.read) || !Array.isArray(b.post) || typeof b.ttlSec !== "number" || (b.label !== undefined && typeof b.label !== "string")))
2912
+ return malformed("open takes { idpToken, holderHash: string, read: string[], post: string[], ttlSec: number, label?: string }");
2913
+ if (op !== "open" && (typeof b.actor !== "string" || typeof b.holderProof !== "string" || (op === "retire" && typeof b.lifecycleUid !== "string")))
2914
+ return malformed(op === "bearer" ? "bearer takes { idpToken, actor: string, holderProof: string }" : "retire takes { idpToken, actor: string, lifecycleUid: string, holderProof: string }");
2915
+ // A refusal keeps the effect outcome its source stated; one that states none is unknown.
2916
+ const refuse = (status, message, code, outcome = "unknown") => {
2917
+ policy.recordFailure(ctx, peer);
2918
+ console.error(`auth-service: refused a chat-session ${op}: ${message}`);
2919
+ if (peerThrottled)
2920
+ return send(res, 429, { error: "too many refused exchanges - wait a minute and retry", code: "resource-exhausted", outcome });
2921
+ return send(res, status, { error: message, code, outcome });
2922
+ };
2923
+ let owner;
2924
+ let idpExp;
2925
+ try {
2926
+ const verified = await verifyIdpToken(b.idpToken, ctx.bridgeIdp);
2927
+ owner = deriveOwnerForIdpSubject(ctx.ownerSecret, ctx.bridgeIdp.issuer, verified.sub);
2928
+ idpExp = verified.exp;
2929
+ }
2930
+ catch (e) {
2931
+ return refuse(401, e instanceof Error ? e.message : String(e), "unauthenticated", "not-executed");
2932
+ }
2933
+ try {
2934
+ if (op === "open")
2935
+ return send(res, 200, await ctx.openChatSession({
2936
+ owner, holderHash: b.holderHash, read: b.read, post: b.post, ttlSec: b.ttlSec,
2937
+ ...(typeof b.label === "string" ? { label: b.label } : {}),
2938
+ }));
2939
+ if (op === "bearer") {
2940
+ const minted = await ctx.chatSessionBearer({ owner, actor: b.actor, holderProof: b.holderProof, idpExp, issuer: ctx.issuer });
2941
+ return send(res, 200, { token: minted.token, owner, exp: minted.exp });
2942
+ }
2943
+ return send(res, 200, await ctx.retireChatSession({ owner, actor: b.actor, lifecycleUid: b.lifecycleUid, holderProof: b.holderProof }));
2944
+ }
2945
+ catch (e) {
2946
+ if (e instanceof EpEnvelopeError)
2947
+ return refuse(REFUSAL_STATUS[e.code] ?? 500, e.message, e.code, e.outcome ?? "unknown");
2948
+ // Anything else came from a store, broker or file read partway through: its effect is unknown.
2949
+ return refuse(503, e instanceof Error ? e.message : String(e), "unavailable", "unknown");
2950
+ }
2951
+ }
2649
2952
  /** The signed-in person's own Manager instances, read-only, on both faces. The proof is the IdP JWT
2650
2953
  * the human exchange verifies, and the owner is derived from it the same way, so a caller lists only
2651
2954
  * its own owner's gates. A browser-origin request is refused as on every exchange route; an
@@ -2891,6 +3194,9 @@ function makePublicHandler(ctx, policy, bundle) {
2891
3194
  ["/exchange", (req, res, c) => handleExchange(req, res, c, policy)],
2892
3195
  ["/manager-service-authority", (req, res, c) => handleManagerServiceAuthority(req, res, c, policy)],
2893
3196
  ["/manager-instances", (req, res, c) => handleManagerInstances(req, res, c, policy)],
3197
+ [CHAT_SESSION_PATHS.open, (req, res, c) => handleChatSession(req, res, c, policy, "open")],
3198
+ [CHAT_SESSION_PATHS.bearer, (req, res, c) => handleChatSession(req, res, c, policy, "bearer")],
3199
+ [CHAT_SESSION_PATHS.retire, (req, res, c) => handleChatSession(req, res, c, policy, "retire")],
2894
3200
  [
2895
3201
  "/.well-known/cotal-mesh",
2896
3202
  (req, res) => {
@@ -2923,7 +3229,7 @@ function makePublicHandler(ctx, policy, bundle) {
2923
3229
  try {
2924
3230
  const route = routes.get(req.url ?? "");
2925
3231
  if (!route)
2926
- return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /manager-instances, /.well-known/cotal-mesh" });
3232
+ return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /manager-instances, /chat-session/open, /chat-session/bearer, /chat-session/retire, /.well-known/cotal-mesh" });
2927
3233
  await route(req, res, ctx);
2928
3234
  }
2929
3235
  catch (e) {