@cotal-ai/auth 0.86.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";
@@ -112,6 +112,8 @@ export const MANAGED_RETIRE_PATH = "/managed-lifecycle/retire";
112
112
  * remote manager (#1972 §2.2). The platform owns the writers; this door owns the decision. It
113
113
  * derives the caller's scope from the local ledger and never accepts one from the caller. */
114
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";
115
117
  /** Failed-exchange rate limit: at most this many REFUSED exchanges per rolling minute; further
116
118
  * attempts get 429 until the window drains. Successes are unthrottled (the CLI's normal path). */
117
119
  const FAILED_EXCHANGE_PER_MIN = 30;
@@ -519,18 +521,9 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
519
521
  return "not registered";
520
522
  return "candidate";
521
523
  };
522
- const selectManagerInstance = async (owner, requested, caller) => {
524
+ const listManagerInstances = async (owner) => {
523
525
  refuseIfFenced();
524
- if (requested !== undefined) {
525
- const instanceId = assertLifecycleToken(requested, "managerInstanceId");
526
- const verdict = await managerGate(owner, instanceId);
527
- if (verdict !== "candidate")
528
- throw new Error(`manager instance ${instanceId} is ${verdict}`);
529
- return instanceId;
530
- }
531
- const remoteInstances = [];
532
- const remoteLive = [];
533
- let localCandidate;
526
+ const instances = [];
534
527
  const localManager = loadLocalManager();
535
528
  for (const raw of await scanner.scanManagerGates()) {
536
529
  if (raw.op !== undefined)
@@ -542,16 +535,26 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
542
535
  const row = parseEndpointGate(raw.data, raw.key);
543
536
  if (row.authorityFamily === "environment-manager")
544
537
  continue;
545
- if (row.principal === `${owner}.${remoteManagerActors(instanceId).serve}`) {
546
- remoteInstances.push(instanceId);
547
- if (await managerGate(owner, instanceId) === "candidate")
548
- remoteLive.push(instanceId);
549
- }
550
- else if (localManager?.instanceId === instanceId && row.principal === `local.${localManager.serveIdentity.id}`) {
551
- if (await managerGate(owner, instanceId) === "candidate")
552
- localCandidate = instanceId;
553
- }
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) });
554
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;
555
558
  remoteLive.sort();
556
559
  if (remoteLive.length === 1)
557
560
  return remoteLive[0];
@@ -628,6 +631,7 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
628
631
  return ensureRootCredential(registry, { ...args, managerInstance: `auth-service:${space}` });
629
632
  },
630
633
  selectManagerInstance,
634
+ listManagerInstances,
631
635
  authorizeManagerCaller: async (owner, instanceId) => {
632
636
  refuseIfFenced();
633
637
  const verdict = await managerGate(owner, assertLifecycleToken(instanceId, "managerInstanceId"));
@@ -1878,6 +1882,7 @@ async function startAuthContext(o) {
1878
1882
  dir,
1879
1883
  mintConnectCredential: plane.mintConnectCredential,
1880
1884
  selectManagerInstance: plane.selectManagerInstance,
1885
+ listManagerInstances: plane.listManagerInstances,
1881
1886
  verifySession: plane.verifySession,
1882
1887
  };
1883
1888
  const loopback = createServer((req, res) => void handle(req, res, ctx));
@@ -2279,16 +2284,18 @@ const ROUTES = new Map([
2279
2284
  ],
2280
2285
  ["/exchange", (req, res, ctx) => handleExchange(req, res, ctx, LOOPBACK_POLICY)],
2281
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)],
2282
2288
  [INTERACTIVE_RETIRE_PATH, handleInteractiveLifecycleRetirement],
2283
2289
  [MANAGED_RETIRE_PATH, handleManagedLifecycleRetirement],
2284
2290
  [VERIFY_ENROLLMENT_PATH, handleVerifyManagedAgentEnrollment],
2291
+ [TOOL_RESULT_HUMAN_PROOF_PATH, handleToolResultHumanProof],
2285
2292
  ]);
2286
2293
  /** Route one HTTP request against the loopback route table. Errors are JSON `{ error }`. */
2287
2294
  async function handle(req, res, ctx) {
2288
2295
  try {
2289
2296
  const route = ROUTES.get(req.url ?? "");
2290
2297
  if (!route)
2291
- 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" });
2292
2299
  await route(req, res, ctx);
2293
2300
  }
2294
2301
  catch (e) {
@@ -2613,6 +2620,45 @@ async function handleExchange(req, res, ctx, policy) {
2613
2620
  return send(res, 401, { error: reason });
2614
2621
  }
2615
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
+ }
2616
2662
  /** The typed manager authority exchange, served on both faces. The face's policy decides whether the
2617
2663
  * loopback capability is demanded; on the public face the verified IdP JWT is the proof, and the
2618
2664
  * dispatcher reads the caller's scope from the ledger either way. */
@@ -2654,6 +2700,40 @@ export async function handleManagerServiceAuthority(req, res, ctx, policy) {
2654
2700
  return send(res, 403, { error: reason });
2655
2701
  }
2656
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
+ }
2657
2737
  /** The advertised broker address the public bundle carries — the URL participants will dial, so
2658
2738
  * the same scheme family `cotal meshes add` accepts. Exported for the producer/consumer contract
2659
2739
  * smoke; returns the refusal sentence, or undefined when the address is usable. */
@@ -2756,6 +2836,7 @@ function makePublicHandler(ctx, policy, bundle) {
2756
2836
  ],
2757
2837
  ["/exchange", (req, res, c) => handleExchange(req, res, c, policy)],
2758
2838
  ["/manager-service-authority", (req, res, c) => handleManagerServiceAuthority(req, res, c, policy)],
2839
+ ["/manager-instances", (req, res, c) => handleManagerInstances(req, res, c, policy)],
2759
2840
  [
2760
2841
  "/.well-known/cotal-mesh",
2761
2842
  (req, res) => {
@@ -2788,7 +2869,7 @@ function makePublicHandler(ctx, policy, bundle) {
2788
2869
  try {
2789
2870
  const route = routes.get(req.url ?? "");
2790
2871
  if (!route)
2791
- 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" });
2792
2873
  await route(req, res, ctx);
2793
2874
  }
2794
2875
  catch (e) {