@cotal-ai/auth 0.85.0 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/service.js CHANGED
@@ -68,7 +68,7 @@ import { makePlatformControlAuthority, makePlatformControlReadiness, requireMana
68
68
  import { OwnedConnections } from "./owned-connections.js";
69
69
  import { makePlatformSupervisorAuthority, makePlatformSupervisorEnd, platformSupervisorEnded, requirePlatformSupervisorAdmin } from "./platform-supervisor.js";
70
70
  import { startAuthCallout } from "./callout.js";
71
- import { createIdpBridge, verifyIdpToken } from "./idp.js";
71
+ import { createIdpBridge, verifyIdpToken, verifyToolResultHumanProof } from "./idp.js";
72
72
  import { PUBLIC_EXCHANGE_VIEWS, assertTransferWriterClaim } from "./token.js";
73
73
  import { grantCoordinates, verifySessionRedemption } from "./session-redemption.js";
74
74
  import { pinnedJwksResolver } from "./issuer.js";
@@ -83,6 +83,7 @@ import { validateRetainedManagedAgent } from "./continuity.js";
83
83
  import { reconstructRemoteManagerServeGrant, remoteManagerServeGrantFromCluster, remoteManagerSurface } from "./manager-contract.js";
84
84
  import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, platformReadinessGrants, remoteManagerIssuerGrants, remoteManagerRegistrationProof, registrationExecutorGrants, servedRunRequestSubjects } from "./authority-client.js";
85
85
  import { authorizeConnectCredential } from "./connect-reader.js";
86
+ import { makeNativeIssuanceRecorder, pruneNativeIssuance } from "./native-issuance.js";
86
87
  import { ensureRootCredential } from "./root-credential.js";
87
88
  import { activateLifecycleAtUid, observeGate, openLifecycleRegistry, readLifecycleHeadForOperation } from "./lifecycle-registry.js";
88
89
  import { openAuthLedgerScannerCandidate } from "./ledger-scanner.js";
@@ -111,6 +112,8 @@ export const MANAGED_RETIRE_PATH = "/managed-lifecycle/retire";
111
112
  * remote manager (#1972 §2.2). The platform owns the writers; this door owns the decision. It
112
113
  * derives the caller's scope from the local ledger and never accepts one from the caller. */
113
114
  export const VERIFY_ENROLLMENT_PATH = "/manager-service-authority/verify-enrollment";
115
+ /** Host-only, capability-gated proof decision. It neither signs consent nor serves results. */
116
+ export const TOOL_RESULT_HUMAN_PROOF_PATH = "/tool-result-human-proof/verify";
114
117
  /** Failed-exchange rate limit: at most this many REFUSED exchanges per rolling minute; further
115
118
  * attempts get 429 until the window drains. Successes are unthrottled (the CLI's normal path). */
116
119
  const FAILED_EXCHANGE_PER_MIN = 30;
@@ -132,6 +135,14 @@ const OBSERVED_RUN_REQUEST_WINDOW_MS = 5 * 60_000; // how long an observed serve
132
135
  * ledger directory, so two plane instances over one ledger share it. */
133
136
  const MANAGED_ALIAS_CHAINS = new Map();
134
137
  const serializedForAlias = (dir, owner, actor, fn) => serializedFor(MANAGED_ALIAS_CHAINS, `${resolve(dir)}:${principalKey(owner, actor).key}`, fn);
138
+ /** How each caller names one manager instance, for the refusal that needs it to. The agent arm's
139
+ * one client is `cotal agent-bearer`; a connector that runs it for a tool renames the flag to the
140
+ * tool's `instance` argument. */
141
+ const MANAGER_PIN_REMEDY = {
142
+ human: "name one with --on <instanceId>",
143
+ agent: "name one with --manager-instance <instanceId>",
144
+ wire: "name one as managerInstanceId",
145
+ };
135
146
  /** Package-internal policy step for the hosted retirement requester. The HTTP production door calls
136
147
  * this after deriving the owner and manager actors; broker-free smokes exercise the same decision. */
137
148
  export function authorizeRemoteManagerRetirement(args) {
@@ -414,6 +425,39 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
414
425
  const authAdmin = await openAuthAdminListener({ onConnection, server, space, dataAccount, reg: barrierReg, retirement, barrierFlight: retirementFlights, instanceId: authServeInstanceId, epoch: authServeGrant.epoch, grant: authServeGrant, log });
415
426
  unwind.push(() => authAdmin.close());
416
427
  const fileArm = ledgerAuthorizeConnect(opts.dir);
428
+ // The callout's issuance evidence: create-only natkey row, then the connect authority re-read
429
+ // in the same stream before the minted JWT may leave.
430
+ const recordIssuance = makeNativeIssuanceRecorder(registry, async (t) => {
431
+ refuseIfFenced();
432
+ fileArm(t);
433
+ await authorizeConnectCredential(reader.current(), t, Date.now);
434
+ });
435
+ // Expired evidence goes at boot (a failure refuses the open) and then on the plane's own timer;
436
+ // a failed pass fences the plane like a scanner death, never a silently unbounded recorder.
437
+ const pruneMs = opts.nativeIssuancePruneMs ?? 60_000;
438
+ if (!Number.isSafeInteger(pruneMs) || pruneMs < 100 || pruneMs > 3_600_000)
439
+ throw new Error(`nativeIssuancePruneMs must be an integer between 100 and 3600000 (got ${pruneMs})`);
440
+ await pruneNativeIssuance(registry, Math.floor(Date.now() / 1000));
441
+ let pruneTimer;
442
+ let pruneFlight = Promise.resolve();
443
+ const schedulePrune = () => {
444
+ if (closing || fatalReason !== undefined)
445
+ return;
446
+ pruneTimer = setTimeout(() => {
447
+ pruneFlight = pruneNativeIssuance(registry, Math.floor(Date.now() / 1000)).then(schedulePrune, (e) => {
448
+ if (closing)
449
+ return;
450
+ const copy = `native-issuance cleanup failed: ${e instanceof Error ? e.message : String(e)}`;
451
+ hold?.fence(copy);
452
+ fatalReason = fatalReason ?? copy;
453
+ fireFatal(copy);
454
+ log(`auth-plane: ${copy}`);
455
+ });
456
+ }, pruneMs);
457
+ };
458
+ const stopPrune = async () => { clearTimeout(pruneTimer); await pruneFlight; };
459
+ unwind.push(async () => { closing = true; await stopPrune(); });
460
+ schedulePrune();
417
461
  const recordsJsm = await jetstreamManager(remoteIssuer.nc);
418
462
  const authKv = await new Kvm(remoteIssuer.nc).open(epAuthBucket(space));
419
463
  const managerIssuanceGate = (instanceId) => serveIssuanceGateKv(authKv, space, { endpoint: "manager", instanceId });
@@ -477,18 +521,9 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
477
521
  return "not registered";
478
522
  return "candidate";
479
523
  };
480
- const selectManagerInstance = async (owner, requested) => {
524
+ const listManagerInstances = async (owner) => {
481
525
  refuseIfFenced();
482
- if (requested !== undefined) {
483
- const instanceId = assertLifecycleToken(requested, "managerInstanceId");
484
- const verdict = await managerGate(owner, instanceId);
485
- if (verdict !== "candidate")
486
- throw new Error(`manager instance ${instanceId} is ${verdict}`);
487
- return instanceId;
488
- }
489
- const remoteInstances = [];
490
- const remoteLive = [];
491
- let localCandidate;
526
+ const instances = [];
492
527
  const localManager = loadLocalManager();
493
528
  for (const raw of await scanner.scanManagerGates()) {
494
529
  if (raw.op !== undefined)
@@ -500,23 +535,33 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
500
535
  const row = parseEndpointGate(raw.data, raw.key);
501
536
  if (row.authorityFamily === "environment-manager")
502
537
  continue;
503
- if (row.principal === `${owner}.${remoteManagerActors(instanceId).serve}`) {
504
- remoteInstances.push(instanceId);
505
- if (await managerGate(owner, instanceId) === "candidate")
506
- remoteLive.push(instanceId);
507
- }
508
- else if (localManager?.instanceId === instanceId && row.principal === `local.${localManager.serveIdentity.id}`) {
509
- if (await managerGate(owner, instanceId) === "candidate")
510
- localCandidate = instanceId;
511
- }
538
+ if (row.principal === `${owner}.${remoteManagerActors(instanceId).serve}`)
539
+ instances.push({ instanceId, placement: "remote", state: await managerGate(owner, instanceId) });
540
+ else if (localManager?.instanceId === instanceId && row.principal === `local.${localManager.serveIdentity.id}`)
541
+ instances.push({ instanceId, placement: "local", state: await managerGate(owner, instanceId) });
512
542
  }
543
+ return { v: 1, owner, complete: true, instances };
544
+ };
545
+ const selectManagerInstance = async (owner, requested, caller) => {
546
+ refuseIfFenced();
547
+ if (requested !== undefined) {
548
+ const instanceId = assertLifecycleToken(requested, "managerInstanceId");
549
+ const verdict = await managerGate(owner, instanceId);
550
+ if (verdict !== "candidate")
551
+ throw new Error(`manager instance ${instanceId} is ${verdict}`);
552
+ return instanceId;
553
+ }
554
+ const { instances } = await listManagerInstances(owner);
555
+ const remoteInstances = instances.filter((i) => i.placement === "remote").map((i) => i.instanceId);
556
+ const remoteLive = instances.filter((i) => i.placement === "remote" && i.state === "candidate").map((i) => i.instanceId);
557
+ const localCandidate = instances.findLast((i) => i.placement === "local" && i.state === "candidate")?.instanceId;
513
558
  remoteLive.sort();
514
559
  if (remoteLive.length === 1)
515
560
  return remoteLive[0];
516
561
  if (remoteLive.length > 1)
517
- throw new Error(`manager-caller selection found ${remoteLive.length} live remote manager candidates for owner ${owner}; exactly one is required or name --manager-instance`);
562
+ throw new Error(`manager-caller selection found ${remoteLive.length} live remote manager candidates for owner ${owner} (${remoteLive.join(", ")}); exactly one is required, so ${MANAGER_PIN_REMEDY[caller ?? "wire"]}`);
518
563
  if (remoteInstances.length > 0)
519
- 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`);
564
+ throw new Error(`the remote manager for owner ${owner} is unavailable (${remoteInstances.length} gate${remoteInstances.length === 1 ? "" : "s"}, none live); start one with \`cotal supervise\` and retry, as this call is never routed to the local manager`);
520
565
  if (localCandidate !== undefined)
521
566
  return localCandidate;
522
567
  throw new Error(`manager-caller selection found no manager candidate for owner ${owner}`);
@@ -577,11 +622,16 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
577
622
  fileArm(t);
578
623
  await authorizeConnectCredential(reader.current(), t, Date.now);
579
624
  },
625
+ recordIssuance: (i) => {
626
+ refuseIfFenced();
627
+ return recordIssuance(i);
628
+ },
580
629
  mintConnectCredential: (args) => {
581
630
  refuseIfFenced();
582
631
  return ensureRootCredential(registry, { ...args, managerInstance: `auth-service:${space}` });
583
632
  },
584
633
  selectManagerInstance,
634
+ listManagerInstances,
585
635
  authorizeManagerCaller: async (owner, instanceId) => {
586
636
  refuseIfFenced();
587
637
  const verdict = await managerGate(owner, assertLifecycleToken(instanceId, "managerInstanceId"));
@@ -607,7 +657,7 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
607
657
  const perms = mint({ generation, acceptedToken });
608
658
  const ref = { space, owner, actor, uid, generation };
609
659
  const prepared = await session.store.stage({
610
- version: 1, ref, sources: [actorLedgerSource(space, owner, actor, uid)], permissions: importNativeSubjectPermissions(perms),
660
+ version: 1, ref, sources: [actorLedgerSource(space, owner, actor, uid)], permissions: importNativeSubjectPermissions(perms), expiresAt: t.exp,
611
661
  });
612
662
  await session.store.release(prepared, async () => { });
613
663
  await writeAcceptedRow(session.accepted, acceptedToken, ref);
@@ -1447,6 +1497,7 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
1447
1497
  // act), then the barrier that wrote it. An unreachable release keeps the barrier alive
1448
1498
  // for a later close retry; successful scanner teardown is not run a second time.
1449
1499
  closing = true;
1500
+ await stopPrune();
1450
1501
  const scannerFlight = closeScanners ??= (async () => {
1451
1502
  await authAdmin?.close();
1452
1503
  const results = await Promise.allSettled([reader.close(), recordsScanner.close(), scanner.close()]);
@@ -1776,6 +1827,7 @@ async function startAuthContext(o) {
1776
1827
  space,
1777
1828
  token: { key: issuer.localKeySet(), issuer: issuer.issuer },
1778
1829
  authorizeActor: plane.authorizeConnect,
1830
+ recordIssuance: plane.recordIssuance,
1779
1831
  permissionsFor: calloutPermissions(ledgerAclResolver(dir), plane.authorizeManagerCaller, plane.verifySession, plane.issueUserCaller),
1780
1832
  log: (l) => console.error(l),
1781
1833
  });
@@ -1830,6 +1882,7 @@ async function startAuthContext(o) {
1830
1882
  dir,
1831
1883
  mintConnectCredential: plane.mintConnectCredential,
1832
1884
  selectManagerInstance: plane.selectManagerInstance,
1885
+ listManagerInstances: plane.listManagerInstances,
1833
1886
  verifySession: plane.verifySession,
1834
1887
  };
1835
1888
  const loopback = createServer((req, res) => void handle(req, res, ctx));
@@ -2231,16 +2284,18 @@ const ROUTES = new Map([
2231
2284
  ],
2232
2285
  ["/exchange", (req, res, ctx) => handleExchange(req, res, ctx, LOOPBACK_POLICY)],
2233
2286
  ["/manager-service-authority", (req, res, ctx) => handleManagerServiceAuthority(req, res, ctx, LOOPBACK_POLICY)],
2287
+ ["/manager-instances", (req, res, ctx) => handleManagerInstances(req, res, ctx, LOOPBACK_POLICY)],
2234
2288
  [INTERACTIVE_RETIRE_PATH, handleInteractiveLifecycleRetirement],
2235
2289
  [MANAGED_RETIRE_PATH, handleManagedLifecycleRetirement],
2236
2290
  [VERIFY_ENROLLMENT_PATH, handleVerifyManagedAgentEnrollment],
2291
+ [TOOL_RESULT_HUMAN_PROOF_PATH, handleToolResultHumanProof],
2237
2292
  ]);
2238
2293
  /** Route one HTTP request against the loopback route table. Errors are JSON `{ error }`. */
2239
2294
  async function handle(req, res, ctx) {
2240
2295
  try {
2241
2296
  const route = ROUTES.get(req.url ?? "");
2242
2297
  if (!route)
2243
- return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /manager-service-authority/verify-enrollment, /interactive-lifecycle/retire, /managed-lifecycle/retire" });
2298
+ 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" });
2244
2299
  await route(req, res, ctx);
2245
2300
  }
2246
2301
  catch (e) {
@@ -2501,7 +2556,7 @@ async function handleExchange(req, res, ctx, policy) {
2501
2556
  // and rides act.credentialId — the connect arm requires it against the LIVE cred row.
2502
2557
  const credentialId = await ctx.mintConnectCredential({ owner, actor, lifecycleUid: grant.lifecycleUid });
2503
2558
  const selectedManagerInstanceId = view === "manager-caller"
2504
- ? await ctx.selectManagerInstance(owner, managerInstanceId)
2559
+ ? await ctx.selectManagerInstance(owner, managerInstanceId, "agent")
2505
2560
  : undefined;
2506
2561
  const token = await ctx.issuer.issue({
2507
2562
  owner,
@@ -2536,7 +2591,7 @@ async function handleExchange(req, res, ctx, policy) {
2536
2591
  // The bridge validates `view` against the closed enum and the fresh ledger grant — an
2537
2592
  // unknown or under-scoped view is a refused exchange (audited + throttled like any other).
2538
2593
  const selectedManagerInstanceId = view === "manager-caller"
2539
- ? await ctx.selectManagerInstance(deriveOwnerForIdpSubject(ctx.ownerSecret, ctx.bridgeIdp.issuer, (await verifyIdpToken(idpToken, ctx.bridgeIdp)).sub), managerInstanceId)
2594
+ ? await ctx.selectManagerInstance(deriveOwnerForIdpSubject(ctx.ownerSecret, ctx.bridgeIdp.issuer, (await verifyIdpToken(idpToken, ctx.bridgeIdp)).sub), managerInstanceId, "human")
2540
2595
  : undefined;
2541
2596
  // #2312: a session-caller bearer is signed only once the identity plane has decided that THIS
2542
2597
  // principal holds THAT redeemed session. The bridge's `verifySession` hook runs the decision on
@@ -2565,6 +2620,45 @@ async function handleExchange(req, res, ctx, policy) {
2565
2620
  return send(res, 401, { error: reason });
2566
2621
  }
2567
2622
  }
2623
+ /** The signed-in person's own Manager instances, read-only, on both faces. The proof is the IdP JWT
2624
+ * the human exchange verifies, and the owner is derived from it the same way, so a caller lists only
2625
+ * its own owner's gates. A browser-origin request is refused as on every exchange route; an
2626
+ * embedding's server-side bridge forwards the person's proof. Issuance still re-checks the chosen
2627
+ * instance. */
2628
+ async function handleManagerInstances(req, res, ctx, policy) {
2629
+ if (req.method !== "POST")
2630
+ return send(res, 405, { error: "POST only" });
2631
+ if (req.headers.origin !== undefined)
2632
+ return send(res, 403, { error: "browser-origin requests are not served here" });
2633
+ if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
2634
+ return send(res, 415, { error: "content-type must be application/json" });
2635
+ if (policy.requireCapability && req.headers.authorization !== `Bearer ${ctx.cap}`)
2636
+ return refuseCapability(res, ctx, "missing/invalid exchange capability - read it from the space's auth-service.json");
2637
+ const peer = policy.peerKey(req);
2638
+ const peerThrottled = policy.throttled(ctx, peer);
2639
+ const body = await readJsonBody(req);
2640
+ if (typeof body?.idpToken !== "string" || !body.idpToken)
2641
+ return send(res, 400, { error: "the manager instance list needs { idpToken: string }" });
2642
+ let owner;
2643
+ try {
2644
+ owner = deriveOwnerForIdpSubject(ctx.ownerSecret, ctx.bridgeIdp.issuer, (await verifyIdpToken(body.idpToken, ctx.bridgeIdp)).sub);
2645
+ }
2646
+ catch (e) {
2647
+ policy.recordFailure(ctx, peer);
2648
+ const reason = e instanceof Error ? e.message : String(e);
2649
+ console.error(`auth-service: refused a manager instance list: ${reason}`);
2650
+ if (peerThrottled)
2651
+ return send(res, 429, { error: "too many refused exchanges - wait a minute and retry" });
2652
+ return send(res, 401, { error: reason });
2653
+ }
2654
+ try {
2655
+ return send(res, 200, await ctx.listManagerInstances(owner));
2656
+ }
2657
+ catch (e) {
2658
+ // The proof was good and the gate read was not: unavailable, never an empty list.
2659
+ return send(res, 503, { error: `the manager instance list is unavailable: ${e instanceof Error ? e.message : String(e)}` });
2660
+ }
2661
+ }
2568
2662
  /** The typed manager authority exchange, served on both faces. The face's policy decides whether the
2569
2663
  * loopback capability is demanded; on the public face the verified IdP JWT is the proof, and the
2570
2664
  * dispatcher reads the caller's scope from the ledger either way. */
@@ -2606,6 +2700,40 @@ export async function handleManagerServiceAuthority(req, res, ctx, policy) {
2606
2700
  return send(res, 403, { error: reason });
2607
2701
  }
2608
2702
  }
2703
+ /** Decide one signed password proof against the host's authenticated session subject.
2704
+ * The subject is an equality constraint, never the source of the derived owner. */
2705
+ export async function handleToolResultHumanProof(req, res, ctx) {
2706
+ if (req.method !== "POST")
2707
+ return send(res, 405, { error: "POST only" });
2708
+ if (req.headers.origin !== undefined)
2709
+ return send(res, 403, { error: "browser-origin requests are not served here" });
2710
+ if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
2711
+ return send(res, 415, { error: "content-type must be application/json" });
2712
+ if (req.headers.authorization !== `Bearer ${ctx.cap}`)
2713
+ return refuseCapability(res, ctx, "missing/invalid exchange capability - human proof verification is a loopback host action");
2714
+ let body;
2715
+ try {
2716
+ body = await readJsonBody(req);
2717
+ }
2718
+ catch (error) {
2719
+ return send(res, error instanceof RequestBodyError ? error.status : 400, { error: "invalid human proof request" });
2720
+ }
2721
+ if (!body || typeof body !== "object" || Array.isArray(body) ||
2722
+ Object.keys(body).some(key => key !== "idpToken" && key !== "subject") ||
2723
+ !("idpToken" in body) || typeof body.idpToken !== "string" || !body.idpToken ||
2724
+ !("subject" in body) || typeof body.subject !== "string" || !body.subject)
2725
+ return send(res, 400, { error: "human proof verification needs only { idpToken, subject }" });
2726
+ try {
2727
+ const proof = await verifyToolResultHumanProof(body.idpToken, ctx.bridgeIdp);
2728
+ if (proof.sub !== body.subject)
2729
+ return send(res, 403, { error: "human proof subject does not match the session" });
2730
+ const owner = deriveOwnerForIdpSubject(ctx.ownerSecret, ctx.bridgeIdp.issuer, proof.sub);
2731
+ return send(res, 200, { authorized: true, owner, ...proof });
2732
+ }
2733
+ catch {
2734
+ return send(res, 403, { error: "fresh signed password proof required" });
2735
+ }
2736
+ }
2609
2737
  /** The advertised broker address the public bundle carries — the URL participants will dial, so
2610
2738
  * the same scheme family `cotal meshes add` accepts. Exported for the producer/consumer contract
2611
2739
  * smoke; returns the refusal sentence, or undefined when the address is usable. */
@@ -2708,6 +2836,7 @@ function makePublicHandler(ctx, policy, bundle) {
2708
2836
  ],
2709
2837
  ["/exchange", (req, res, c) => handleExchange(req, res, c, policy)],
2710
2838
  ["/manager-service-authority", (req, res, c) => handleManagerServiceAuthority(req, res, c, policy)],
2839
+ ["/manager-instances", (req, res, c) => handleManagerInstances(req, res, c, policy)],
2711
2840
  [
2712
2841
  "/.well-known/cotal-mesh",
2713
2842
  (req, res) => {
@@ -2740,7 +2869,7 @@ function makePublicHandler(ctx, policy, bundle) {
2740
2869
  try {
2741
2870
  const route = routes.get(req.url ?? "");
2742
2871
  if (!route)
2743
- return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /.well-known/cotal-mesh" });
2872
+ return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /manager-instances, /.well-known/cotal-mesh" });
2744
2873
  await route(req, res, ctx);
2745
2874
  }
2746
2875
  catch (e) {