@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.
- package/dist/authority-client.d.ts.map +1 -1
- package/dist/authority-client.js +4 -1
- package/dist/authority-client.js.map +1 -1
- package/dist/chat-parent-pin.d.ts +43 -0
- package/dist/chat-parent-pin.d.ts.map +1 -0
- package/dist/chat-parent-pin.js +115 -0
- package/dist/chat-parent-pin.js.map +1 -0
- package/dist/chat-session-authority.d.ts +120 -0
- package/dist/chat-session-authority.d.ts.map +1 -0
- package/dist/chat-session-authority.js +924 -0
- package/dist/chat-session-authority.js.map +1 -0
- package/dist/chat-session-row.d.ts +117 -0
- package/dist/chat-session-row.d.ts.map +1 -0
- package/dist/chat-session-row.js +358 -0
- package/dist/chat-session-row.js.map +1 -0
- package/dist/chat-session-store.d.ts +126 -0
- package/dist/chat-session-store.d.ts.map +1 -0
- package/dist/chat-session-store.js +392 -0
- package/dist/chat-session-store.js.map +1 -0
- package/dist/commands.d.ts +3 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +497 -122
- package/dist/commands.js.map +1 -1
- package/dist/continuity.js +1 -1
- package/dist/continuity.js.map +1 -1
- package/dist/daemon-flags.d.ts +42 -0
- package/dist/daemon-flags.d.ts.map +1 -0
- package/dist/daemon-flags.js +12 -0
- package/dist/daemon-flags.js.map +1 -0
- package/dist/handle-inspect.d.ts +101 -0
- package/dist/handle-inspect.d.ts.map +1 -0
- package/dist/handle-inspect.js +50 -0
- package/dist/handle-inspect.js.map +1 -0
- package/dist/handle-verification.d.ts +75 -0
- package/dist/handle-verification.d.ts.map +1 -0
- package/dist/handle-verification.js +135 -0
- package/dist/handle-verification.js.map +1 -0
- package/dist/idp.d.ts +23 -1
- package/dist/idp.d.ts.map +1 -1
- package/dist/idp.js +54 -8
- package/dist/idp.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +58 -5
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +126 -6
- package/dist/ledger.js.map +1 -1
- package/dist/lifecycle-registry.d.ts +1 -70
- package/dist/lifecycle-registry.d.ts.map +1 -1
- package/dist/lifecycle-registry.js +4 -35
- package/dist/lifecycle-registry.js.map +1 -1
- package/dist/login.d.ts +9 -0
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +53 -8
- package/dist/login.js.map +1 -1
- package/dist/permissions.js +4 -0
- package/dist/permissions.js.map +1 -1
- package/dist/provider.d.ts +23 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +244 -44
- package/dist/provider.js.map +1 -1
- package/dist/service-adapter.d.ts +6 -0
- package/dist/service-adapter.d.ts.map +1 -0
- package/dist/service-adapter.js +120 -0
- package/dist/service-adapter.js.map +1 -0
- package/dist/service.d.ts +61 -2
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +385 -79
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +10 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +25 -8
- package/dist/store.js.map +1 -1
- package/dist/token.d.ts +1 -1
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +4 -1
- package/dist/token.js.map +1 -1
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
|
1161
|
-
//
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
-
|
|
1212
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1613
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
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
|
-
|
|
2355
|
-
|
|
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) {
|