@cotal-ai/auth 0.62.0 → 0.64.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
@@ -59,7 +59,7 @@ import { resolve } from "node:path";
59
59
  import { connect, credsAuthenticator } from "@nats-io/transport-node";
60
60
  import { jetstreamManager } from "@nats-io/jetstream";
61
61
  import { Kvm } from "@nats-io/kv";
62
- import { contractRefToHex, contractStoreContext, fetchContractArtifact, verifyClusterManifest, verifyClusterRoot, admissionBucket, admissionMediatorGrants, assertDerivedOwnerToken, assertLifecycleToken, assertValidOwnerToken, authorizeTrustedServeSnapshot, commitSiblingIssuance, credsClaims, EpEnvelopeError, ensureAuthorityStores, epAuthBucket, isReachable, jwtFromCreds, managedRetirementOpId, mintCreds, mintPublicUserJwt, newIdentity, observeHostedRunAttempt, openRecordsBucket, parseEndpointGate, parseServiceSpec, parseServiceStatus, rawDigest, parseSecretStoreIdentity, readCheckpointStatus, readRunAdmission, readRunRecord, readSvcRecordLeader, reconcileEndpointGate, sameSecretStoreIdentity, recordSpecKey, recordStatusKey, RECORD_KINDS, recordsBucket, remoteManagerActors, retirementFrontierStreams, runDriverCaller, serveIssuanceGateKv, standaloneConnectOpts, STANDING_RENEWABLE_TTL_SEC, withIssuerSession, authorizeServeGrant, invokeCommand, resolveService, contractArtifactCanonicalBytes, DEV_OWNER, endpointRegistrationBarrier, mintLifecycleUid, principalKey, provisionEndpointGateOpen, publishContractArtifact, registerServiceInstance, SERVICE_READY, writeServiceStatus } from "@cotal-ai/core";
62
+ import { contractRefToHex, contractStoreContext, fetchContractArtifact, verifyClusterManifest, verifyClusterRoot, admissionBucket, admissionMediatorGrants, assertDerivedOwnerToken, assertLifecycleToken, assertValidOwnerToken, authorizeTrustedServeSnapshot, commitSiblingIssuance, credsClaims, EpEnvelopeError, ensureAuthorityStores, epAuthBucket, isReachable, jwtFromCreds, managedRetirementOpId, mintCreds, mintPublicUserJwt, newIdentity, observeHostedRunAttempt, openRecordsBucket, parseEndpointGate, parseServiceSpec, parseServiceStatus, rawDigest, parseSecretStoreIdentity, readCheckpointStatus, readRunAdmission, readRunRecord, readSvcRecordLeader, reconcileEndpointGate, sameSecretStoreIdentity, recordSpecKey, recordStatusKey, RECORD_KINDS, recordsBucket, remoteManagerActors, retirementFrontierStreams, runDriverCaller, serveIssuanceGateKv, standaloneConnectOpts, STANDING_RENEWABLE_TTL_SEC, withIssuerSession, acceptedReadGrant, actorLedgerSource, connectionAcceptedToken, importNativeSubjectPermissions, mintGeneration, parseActorLedgerSource, writeAcceptedRow, authorizeServeGrant, invokeCommand, resolveService, contractArtifactCanonicalBytes, DEV_OWNER, endpointRegistrationBarrier, mintLifecycleUid, principalKey, provisionEndpointGateOpen, publishContractArtifact, registerServiceInstance, SERVICE_READY, writeServiceStatus } from "@cotal-ai/core";
63
63
  import { findCotalRoot, loadManagerInstanceIdentity, userAuthStateDir, workspaceSecretStore, createAuthInstanceIdentity, loadAuthInstanceIdentity } from "@cotal-ai/workspace";
64
64
  import { decodeJwt } from "jose";
65
65
  import { deriveOwnerForIdpSubject, platformControlOwner } from "./derive.js";
@@ -70,15 +70,15 @@ import { PUBLIC_EXCHANGE_VIEWS } from "./token.js";
70
70
  import { grantCoordinates, verifySessionRedemption } from "./session-redemption.js";
71
71
  import { pinnedJwksResolver } from "./issuer.js";
72
72
  import { calloutPermissions } from "./permissions.js";
73
- import { admitRemoteRun, authorizeRemoteManagerRenewal, authorizeRemoteRunAttempt, issueRemoteManagerAuthority, parseRemoteRunAdmissionRequest, parseRemoteRunAttemptRequest } from "./manager-authority.js";
73
+ import { admitRemoteRun, authorizeRemoteManagerRenewal, authorizeRemoteRunAttempt, issueRemoteManagerAuthority, observedRunRequest, parseRemoteRunAdmissionRequest, parseRemoteRunAttemptRequest } from "./manager-authority.js";
74
74
  import { authorizeRemoteRetainedAgentValidation, completeRemoteRetainedAgentValidation, remoteManagerCurrentRegistrationProof } from "./retained-manager-validation.js";
75
75
  import { authorizeRemoteManagedAgentEnrollment, authorizeRemoteManagedAgentPrepareRetirement, authorizeRemoteManagedAgentRuntimeCreate, authorizeRemoteManagedAgentRuntimeStatus } from "./managed-agent-enrollment.js";
76
76
  import { authorizeRemoteManagerGoalIndexScan, completeRemoteManagerGoalIndexScan } from "./manager-goal-index.js";
77
77
  import { authorizeRemoteManagerAdmin } from "./manager-admin-authorization.js";
78
78
  import { authorizeRemoteManagerMaintenance, completeRemoteManagerMaintenance } from "./manager-maintenance.js";
79
79
  import { validateRetainedManagedAgent } from "./continuity.js";
80
- import { reconstructRemoteManagerServeGrant, remoteManagerServeGrantFromCluster } from "./manager-contract.js";
81
- import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, platformReadinessGrants, remoteManagerIssuerGrants, remoteManagerRegistrationProof, authRegistrationExecutorGrants } from "./authority-client.js";
80
+ import { reconstructRemoteManagerServeGrant, remoteManagerServeGrantFromCluster, remoteManagerSurface } from "./manager-contract.js";
81
+ import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, platformReadinessGrants, remoteManagerIssuerGrants, remoteManagerRegistrationProof, authRegistrationExecutorGrants, servedRunRequestSubjects } from "./authority-client.js";
82
82
  import { authorizeConnectCredential } from "./connect-reader.js";
83
83
  import { ensureRootCredential } from "./root-credential.js";
84
84
  import { activateLifecycleAtUid, observeGate, openLifecycleRegistry, readLifecycleHeadForOperation } from "./lifecycle-registry.js";
@@ -93,7 +93,7 @@ import { makeDrainRepairers } from "./drain-repair.js";
93
93
  import { joinOrStartRetirement, openAuthAdminListener } from "./auth-admin.js";
94
94
  import { AUTH_SERVICE_ENDPOINT, authClusterArtifacts, authContractArtifactValues } from "./auth-service-contract.js";
95
95
  import { drainTargetForEndpoint, openAdmissionMediator } from "./admission-mediator.js";
96
- import { AGENT_BEARER_TTL_SEC, findInteractiveActor, findManagedActor, ledgerAclResolver, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, } from "./ledger.js";
96
+ import { AGENT_BEARER_TTL_SEC, findInteractiveActor, findManagedActor, findActorUnified, ledgerAclResolver, ledgerActorSourceIsLive, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, } from "./ledger.js";
97
97
  import { AUTH_PROVIDER_NAME, clearAuthServiceInfo, loadCalloutAuth, loadIssuer, loadOwnerSecret, loadPinnedIdp, loadServiceKeys, saveAuthServiceInfo, spaceIssuer, } from "./store.js";
98
98
  /** JWKS max-age seconds — the cache contract's knob. Exported so a rotation tool can compute the
99
99
  * retire floor (max-age + max bearer TTL) from it. */
@@ -121,6 +121,7 @@ const PUBLIC_FAILED_PER_MIN = 30; // per-PEER refused-exchange window (rolling m
121
121
  const PUBLIC_PEER_BUCKETS_MAX = 1024; // bounded LRU of per-peer failure buckets
122
122
  const PUBLIC_MAX_IN_FLIGHT = 64; // global concurrent-admission cap on the public listener
123
123
  const PUBLIC_DEADLINE_MS = 10_000; // hard wall-clock deadline per public request
124
+ const OBSERVED_RUN_REQUEST_WINDOW_MS = 5 * 60_000; // how long an observed served resume or answer can still be forwarded
124
125
  /** Package-internal policy step for the hosted retirement requester. The HTTP production door calls
125
126
  * this after deriving the owner and manager actors; broker-free smokes exercise the same decision. */
126
127
  export function authorizeRemoteManagerRetirement(args) {
@@ -431,21 +432,21 @@ export async function openAuthAuthorityPlane(opts) {
431
432
  const fileArm = ledgerAuthorizeConnect(opts.dir);
432
433
  const recordsJsm = await jetstreamManager(remoteIssuer.nc);
433
434
  const loadLocalManager = opts.localManager ?? (() => undefined);
434
- // Standing renewal re-derives the serve surface from the REGISTERED service spec at the gate's
435
- // registration revision: spec (leader read) -> closure manifest -> root document, each verified
436
- // against its content digest. The request never selects the surface.
435
+ // Standing renewal and a served resume or answer read the manager surface from the REGISTERED
436
+ // service spec at the gate's registration revision: spec (leader read) -> closure manifest -> root
437
+ // document, each verified against its content digest. The request never selects the surface.
437
438
  const registeredManagerCluster = async (owner, instanceId, observed) => {
438
439
  const rec = await readSvcRecordLeader(recordsJsm, space, recordSpecKey(RECORD_KINDS.svc, ["manager", instanceId]));
439
440
  if (!rec || "deleted" in rec || rec.revision !== observed.registrationRevision)
440
- throw new EpEnvelopeError("failed-precondition", "manager-service renewal found no service spec at the gate's registration revision");
441
+ throw new EpEnvelopeError("failed-precondition", "found no manager service spec at the gate's registration revision");
441
442
  const spec = parseServiceSpec(rec.value, { endpoint: "manager" });
442
443
  if (spec.owner !== owner)
443
- throw new EpEnvelopeError("permission-denied", "manager-service renewal spec belongs to another owner");
444
+ throw new EpEnvelopeError("permission-denied", "the manager service spec belongs to another owner");
444
445
  const store = await contractStoreContext(remoteIssuer.nc, space);
445
446
  const read = async (digest) => {
446
447
  const bytes = await fetchContractArtifact(store, contractRefToHex(digest));
447
448
  if (!bytes)
448
- throw new EpEnvelopeError("failed-precondition", `manager-service renewal cannot read registered contract artifact ${digest}`);
449
+ throw new EpEnvelopeError("failed-precondition", `cannot read registered manager contract artifact ${digest}`);
449
450
  return JSON.parse(new TextDecoder().decode(bytes));
450
451
  };
451
452
  for (const closure of spec.clusterDigests) {
@@ -454,7 +455,7 @@ export async function openAuthAuthorityPlane(opts) {
454
455
  if (verifyClusterRoot(root, document).urn === "ai.cotal.manager")
455
456
  return document;
456
457
  }
457
- throw new EpEnvelopeError("failed-precondition", "manager-service renewal spec registers no manager cluster");
458
+ throw new EpEnvelopeError("failed-precondition", "the manager service spec registers no manager cluster");
458
459
  };
459
460
  const managerGate = async (owner, instanceId) => {
460
461
  const localManager = loadLocalManager();
@@ -532,6 +533,47 @@ export async function openAuthAuthorityPlane(opts) {
532
533
  if (fatalReason !== undefined)
533
534
  throw new EpEnvelopeError("unavailable", `the auth service for space "${space}" is momentarily unavailable (it detected a fault and is restarting); retry shortly`);
534
535
  };
536
+ // The issuer window's signer: the data account's signing key, the same `hostAuth` shape the run
537
+ // admission builds.
538
+ const issuerAuth = () => ({
539
+ space,
540
+ operator: { seed: "", jwt: "" },
541
+ account: { pub: dataAccount.pub, seed: "", jwt: "", signingSeed: dataAccount.signingSeed, signingPub: "" },
542
+ sys: { pub: "", jwt: "" },
543
+ });
544
+ // The source check the issuing host resolves with: an actor-ledger row is attested here, from the
545
+ // ledger this service owns; every other coordinate goes to core's check (SPEC 13.15).
546
+ const composedSourceIsLive = (session) => (source) => parseActorLedgerSource(source) ? Promise.resolve(ledgerActorSourceIsLive(opts.dir)(source)) : session.sourceIsLive(source);
547
+ // A served resume or answer is issued only for a request this host saw its caller publish, only
548
+ // once, and only for what that request's envelope asked (SPEC 14.8): the forward's coordinates
549
+ // are the manager's word. The manager forwards from inside the handler serving the request, so an
550
+ // observation older than the window has no forward left to bind; the window also bounds the table.
551
+ const observedRunRequests = new Map();
552
+ for (const subject of servedRunRequestSubjects(space))
553
+ remoteIssuer.nc.subscribe(subject, {
554
+ callback: (_err, msg) => {
555
+ const now = Date.now();
556
+ for (const [seen, { expires }] of observedRunRequests) {
557
+ if (expires > now)
558
+ break;
559
+ observedRunRequests.delete(seen);
560
+ }
561
+ const request = observedRunRequest(msg.subject, msg.data);
562
+ if (request === undefined)
563
+ return;
564
+ // Re-inserting keeps the table in expiry order, which the prune above stops on.
565
+ observedRunRequests.delete(msg.subject);
566
+ observedRunRequests.set(msg.subject, { expires: now + OBSERVED_RUN_REQUEST_WINDOW_MS, request });
567
+ },
568
+ });
569
+ const takeObservedRunRequest = async (subject) => {
570
+ // The broker queued the request to this connection before the manager could read it, and a
571
+ // flush returns only after this connection has read everything its server queued before it.
572
+ await remoteIssuer.nc.flush();
573
+ const observed = observedRunRequests.get(subject);
574
+ observedRunRequests.delete(subject);
575
+ return observed !== undefined && observed.expires > Date.now() ? observed.request : undefined;
576
+ };
535
577
  return {
536
578
  authorizeConnect: async (t) => {
537
579
  refuseIfFenced();
@@ -549,6 +591,40 @@ export async function openAuthAuthorityPlane(opts) {
549
591
  if (verdict !== "candidate")
550
592
  throw new Error(`manager instance ${instanceId} is ${verdict} at the permission mint`);
551
593
  },
594
+ issueUserCaller: async ({ t, connId, mint }) => {
595
+ refuseIfFenced();
596
+ const owner = assertDerivedOwnerToken(t.owner);
597
+ const actor = t.act.actor;
598
+ const uid = assertLifecycleToken(t.act.lifecycleUid ?? "", "lifecycleUid");
599
+ const acceptedToken = connectionAcceptedToken(connId);
600
+ return withIssuerSession({ servers: server, space, auth: issuerAuth(), tls: false }, async (session) => {
601
+ // The row this connection's nonce names, read the way the client reads it.
602
+ const existing = await session.nc.request(acceptedReadGrant(space, acceptedToken), new Uint8Array(0), { timeout: 3000 });
603
+ const code = existing.headers?.code ?? 0;
604
+ const status = existing.headers?.get("Status") ?? "";
605
+ const missing = code === 404 || status.startsWith("404");
606
+ if (!missing && (code >= 400 || status !== ""))
607
+ throw new Error(`callout permissions: the accepted-row read for this connection failed (${code || status})`);
608
+ if (missing) {
609
+ const generation = mintGeneration();
610
+ const perms = mint({ generation, acceptedToken });
611
+ const ref = { space, owner, actor, uid, generation };
612
+ const prepared = await session.store.stage({
613
+ version: 1, ref, sources: [actorLedgerSource(space, owner, actor, uid)], permissions: importNativeSubjectPermissions(perms),
614
+ });
615
+ await session.store.release(prepared, async () => { });
616
+ await writeAcceptedRow(session.accepted, acceptedToken, ref);
617
+ return perms;
618
+ }
619
+ const row = JSON.parse(new TextDecoder().decode(existing.data));
620
+ const ref = row.ref;
621
+ if (row.version !== 1 || ref === undefined || ref.space !== space || ref.owner !== owner || ref.actor !== actor || ref.uid !== uid)
622
+ throw new Error("callout permissions: this connection's accepted row names another issued reference; reconnect under a new nonce");
623
+ const perms = mint({ generation: ref.generation, acceptedToken });
624
+ await session.store.confirm(ref, importNativeSubjectPermissions(perms));
625
+ return perms;
626
+ });
627
+ },
552
628
  verifySession: async (principal, claim) => {
553
629
  refuseIfFenced();
554
630
  return verifySessionRedemption({
@@ -996,7 +1072,7 @@ export async function openAuthAuthorityPlane(opts) {
996
1072
  return gate.observe();
997
1073
  },
998
1074
  issued: session.store,
999
- sourceIsLive: (source) => session.sourceIsLive(source),
1075
+ sourceIsLive: composedSourceIsLive(session),
1000
1076
  admissions: await new Kvm(admitterNc).open(admissionBucket(space)),
1001
1077
  });
1002
1078
  }
@@ -1011,7 +1087,19 @@ export async function openAuthAuthorityPlane(opts) {
1011
1087
  requireManagerAuthorityHolder(holder, String(request?.instanceId), 'manager run attempt needs scope "supervise"; spawn/admin do not imply it');
1012
1088
  const req = parseRemoteRunAttemptRequest(request);
1013
1089
  const recordsKv = await openRecordsBucket(remoteIssuer.nc, space);
1014
- const grant = await authorizeRemoteRunAttempt({
1090
+ const authorize = (session) => authorizeRemoteRunAttempt({
1091
+ ...(session ? { issued: session.store, sourceIsLive: composedSourceIsLive(session) } : {}),
1092
+ isLiveManagedActor: (owner, actor, lifecycleUid) => {
1093
+ const row = findActorUnified(opts.dir, owner, actor);
1094
+ return row?.kind === "managed-agent" && row.lifecycleUid === lifecycleUid;
1095
+ },
1096
+ takeObserved: takeObservedRunRequest,
1097
+ registeredCommand: async (instanceId, registrationRevision, command) => {
1098
+ const registered = remoteManagerSurface(await registeredManagerCluster(owner, instanceId, { registrationRevision }))[command];
1099
+ if (registered === undefined)
1100
+ throw new EpEnvelopeError("failed-precondition", `the registered manager cluster declares no ${command}`);
1101
+ return registered;
1102
+ },
1015
1103
  request,
1016
1104
  owner,
1017
1105
  space,
@@ -1025,7 +1113,15 @@ export async function openAuthAuthorityPlane(opts) {
1025
1113
  readAdmission: (runId) => readRunAdmission(recordsJsm, space, "manager", runId),
1026
1114
  readRunStatus: async (runId) => (await readRunRecord(recordsKv, "manager", runId))?.status?.value,
1027
1115
  checkpointWaiting: async (token) => (await readCheckpointStatus(recordsKv, { endpoint: "manager", token }))?.value.state === "waiting",
1116
+ checkpointSettled: async (token) => {
1117
+ const status = (await readCheckpointStatus(recordsKv, { endpoint: "manager", token }))?.value;
1118
+ return status?.state === "resumed" && status.settledAnswerId !== undefined;
1119
+ },
1028
1120
  });
1121
+ // A served resume or answer resolves its caller's issuance, which needs one issuer window.
1122
+ const grant = req.attempt?.served !== undefined || req.operator?.served !== undefined
1123
+ ? await withIssuerSession({ servers: server, space, auth: issuerAuth(), tls: false }, (session) => authorize(session))
1124
+ : await authorize();
1029
1125
  const hostAuth = {
1030
1126
  space,
1031
1127
  operator: { seed: "", jwt: "" },
@@ -1378,7 +1474,7 @@ async function startAuthContext(o) {
1378
1474
  space,
1379
1475
  token: { key: issuer.localKeySet(), issuer: issuer.issuer },
1380
1476
  authorizeActor: plane.authorizeConnect,
1381
- permissionsFor: calloutPermissions(ledgerAclResolver(dir), plane.authorizeManagerCaller, plane.verifySession),
1477
+ permissionsFor: calloutPermissions(ledgerAclResolver(dir), plane.authorizeManagerCaller, plane.verifySession, plane.issueUserCaller),
1382
1478
  log: (l) => console.error(l),
1383
1479
  });
1384
1480
  // The subscription must be ON the broker before readiness is signaled — an `up` that recorded a