@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/authority-client.d.ts +3 -2
- package/dist/authority-client.d.ts.map +1 -1
- package/dist/authority-client.js +4 -2
- package/dist/authority-client.js.map +1 -1
- package/dist/callout.d.ts +10 -2
- package/dist/callout.d.ts.map +1 -1
- package/dist/callout.js +9 -2
- package/dist/callout.js.map +1 -1
- package/dist/idp.d.ts +19 -4
- package/dist/idp.d.ts.map +1 -1
- package/dist/idp.js +19 -1
- package/dist/idp.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/native-issuance.d.ts +62 -0
- package/dist/native-issuance.d.ts.map +1 -0
- package/dist/native-issuance.js +102 -0
- package/dist/native-issuance.js.map +1 -0
- package/dist/permissions.d.ts +6 -5
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +2 -2
- package/dist/permissions.js.map +1 -1
- package/dist/provider.js +2 -1
- package/dist/provider.js.map +1 -1
- package/dist/service.d.ts +38 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +157 -28
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +21 -8
- package/dist/store.js.map +1 -1
- package/package.json +4 -4
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
|
|
524
|
+
const listManagerInstances = async (owner) => {
|
|
481
525
|
refuseIfFenced();
|
|
482
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
|
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);
|
|
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) {
|