@cotal-ai/auth 0.57.0 → 0.58.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
@@ -54,11 +54,12 @@
54
54
  */
55
55
  import { createHash, randomBytes } from "node:crypto";
56
56
  import { createServer } from "node:http";
57
+ import { resolve } from "node:path";
57
58
  import { connect, credsAuthenticator } from "@nats-io/transport-node";
58
59
  import { jetstreamManager } from "@nats-io/jetstream";
59
60
  import { Kvm } from "@nats-io/kv";
60
- import { admissionMediatorGrants, assertDerivedOwnerToken, assertLifecycleToken, assertValidOwnerToken, authorizeTrustedServeSnapshot, commitSiblingIssuance, EpEnvelopeError, ensureAuthorityStores, epAuthBucket, isReachable, managedRetirementOpId, mintCreds, mintPublicUserJwt, newIdentity, parseEndpointGate, parseServiceSpec, parseServiceStatus, rawDigest, readSvcRecordLeader, reconcileEndpointGate, recordSpecKey, recordStatusKey, RECORD_KINDS, recordsBucket, remoteManagerActors, retirementFrontierStreams, serveIssuanceGateKv, standaloneConnectOpts, STANDING_RENEWABLE_TTL_SEC } from "@cotal-ai/core";
61
- import { findCotalRoot, loadManagerInstanceIdentity, userAuthStateDir, workspaceSecretStore } from "@cotal-ai/workspace";
61
+ 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, contractArtifactCanonicalBytes, DEV_OWNER, endpointRegistrationBarrier, mintLifecycleUid, principalKey, provisionEndpointGateOpen, publishContractArtifact, registerServiceInstance, SERVICE_READY, writeServiceStatus } from "@cotal-ai/core";
62
+ import { findCotalRoot, loadManagerInstanceIdentity, userAuthStateDir, workspaceSecretStore, createAuthInstanceIdentity, loadAuthInstanceIdentity } from "@cotal-ai/workspace";
62
63
  import { decodeJwt } from "jose";
63
64
  import { deriveOwnerForIdpSubject } from "./derive.js";
64
65
  import { startAuthCallout } from "./callout.js";
@@ -66,15 +67,15 @@ import { createIdpBridge, verifyIdpToken } from "./idp.js";
66
67
  import { PUBLIC_EXCHANGE_VIEWS } from "./token.js";
67
68
  import { pinnedJwksResolver } from "./issuer.js";
68
69
  import { calloutPermissions } from "./permissions.js";
69
- import { issueRemoteManagerAuthority } from "./manager-authority.js";
70
+ import { admitRemoteRun, authorizeRemoteManagerRenewal, authorizeRemoteRunAttempt, issueRemoteManagerAuthority, parseRemoteRunAdmissionRequest, parseRemoteRunAttemptRequest } from "./manager-authority.js";
70
71
  import { authorizeRemoteRetainedAgentValidation, completeRemoteRetainedAgentValidation, remoteManagerCurrentRegistrationProof } from "./retained-manager-validation.js";
71
72
  import { authorizeRemoteManagedAgentEnrollment, authorizeRemoteManagedAgentPrepareRetirement } from "./managed-agent-enrollment.js";
72
73
  import { authorizeRemoteManagerGoalIndexScan, completeRemoteManagerGoalIndexScan } from "./manager-goal-index.js";
73
74
  import { authorizeRemoteManagerAdmin } from "./manager-admin-authorization.js";
74
75
  import { authorizeRemoteManagerMaintenance, completeRemoteManagerMaintenance } from "./manager-maintenance.js";
75
76
  import { validateRetainedManagedAgent } from "./continuity.js";
76
- import { reconstructRemoteManagerServeGrant } from "./manager-contract.js";
77
- import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, remoteManagerIssuerGrants, remoteManagerRegistrationProof } from "./authority-client.js";
77
+ import { reconstructRemoteManagerServeGrant, remoteManagerServeGrantFromCluster } from "./manager-contract.js";
78
+ import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, remoteManagerIssuerGrants, remoteManagerRegistrationProof, authRegistrationExecutorGrants } from "./authority-client.js";
78
79
  import { authorizeConnectCredential } from "./connect-reader.js";
79
80
  import { ensureRootCredential } from "./root-credential.js";
80
81
  import { observeGate, openLifecycleRegistry, readLifecycleHeadForOperation } from "./lifecycle-registry.js";
@@ -87,6 +88,7 @@ import { resumeAgentRetirement, runAgentRetirementBarrier } from "./retirement-b
87
88
  import { makeRetirementCleaners } from "./retirement-cleaner.js";
88
89
  import { makeDrainRepairers } from "./drain-repair.js";
89
90
  import { joinOrStartRetirement, openAuthAdminListener } from "./auth-admin.js";
91
+ import { AUTH_SERVICE_ENDPOINT, authClusterArtifacts, authContractArtifactValues } from "./auth-service-contract.js";
90
92
  import { drainTargetForEndpoint, openAdmissionMediator } from "./admission-mediator.js";
91
93
  import { AGENT_BEARER_TTL_SEC, findInteractiveActor, findManagedActor, ledgerAclResolver, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, } from "./ledger.js";
92
94
  import { AUTH_PROVIDER_NAME, clearAuthServiceInfo, loadCalloutAuth, loadIssuer, loadOwnerSecret, loadPinnedIdp, loadServiceKeys, saveAuthServiceInfo, spaceIssuer, } from "./store.js";
@@ -143,6 +145,10 @@ export function authorizeRemoteManagerRetirement(args) {
143
145
  */
144
146
  export async function openAuthAuthorityPlane(opts) {
145
147
  const { server, space, dataAccount, log } = opts;
148
+ const standingTtl = opts.standingRenewableTtlSeconds ?? STANDING_RENEWABLE_TTL_SEC;
149
+ if (!Number.isSafeInteger(standingTtl) || standingTtl < 5 || standingTtl > STANDING_RENEWABLE_TTL_SEC) {
150
+ throw new Error(`standingRenewableTtlSeconds must be an integer between 5 and ${STANDING_RENEWABLE_TTL_SEC} seconds (got ${standingTtl})`);
151
+ }
146
152
  const writer = await openAuthorityClient({ server, space, dataAccount, label: `cotal:auth-mint:${space}`, grants: (id) => authorityWriterGrants(space, id), log });
147
153
  let registry;
148
154
  try {
@@ -153,6 +159,87 @@ export async function openAuthAuthorityPlane(opts) {
153
159
  await writer.close();
154
160
  throw e;
155
161
  }
162
+ // #399 M2: register the auth plane itself as an ordinary `auth` service endpoint — the SAME
163
+ // §13.7 registration ceremony the manager runs (`manager.ts:6562-6690`), before the listener
164
+ // below serves a single request. The persisted instance id + serve nkey (under the plane's
165
+ // `dir`, hardened the way the manager's own instance identity is) so a restart re-registers the
166
+ // SAME instance: `registerServiceInstance` advances the process epoch on a re-registration and
167
+ // fences the predecessor; a first registration stays at epoch 0.
168
+ let authServeInstanceId;
169
+ let authServeGrant;
170
+ try {
171
+ const persisted = loadAuthInstanceIdentity(opts.dir, space);
172
+ const authIdentity = persisted ?? createAuthInstanceIdentity(opts.dir, space, {
173
+ instanceId: mintLifecycleUid(),
174
+ serveIdentity: newIdentity(),
175
+ });
176
+ const iid = authIdentity.instanceId;
177
+ const artifacts = authClusterArtifacts();
178
+ const store = new Map([
179
+ [artifacts.rootDigest, artifacts.document],
180
+ [artifacts.closureDigest, artifacts.manifest],
181
+ ]);
182
+ const readClusterArtifact = (digest) => store.get(digest);
183
+ // §13.9 name authority, static mode: the auth plane holds the space signing seed, so it
184
+ // self-authorizes exactly its own name ("auth") for exactly DEV_OWNER — mirrors the manager's
185
+ // own self-authorization for "manager".
186
+ const authority = {
187
+ authorize: (name, owner) => ({ authorized: name === AUTH_SERVICE_ENDPOINT && owner === DEV_OWNER, revision: 0 }),
188
+ };
189
+ const servePrincipal = principalKey(DEV_OWNER, authIdentity.serveIdentity.id).key;
190
+ const registrationOpId = mintLifecycleUid();
191
+ const regClient = await openAuthorityClient({
192
+ server, space, dataAccount, label: `cotal:auth-registration:${space}`,
193
+ grants: (id) => authRegistrationExecutorGrants(space, id, iid),
194
+ log,
195
+ });
196
+ try {
197
+ const authKv = await new Kvm(regClient.nc).open(epAuthBucket(space));
198
+ const recordsKv = await new Kvm(regClient.nc).open(recordsBucket(space));
199
+ // §13.7 contract-artifact publication BEFORE the registration that advertises the digests.
200
+ const storeCtx = await contractStoreContext(regClient.nc, space);
201
+ for (const value of [...authContractArtifactValues(), artifacts.document, artifacts.manifest])
202
+ await publishContractArtifact(storeCtx, contractArtifactCanonicalBytes(value));
203
+ // §13.1 pre-registration: the issuance gate, born open@gen0, provisioned ONCE on the FIRST
204
+ // registration. On a restart the gate already exists; `registerServiceInstance` below
205
+ // freezes + re-registers it (advancing the epoch).
206
+ if ((await serveIssuanceGateKv(authKv, space, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid }).observe()) === null)
207
+ await provisionEndpointGateOpen(authKv, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, principal: servePrincipal });
208
+ const barrier = endpointRegistrationBarrier(authKv, space, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, opId: registrationOpId });
209
+ const spec = { endpoint: AUTH_SERVICE_ENDPOINT, owner: DEV_OWNER, clusterDigests: [artifacts.closureDigest], protocol: { v: 1 } };
210
+ const { registrationRevision } = await registerServiceInstance(recordsKv, {
211
+ space, spec, instanceId: iid, registrant: { owner: DEV_OWNER }, authority, barrier, readClusterArtifact,
212
+ });
213
+ const fence = serveIssuanceGateKv(authKv, space, { endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid });
214
+ const observed = await fence.observe();
215
+ if (observed === null)
216
+ throw new Error(`the issuance gate for ${AUTH_SERVICE_ENDPOINT}/${iid} vanished after registration`);
217
+ const readProcessEpoch = async () => {
218
+ const g = await fence.observe();
219
+ if (g === null)
220
+ throw new Error(`no issuance gate for ${AUTH_SERVICE_ENDPOINT}/${iid}`);
221
+ return g.processEpoch;
222
+ };
223
+ const grant = await authorizeServeGrant(recordsKv, {
224
+ space, endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, epoch: observed.processEpoch,
225
+ holder: { owner: DEV_OWNER }, authority, readClusterArtifact, readProcessEpoch,
226
+ });
227
+ await writeServiceStatus(recordsKv, {
228
+ endpoint: AUTH_SERVICE_ENDPOINT, instanceId: iid, epoch: observed.processEpoch,
229
+ status: { state: SERVICE_READY, epoch: observed.processEpoch, observedSpecRevision: registrationRevision },
230
+ readProcessEpoch,
231
+ });
232
+ authServeInstanceId = iid;
233
+ authServeGrant = grant;
234
+ }
235
+ finally {
236
+ await regClient.close();
237
+ }
238
+ }
239
+ catch (e) {
240
+ await writer.close();
241
+ throw e;
242
+ }
156
243
  let reader;
157
244
  try {
158
245
  reader = await openSupervisedConnectReader({ server, space, dataAccount, log });
@@ -325,7 +412,7 @@ export async function openAuthAuthorityPlane(opts) {
325
412
  // the FRESH space-manager-lease holder check) and dispatches.
326
413
  let authAdmin;
327
414
  try {
328
- authAdmin = await openAuthAdminListener({ server, space, dataAccount, reg: barrierReg, retirement, barrierFlight: retirementFlights, log });
415
+ authAdmin = await openAuthAdminListener({ server, space, dataAccount, reg: barrierReg, retirement, barrierFlight: retirementFlights, instanceId: authServeInstanceId, epoch: authServeGrant.epoch, grant: authServeGrant, log });
329
416
  }
330
417
  catch (e) {
331
418
  closing = true;
@@ -340,9 +427,34 @@ export async function openAuthAuthorityPlane(opts) {
340
427
  }
341
428
  const fileArm = ledgerAuthorizeConnect(opts.dir);
342
429
  const recordsJsm = await jetstreamManager(remoteIssuer.nc);
343
- const root = findCotalRoot();
430
+ const loadLocalManager = opts.localManager ?? (() => undefined);
431
+ // Standing renewal re-derives the serve surface from the REGISTERED service spec at the gate's
432
+ // registration revision: spec (leader read) -> closure manifest -> root document, each verified
433
+ // against its content digest. The request never selects the surface.
434
+ const registeredManagerCluster = async (owner, instanceId, observed) => {
435
+ const rec = await readSvcRecordLeader(recordsJsm, space, recordSpecKey(RECORD_KINDS.svc, ["manager", instanceId]));
436
+ if (!rec || "deleted" in rec || rec.revision !== observed.registrationRevision)
437
+ throw new EpEnvelopeError("failed-precondition", "manager-service renewal found no service spec at the gate's registration revision");
438
+ const spec = parseServiceSpec(rec.value, { endpoint: "manager" });
439
+ if (spec.owner !== owner)
440
+ throw new EpEnvelopeError("permission-denied", "manager-service renewal spec belongs to another owner");
441
+ const store = await contractStoreContext(remoteIssuer.nc, space);
442
+ const read = async (digest) => {
443
+ const bytes = await fetchContractArtifact(store, contractRefToHex(digest));
444
+ if (!bytes)
445
+ throw new EpEnvelopeError("failed-precondition", `manager-service renewal cannot read registered contract artifact ${digest}`);
446
+ return JSON.parse(new TextDecoder().decode(bytes));
447
+ };
448
+ for (const closure of spec.clusterDigests) {
449
+ const { root } = verifyClusterManifest(closure, await read(closure));
450
+ const document = await read(root);
451
+ if (verifyClusterRoot(root, document).urn === "ai.cotal.manager")
452
+ return document;
453
+ }
454
+ throw new EpEnvelopeError("failed-precondition", "manager-service renewal spec registers no manager cluster");
455
+ };
344
456
  const managerGate = async (owner, instanceId) => {
345
- const localManager = loadManagerInstanceIdentity(root, space);
457
+ const localManager = loadLocalManager();
346
458
  const gate = await serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId }).observe();
347
459
  if (!gate)
348
460
  return "unknown";
@@ -379,7 +491,7 @@ export async function openAuthAuthorityPlane(opts) {
379
491
  const remoteInstances = [];
380
492
  const remoteLive = [];
381
493
  let localCandidate;
382
- const localManager = loadManagerInstanceIdentity(root, space);
494
+ const localManager = loadLocalManager();
383
495
  for (const raw of await scanner.scanManagerGates()) {
384
496
  if (raw.op !== undefined)
385
497
  continue;
@@ -494,13 +606,43 @@ export async function openAuthAuthorityPlane(opts) {
494
606
  },
495
607
  issueManagerServiceAuthority: async ({ owner, scope, request }) => {
496
608
  refuseIfFenced();
609
+ const observeManagerGate = async (instanceId) => (await serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId }).observe()) ?? null;
497
610
  return issueRemoteManagerAuthority({
498
611
  owner,
499
612
  scope,
500
613
  request,
614
+ authorizeRenewal: ({ owner: o, request: r }) => authorizeRemoteManagerRenewal({
615
+ request: r, owner: o, space, accountPublicKey: dataAccount.pub, proofSecret: dataAccount.signingSeed,
616
+ observeManagerGate,
617
+ observeRun: async (runId) => {
618
+ const recordsKv = await openRecordsBucket(remoteIssuer.nc, space);
619
+ return await observeHostedRunAttempt(recordsKv, "manager", runId, {
620
+ supervisorId: r.identities.supervisor.id,
621
+ instanceId: r.instanceId,
622
+ });
623
+ },
624
+ }),
501
625
  issue: async ({ actors, request: r }) => {
502
626
  const credential = async (key, profile, actor, opts) => mintPublicUserJwt({ space, account: { pub: dataAccount.pub, signingSeed: dataAccount.signingSeed } }, r.identities[key].id, profile, { ...opts, principal: { owner, actor }, lifecycleUid: r.managerLifecycleUid });
503
627
  const credentials = {};
628
+ const siblingOn = (gate, observed) => async (key, profile, actor) => {
629
+ const issued = await credential(key, profile, actor, profile === "goal-writer" ? { goalWriter: { endpoint: "manager" }, expiresInSeconds: standingTtl } : { expiresInSeconds: standingTtl });
630
+ await commitSiblingIssuance(gate, observed, {
631
+ credentialId: rawDigest(issued.jwt).replace("sha256:", "sha256-"),
632
+ credentialKey: r.identities[key].id,
633
+ holderPrincipal: `${owner}.${actor}`,
634
+ endpoint: "manager",
635
+ lifecycleUid: r.instanceId,
636
+ sourceChain: ["root"],
637
+ state: "active",
638
+ exp: issued.exp,
639
+ generation: observed.generation,
640
+ processEpoch: observed.processEpoch,
641
+ registrationRevision: observed.registrationRevision,
642
+ nameAuthorityRevision: observed.nameAuthorityRevision,
643
+ });
644
+ return issued;
645
+ };
504
646
  if (r.operation === "renew") {
505
647
  const gate = serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId: r.instanceId });
506
648
  const observed = await gate.observe();
@@ -516,7 +658,7 @@ export async function openAuthAuthorityPlane(opts) {
516
658
  if (r.operation === "prepare" || r.operation === "renew") {
517
659
  credentials.supervisor = await credential("supervisor", "remote-manager", actors.supervisor, {
518
660
  remoteManager: { instanceId: r.instanceId, owner, actor: actors.supervisor },
519
- expiresInSeconds: STANDING_RENEWABLE_TTL_SEC,
661
+ expiresInSeconds: standingTtl,
520
662
  });
521
663
  // The executor receives the exact instance-scoped registration/maintenance surface under
522
664
  // a separate nkey and bounded five-minute lifetime. The participant retains it for the
@@ -564,6 +706,71 @@ export async function openAuthAuthorityPlane(opts) {
564
706
  });
565
707
  return { credentials };
566
708
  }
709
+ if (r.operation === "renewStandingBundle") {
710
+ // authorizeRenewal fresh-checked account, owner, epoch and the current-registration proof
711
+ // (which binds the held nkeys and lifecycle). Re-observe so every row binds one revision.
712
+ const gate = serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId: r.instanceId });
713
+ const observed = await gate.observe();
714
+ if (!observed || observed.state !== "open" || observed.processEpoch !== r.processEpoch)
715
+ throw new EpEnvelopeError("conflict", "manager-service standing renewal gate moved before issuance");
716
+ credentials.supervisor = await credential("supervisor", "remote-manager", actors.supervisor, {
717
+ remoteManager: { instanceId: r.instanceId, owner, actor: actors.supervisor },
718
+ expiresInSeconds: standingTtl,
719
+ });
720
+ credentials.executor = await credential("executor", "remote-manager", actors.executor, {
721
+ remoteManager: { instanceId: r.instanceId, owner, actor: actors.executor },
722
+ expiresInSeconds: 5 * 60,
723
+ });
724
+ credentials.serve = await mintPublicUserJwt({ space, account: { pub: dataAccount.pub, signingSeed: dataAccount.signingSeed } }, r.identities.serve.id, "endpoint-serve", {
725
+ principal: { owner, actor: actors.serve },
726
+ lifecycleUid: r.managerLifecycleUid,
727
+ expiresInSeconds: standingTtl,
728
+ endpointServe: remoteManagerServeGrantFromCluster(r, owner, await registeredManagerCluster(owner, r.instanceId, observed), observed),
729
+ serveIssuance: gate,
730
+ });
731
+ const sibling = siblingOn(gate, observed);
732
+ credentials.goalWriter = await sibling("goalWriter", "goal-writer", actors.goalWriter);
733
+ credentials.sessionLedger = await sibling("sessionLedger", "session-ledger", actors.sessionLedger);
734
+ return { credentials };
735
+ }
736
+ if (r.operation === "renewRunDriver") {
737
+ const gate = serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId: r.instanceId });
738
+ const observed = await gate.observe();
739
+ if (!observed || observed.state !== "open" || observed.processEpoch !== r.processEpoch)
740
+ throw new EpEnvelopeError("conflict", "manager-service run renewal gate moved before issuance");
741
+ const recordsKv = await openRecordsBucket(remoteIssuer.nc, space);
742
+ const run = await observeHostedRunAttempt(recordsKv, "manager", r.run.runId, {
743
+ supervisorId: r.identities.supervisor.id,
744
+ instanceId: r.instanceId,
745
+ });
746
+ if (!run || run.state !== "running" || run.instanceId !== r.instanceId ||
747
+ run.holder !== r.run.holder || run.takeoverId !== r.run.takeoverId ||
748
+ run.epoch !== r.run.epoch || run.fencingToken !== r.run.fencingToken)
749
+ throw new EpEnvelopeError("conflict", "manager-service run renewal authority moved before issuance");
750
+ const caller = runDriverCaller(r.run.runId, owner);
751
+ const binding = {
752
+ endpoint: "manager",
753
+ runId: r.run.runId,
754
+ takeoverId: r.run.takeoverId,
755
+ instanceId: r.instanceId,
756
+ epoch: r.run.epoch,
757
+ owner,
758
+ };
759
+ const auth = { space, account: { pub: dataAccount.pub, signingSeed: dataAccount.signingSeed } };
760
+ credentials.runDriver = await mintPublicUserJwt(auth, r.run.driverId, "run-driver", {
761
+ principal: { owner, actor: caller.actor },
762
+ lifecycleUid: r.managerLifecycleUid,
763
+ runDriver: binding,
764
+ expiresInSeconds: standingTtl,
765
+ });
766
+ credentials.runMediator = await mintPublicUserJwt(auth, r.run.mediatorId, "run-mediator", {
767
+ principal: { owner, actor: caller.actor },
768
+ lifecycleUid: r.managerLifecycleUid,
769
+ runMediator: binding,
770
+ expiresInSeconds: standingTtl,
771
+ });
772
+ return { credentials };
773
+ }
567
774
  if (r.operation === "activate") {
568
775
  const expectedProof = remoteManagerRegistrationProof(owner, r);
569
776
  if (r.registrationProof !== expectedProof)
@@ -580,30 +787,13 @@ export async function openAuthAuthorityPlane(opts) {
580
787
  credentials.serve = await mintPublicUserJwt({ space, account: { pub: dataAccount.pub, signingSeed: dataAccount.signingSeed } }, r.identities.serve.id, "endpoint-serve", {
581
788
  principal: { owner, actor: actors.serve },
582
789
  lifecycleUid: r.managerLifecycleUid,
583
- expiresInSeconds: STANDING_RENEWABLE_TTL_SEC,
790
+ expiresInSeconds: standingTtl,
584
791
  endpointServe: reconstructRemoteManagerServeGrant(r, owner, actors.serve, observed),
585
792
  serveIssuance: gate,
586
793
  });
587
794
  if (!observed)
588
795
  throw new EpEnvelopeError("failed-precondition", "manager-service activation found no issuance gate");
589
- const sibling = async (key, profile, actor) => {
590
- const issued = await credential(key, profile, actor, profile === "goal-writer" ? { goalWriter: { endpoint: "manager" }, expiresInSeconds: STANDING_RENEWABLE_TTL_SEC } : { expiresInSeconds: STANDING_RENEWABLE_TTL_SEC });
591
- await commitSiblingIssuance(gate, observed, {
592
- credentialId: rawDigest(issued.jwt).replace("sha256:", "sha256-"),
593
- credentialKey: r.identities[key].id,
594
- holderPrincipal: `${owner}.${actor}`,
595
- endpoint: "manager",
596
- lifecycleUid: r.instanceId,
597
- sourceChain: ["root"],
598
- state: "active",
599
- exp: issued.exp,
600
- generation: observed.generation,
601
- processEpoch: observed.processEpoch,
602
- registrationRevision: observed.registrationRevision,
603
- nameAuthorityRevision: observed.nameAuthorityRevision,
604
- });
605
- return issued;
606
- };
796
+ const sibling = siblingOn(gate, observed);
607
797
  credentials.goalWriter = await sibling("goalWriter", "goal-writer", actors.goalWriter);
608
798
  credentials.sessionLedger = await sibling("sessionLedger", "session-ledger", actors.sessionLedger);
609
799
  return {
@@ -741,6 +931,124 @@ export async function openAuthAuthorityPlane(opts) {
741
931
  },
742
932
  });
743
933
  },
934
+ admitManagerRun: async ({ owner, scope, request }) => {
935
+ refuseIfFenced();
936
+ if (!scope.includes("supervise"))
937
+ throw new EpEnvelopeError("permission-denied", 'manager run admission needs scope "supervise"; spawn/admin do not imply it');
938
+ const runId = parseRemoteRunAdmissionRequest(request).run.runId;
939
+ const hostAuth = {
940
+ space,
941
+ operator: { seed: "", jwt: "" },
942
+ account: { pub: dataAccount.pub, seed: "", jwt: "", signingSeed: dataAccount.signingSeed, signingPub: "" },
943
+ sys: { pub: "", jwt: "" },
944
+ };
945
+ return withIssuerSession({ servers: server, space, auth: hostAuth, tls: false }, async (session) => {
946
+ const admitterNc = await connect({
947
+ servers: server,
948
+ ...standaloneConnectOpts({
949
+ creds: await mintCreds(hostAuth, newIdentity(), "run-admitter", { runAdmitter: { endpoint: "manager", runId }, expiresInSeconds: 60 }),
950
+ tls: false,
951
+ }),
952
+ maxReconnectAttempts: 0,
953
+ });
954
+ try {
955
+ return await admitRemoteRun({
956
+ request, owner, space, accountPublicKey: dataAccount.pub, proofSecret: dataAccount.signingSeed, endpoint: "manager",
957
+ observeManagerGate: async (instanceId) => {
958
+ const gate = serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId });
959
+ return gate.observe();
960
+ },
961
+ issued: session.store,
962
+ sourceIsLive: (source) => session.sourceIsLive(source),
963
+ admissions: await new Kvm(admitterNc).open(admissionBucket(space)),
964
+ });
965
+ }
966
+ finally {
967
+ await admitterNc.drain().catch(() => admitterNc.close());
968
+ }
969
+ });
970
+ },
971
+ issueManagerRunAttempt: async ({ owner, scope, request }) => {
972
+ refuseIfFenced();
973
+ if (!scope.includes("supervise"))
974
+ throw new EpEnvelopeError("permission-denied", 'manager run attempt needs scope "supervise"; spawn/admin do not imply it');
975
+ const req = parseRemoteRunAttemptRequest(request);
976
+ const recordsKv = await openRecordsBucket(remoteIssuer.nc, space);
977
+ const grant = await authorizeRemoteRunAttempt({
978
+ request,
979
+ owner,
980
+ space,
981
+ accountPublicKey: dataAccount.pub,
982
+ proofSecret: dataAccount.signingSeed,
983
+ endpoint: "manager",
984
+ observeManagerGate: async (instanceId) => {
985
+ const gate = serveIssuanceGateKv(await new Kvm(remoteIssuer.nc).open(epAuthBucket(space)), space, { endpoint: "manager", instanceId });
986
+ return gate.observe();
987
+ },
988
+ readAdmission: (runId) => readRunAdmission(recordsJsm, space, "manager", runId),
989
+ readRunStatus: async (runId) => (await readRunRecord(recordsKv, "manager", runId))?.status?.value,
990
+ checkpointWaiting: async (token) => (await readCheckpointStatus(recordsKv, { endpoint: "manager", token }))?.value.state === "waiting",
991
+ });
992
+ const hostAuth = {
993
+ space,
994
+ operator: { seed: "", jwt: "" },
995
+ account: { pub: dataAccount.pub, seed: "", jwt: "", signingSeed: dataAccount.signingSeed, signingPub: "" },
996
+ sys: { pub: "", jwt: "" },
997
+ };
998
+ if (grant.kind === "attempt") {
999
+ const caller = runDriverCaller(grant.driver.runDriver.runId, owner);
1000
+ const driver = await mintPublicUserJwt(hostAuth, grant.driver.id, "run-driver", {
1001
+ principal: { owner, actor: caller.actor },
1002
+ lifecycleUid: req.managerLifecycleUid,
1003
+ runDriver: grant.driver.runDriver,
1004
+ expiresInSeconds: standingTtl,
1005
+ });
1006
+ const mediator = await mintPublicUserJwt(hostAuth, grant.mediator.id, "run-mediator", {
1007
+ principal: { owner, actor: caller.actor },
1008
+ lifecycleUid: req.managerLifecycleUid,
1009
+ runMediator: grant.mediator.runMediator,
1010
+ expiresInSeconds: standingTtl,
1011
+ });
1012
+ return {
1013
+ v: 1,
1014
+ kind: "manager-run-attempt",
1015
+ space,
1016
+ owner,
1017
+ actor: req.actor,
1018
+ instanceId: req.instanceId,
1019
+ managerLifecycleUid: req.managerLifecycleUid,
1020
+ requestId: req.requestId,
1021
+ registrationProof: req.registrationProof,
1022
+ accountPublicKey: req.accountPublicKey,
1023
+ processEpoch: req.processEpoch,
1024
+ identities: req.identities,
1025
+ attempt: req.attempt,
1026
+ credentials: { driver, mediator },
1027
+ };
1028
+ }
1029
+ const operator = await mintPublicUserJwt(hostAuth, grant.operator.id, "run-operator", {
1030
+ principal: { owner, actor: req.actor },
1031
+ lifecycleUid: req.managerLifecycleUid,
1032
+ runOperator: grant.operator.runOperator,
1033
+ expiresInSeconds: 60,
1034
+ });
1035
+ return {
1036
+ v: 1,
1037
+ kind: "manager-run-attempt",
1038
+ space,
1039
+ owner,
1040
+ actor: req.actor,
1041
+ instanceId: req.instanceId,
1042
+ managerLifecycleUid: req.managerLifecycleUid,
1043
+ requestId: req.requestId,
1044
+ registrationProof: req.registrationProof,
1045
+ accountPublicKey: req.accountPublicKey,
1046
+ processEpoch: req.processEpoch,
1047
+ identities: req.identities,
1048
+ operator: req.operator,
1049
+ credentials: { operator },
1050
+ };
1051
+ },
744
1052
  fenced,
745
1053
  close: async () => {
746
1054
  // Clean-close order (SPEC 13.13): the rail stops answering first, then scan-capable
@@ -894,6 +1202,77 @@ export async function runAuthService(args, store) {
894
1202
  await new Promise((r) => setTimeout(r, hold));
895
1203
  }
896
1204
  }
1205
+ const started = await startAuthContext({
1206
+ space,
1207
+ server,
1208
+ port,
1209
+ dir,
1210
+ secrets,
1211
+ hostedStore: store !== undefined,
1212
+ ...(publicPort !== undefined ? { publicFace: { port: publicPort, urlFlag: publicUrlFlag, trustedProxy, advertisedServer, agentProvisioningUrl } } : {}),
1213
+ // The CLI composition's local manager is this workspace root's persisted instance, re-read per
1214
+ // selection exactly as before. A hosted context passes no local manager at all.
1215
+ localManager: () => loadManagerInstanceIdentity(root, space),
1216
+ });
1217
+ // All planes bound — NOW write the discovery file (its existence is the readiness signal).
1218
+ saveAuthServiceInfo(dir, { url: started.url, pid: process.pid, cap: started.cap, ...(started.publicUrl !== undefined ? { publicUrl: started.publicUrl } : {}) });
1219
+ console.log(`✓ auth service up (space ${space}) - callout on ${server}, exchange/JWKS at ${started.url}${started.publicUrl !== undefined ? `, public exchange at ${started.publicUrl}` : ""}`);
1220
+ // The CLI wrapper, not the context, owns process signals and exit codes.
1221
+ const stop = async () => {
1222
+ clearAuthServiceInfo(dir); // a dead service must not satisfy the next start's readiness poll
1223
+ await started.handle.close();
1224
+ process.exit(0);
1225
+ };
1226
+ process.on("SIGINT", () => void stop());
1227
+ process.on("SIGTERM", () => void stop());
1228
+ // A dropped broker connection is fatal-loud, not a zombie: the supervising `up`/`down` lifecycle
1229
+ // owns restarts, and a callout that silently stopped answering would hang every user connect.
1230
+ // A FENCED plane is equally fatal (SPEC 13.13): its scanners are no longer whole, every authority
1231
+ // operation already refuses, and a daemon that stayed up would look healthy while a successor
1232
+ // reclaims — down the whole service instead.
1233
+ const ended = await started.ended;
1234
+ clearAuthServiceInfo(dir);
1235
+ if (ended.kind === "fenced") {
1236
+ console.error(`✗ auth-service: ${ended.cause} - exiting`);
1237
+ process.exit(1);
1238
+ }
1239
+ if (ended.cause !== undefined) {
1240
+ console.error(`✗ auth-service: broker connection closed (${ended.cause}) - exiting`);
1241
+ process.exit(1);
1242
+ }
1243
+ process.exit(0);
1244
+ }
1245
+ /** Start one account-scoped auth-service context. Every input is explicit: the state dir, the
1246
+ * injected store with its proved identity, the assigned data account and the broker. The context
1247
+ * installs no process signal handler, never exits the process, never selects a root from cwd,
1248
+ * and has no local manager. It returns only after the authority plane, the callout subscription
1249
+ * and the loopback listener are bound. A failed start releases what it acquired and throws. A
1250
+ * mid-life fence or broker loss makes only this context `unavailable` and closes its resources. */
1251
+ export async function startAuthService(inputs) {
1252
+ if (!inputs.context?.accountPublicKey || !inputs.context.lifecycleUid || !inputs.space || !inputs.servers || !inputs.stateDir)
1253
+ throw new Error("auth-service: hosted context needs an account, lifecycle, space, server and stateDir");
1254
+ if (inputs.store.identity === undefined)
1255
+ throw new Error("auth-service: hosted SecretStore must declare a stable identity");
1256
+ const actual = parseSecretStoreIdentity(inputs.store.identity);
1257
+ if (actual.kind !== "injected" || !sameSecretStoreIdentity(actual, inputs.storeIdentity))
1258
+ throw new Error("auth-service: hosted SecretStore identity does not match the assigned store identity");
1259
+ const port = inputs.port ?? 0;
1260
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
1261
+ throw new Error(`auth-service: port must be a port number, got ${port}`);
1262
+ const started = await startAuthContext({
1263
+ space: inputs.space,
1264
+ server: inputs.servers,
1265
+ port,
1266
+ dir: resolve(inputs.stateDir),
1267
+ secrets: inputs.store,
1268
+ hostedStore: true,
1269
+ localManager: () => undefined,
1270
+ context: inputs.context,
1271
+ });
1272
+ return started.handle;
1273
+ }
1274
+ async function startAuthContext(o) {
1275
+ const { space, server, dir, secrets, port } = o;
897
1276
  const keys = await loadServiceKeys(secrets, space);
898
1277
  const callout = await loadCalloutAuth(secrets, space);
899
1278
  const issuer = await loadIssuer(secrets, space);
@@ -907,10 +1286,12 @@ export async function runAuthService(args, store) {
907
1286
  ...(ownerSecret ? [] : ["owner secret"]),
908
1287
  ...(idp ? [] : [`IdP pin under ${dir}`]),
909
1288
  ];
910
- throw new Error(`auth-service: user-auth material is missing (${missing.join(", ")}) - ${store ? "the hosted composition must provision the secret store before starting this daemon" : "enable it with `cotal up --user-auth --idp <url>`"}`);
1289
+ throw new Error(`auth-service: user-auth material is missing (${missing.join(", ")}) - ${o.hostedStore ? "the hosted composition must provision the secret store before starting this daemon" : "enable it with `cotal up --user-auth --idp <url>`"}`);
911
1290
  }
912
1291
  if (issuer.issuer !== spaceIssuer(space))
913
1292
  throw new Error(`auth-service: issuer pin ${issuer.issuer} does not match space "${space}"`);
1293
+ if (o.context !== undefined && keys.dataAccount.pub !== o.context.accountPublicKey)
1294
+ throw new Error("auth-service: the store's data account does not match the assigned hosted context");
914
1295
  if (!(await isReachable(server, { creds: callout.calloutCreds })))
915
1296
  throw new Error(`auth-service: can't reach the broker at ${server} with the callout creds - is the mesh up (with the callout account preloaded)?`);
916
1297
  // ---- The authority plane (R1): stores ensured + registry + supervised reader, BEFORE the
@@ -921,132 +1302,170 @@ export async function runAuthService(args, store) {
921
1302
  dir,
922
1303
  dataAccount: { pub: keys.dataAccount.pub, signingSeed: keys.dataAccount.signingSeed },
923
1304
  log: (l) => console.error(l),
1305
+ localManager: o.localManager,
924
1306
  });
925
- // ---- Plane 2: the callout, on its own callout-account connection ----
926
- const nc = await connect({
927
- servers: server,
928
- authenticator: credsAuthenticator(new TextEncoder().encode(callout.calloutCreds)),
929
- name: `cotal:auth-service:${space}`,
930
- });
931
- startAuthCallout(nc, {
932
- xkeySeed: callout.xkey.seed,
933
- authAccount: { pub: callout.account.pub, signingSeed: callout.account.signingSeed },
934
- dataAccount: { pub: keys.dataAccount.pub, signingSeed: keys.dataAccount.signingSeed },
935
- space,
936
- token: { key: issuer.localKeySet(), issuer: issuer.issuer },
937
- authorizeActor: plane.authorizeConnect,
938
- permissionsFor: calloutPermissions(ledgerAclResolver(dir), plane.authorizeManagerCaller),
939
- log: (l) => console.error(l),
940
- });
941
- // The subscription must be ON the broker before readiness is signaled — an `up` that recorded a
942
- // usable user mesh while the SUB was still in flight would intermittently deny first connects.
943
- await nc.flush();
944
- // ---- Plane 1: the exchange + JWKS, loopback HTTP ----
945
- const bridgeIdp = { issuer: idp.issuer, audience: idp.audience, key: pinnedJwksResolver(idp.jwksUri) };
946
- const bridge = createIdpBridge({
947
- idp: bridgeIdp,
948
- space,
949
- spaceSecret: ownerSecret,
950
- issuer,
951
- authorizeActor: ledgerAuthorizeGrant(dir),
952
- mintConnectCredential: plane.mintConnectCredential,
953
- });
954
- const cap = randomBytes(32).toString("hex"); // per-start exchange capability (rotates with the daemon)
955
- const failures = []; // rolling-window timestamps of REFUSED exchanges
956
- const badCaps = []; // rolling-window timestamps of invalid-capability attempts
957
- const ctx = {
958
- issuer,
959
- bridge,
960
- bridgeIdp,
961
- ownerSecret,
962
- managerServiceAuthority: plane.issueManagerServiceAuthority,
963
- maintainRemoteManager: plane.maintainRemoteManager,
964
- validateRetainedAgent: plane.validateRetainedAgent,
965
- verifyManagedAgentEnrollment: plane.verifyManagedAgentEnrollment,
966
- verifyManagedAgentPrepareRetirement: plane.verifyManagedAgentPrepareRetirement,
967
- scanManagerGoalIndex: plane.scanManagerGoalIndex,
968
- authorizeManagerAdmin: plane.authorizeManagerAdmin,
969
- secrets,
970
- retireInteractiveLifecycle: plane.retireInteractiveLifecycle,
971
- retireManagedLifecycle: plane.retireManagedLifecycle,
972
- cap,
973
- failures,
974
- badCaps,
975
- space,
976
- dir,
977
- mintConnectCredential: plane.mintConnectCredential,
978
- selectManagerInstance: plane.selectManagerInstance,
979
- };
980
- const http = createServer((req, res) => void handle(req, res, ctx));
981
- await new Promise((resolvePort, reject) => {
982
- http.once("error", reject);
983
- http.listen(port, "127.0.0.1", () => resolvePort());
984
- });
985
- const addr = http.address();
986
- const boundPort = typeof addr === "object" && addr ? addr.port : port;
987
- const url = `http://127.0.0.1:${boundPort}`;
988
- // The optional PUBLIC face: its own server, its own closed route table, its own budgets — also
989
- // loopback-bound (the operator's reverse proxy terminates TLS and forwards here).
1307
+ let nc;
1308
+ let http;
990
1309
  let publicHttp;
991
- let publicUrl;
992
- if (publicPort !== undefined) {
993
- // The discovery bundle is GENERATED from the daemon's own recorded config — the pinned IdP,
994
- // the flags, the callout material — so it cannot drift from what this process enforces.
995
- // `endpoints.url` is finalized AFTER bind (the closure sees the mutation): with `--port 0`
996
- // the pre-bind port would advertise an address nothing listens on.
997
- const bundle = composeUserBundle({
1310
+ // Stop admission, let accepted requests settle within the public request deadline, then cut
1311
+ // whatever is still open so a stuck client cannot hold this context's close forever.
1312
+ const closeServer = async (srv) => {
1313
+ if (srv === undefined || !srv.listening)
1314
+ return;
1315
+ const closed = new Promise((r) => srv.close(() => r()));
1316
+ let timer;
1317
+ await Promise.race([closed, new Promise((r) => { timer = setTimeout(r, PUBLIC_DEADLINE_MS); })]);
1318
+ clearTimeout(timer);
1319
+ srv.closeAllConnections();
1320
+ await closed;
1321
+ };
1322
+ try {
1323
+ // ---- Plane 2: the callout, on its own callout-account connection ----
1324
+ nc = await connect({
1325
+ servers: server,
1326
+ authenticator: credsAuthenticator(new TextEncoder().encode(callout.calloutCreds)),
1327
+ name: `cotal:auth-service:${space}`,
1328
+ });
1329
+ startAuthCallout(nc, {
1330
+ xkeySeed: callout.xkey.seed,
1331
+ authAccount: { pub: callout.account.pub, signingSeed: callout.account.signingSeed },
1332
+ dataAccount: { pub: keys.dataAccount.pub, signingSeed: keys.dataAccount.signingSeed },
998
1333
  space,
999
- // What participants DIAL, not what the callout dials: the daemon reaches the broker on its
1000
- // loopback/LAN address (--server), which is meaningless off this machine. --advertised-server
1001
- // is the publicly dialable address (e.g. wss://… through the reverse proxy).
1002
- server: advertisedServer ?? server,
1003
- idp: { url: idp.url, issuer: idp.issuer, audience: idp.audience },
1004
- sentinelCreds: callout.sentinelCreds,
1005
- ...(agentProvisioningUrl ? { agentProvisioningUrl } : {}),
1334
+ token: { key: issuer.localKeySet(), issuer: issuer.issuer },
1335
+ authorizeActor: plane.authorizeConnect,
1336
+ permissionsFor: calloutPermissions(ledgerAclResolver(dir), plane.authorizeManagerCaller),
1337
+ log: (l) => console.error(l),
1006
1338
  });
1007
- publicHttp = createServer(makePublicHandler(ctx, makePublicPolicy(trustedProxy), bundle));
1339
+ // The subscription must be ON the broker before readiness is signaled — an `up` that recorded a
1340
+ // usable user mesh while the SUB was still in flight would intermittently deny first connects.
1341
+ await nc.flush();
1342
+ // ---- Plane 1: the exchange + JWKS, loopback HTTP ----
1343
+ const bridgeIdp = { issuer: idp.issuer, audience: idp.audience, key: pinnedJwksResolver(idp.jwksUri) };
1344
+ const bridge = createIdpBridge({
1345
+ idp: bridgeIdp,
1346
+ space,
1347
+ spaceSecret: ownerSecret,
1348
+ issuer,
1349
+ authorizeActor: ledgerAuthorizeGrant(dir),
1350
+ mintConnectCredential: plane.mintConnectCredential,
1351
+ });
1352
+ const cap = randomBytes(32).toString("hex"); // per-start exchange capability (rotates with the daemon)
1353
+ const failures = []; // rolling-window timestamps of REFUSED exchanges
1354
+ const badCaps = []; // rolling-window timestamps of invalid-capability attempts
1355
+ const ctx = {
1356
+ issuer,
1357
+ bridge,
1358
+ bridgeIdp,
1359
+ ownerSecret,
1360
+ managerServiceAuthority: plane.issueManagerServiceAuthority,
1361
+ maintainRemoteManager: plane.maintainRemoteManager,
1362
+ validateRetainedAgent: plane.validateRetainedAgent,
1363
+ verifyManagedAgentEnrollment: plane.verifyManagedAgentEnrollment,
1364
+ verifyManagedAgentPrepareRetirement: plane.verifyManagedAgentPrepareRetirement,
1365
+ scanManagerGoalIndex: plane.scanManagerGoalIndex,
1366
+ authorizeManagerAdmin: plane.authorizeManagerAdmin,
1367
+ admitManagerRun: plane.admitManagerRun,
1368
+ issueManagerRunAttempt: plane.issueManagerRunAttempt,
1369
+ secrets,
1370
+ retireInteractiveLifecycle: plane.retireInteractiveLifecycle,
1371
+ retireManagedLifecycle: plane.retireManagedLifecycle,
1372
+ cap,
1373
+ failures,
1374
+ badCaps,
1375
+ space,
1376
+ dir,
1377
+ mintConnectCredential: plane.mintConnectCredential,
1378
+ selectManagerInstance: plane.selectManagerInstance,
1379
+ };
1380
+ const loopback = createServer((req, res) => void handle(req, res, ctx));
1381
+ http = loopback;
1008
1382
  await new Promise((resolvePort, reject) => {
1009
- publicHttp.once("error", reject);
1010
- publicHttp.listen(publicPort, "127.0.0.1", () => resolvePort());
1383
+ loopback.once("error", reject);
1384
+ loopback.listen(port, "127.0.0.1", () => resolvePort());
1385
+ });
1386
+ const addr = loopback.address();
1387
+ const boundPort = typeof addr === "object" && addr ? addr.port : port;
1388
+ const url = `http://127.0.0.1:${boundPort}`;
1389
+ // The optional PUBLIC face: its own server, its own closed route table, its own budgets — also
1390
+ // loopback-bound (the operator's reverse proxy terminates TLS and forwards here).
1391
+ let publicUrl;
1392
+ if (o.publicFace !== undefined) {
1393
+ const face = o.publicFace;
1394
+ // The discovery bundle is GENERATED from the daemon's own recorded config — the pinned IdP,
1395
+ // the flags, the callout material — so it cannot drift from what this process enforces.
1396
+ // `endpoints.url` is finalized AFTER bind (the closure sees the mutation): with `--port 0`
1397
+ // the pre-bind port would advertise an address nothing listens on.
1398
+ const bundle = composeUserBundle({
1399
+ space,
1400
+ // What participants DIAL, not what the callout dials: the daemon reaches the broker on its
1401
+ // loopback/LAN address (--server), which is meaningless off this machine. --advertised-server
1402
+ // is the publicly dialable address (e.g. wss://… through the reverse proxy).
1403
+ server: face.advertisedServer ?? server,
1404
+ idp: { url: idp.url, issuer: idp.issuer, audience: idp.audience },
1405
+ sentinelCreds: callout.sentinelCreds,
1406
+ ...(face.agentProvisioningUrl ? { agentProvisioningUrl: face.agentProvisioningUrl } : {}),
1407
+ });
1408
+ const pub = createServer(makePublicHandler(ctx, makePublicPolicy(face.trustedProxy), bundle));
1409
+ publicHttp = pub;
1410
+ await new Promise((resolvePort, reject) => {
1411
+ pub.once("error", reject);
1412
+ pub.listen(face.port, "127.0.0.1", () => resolvePort());
1413
+ });
1414
+ const paddr = pub.address();
1415
+ const boundPublic = typeof paddr === "object" && paddr ? paddr.port : face.port;
1416
+ publicUrl = face.urlFlag ?? `http://127.0.0.1:${boundPublic}`;
1417
+ finalizeUserBundleEndpoint(bundle, publicUrl);
1418
+ }
1419
+ const callNc = nc;
1420
+ let state = "ready";
1421
+ let cause;
1422
+ let closePromise;
1423
+ const close = () => {
1424
+ if (closePromise !== undefined)
1425
+ return closePromise;
1426
+ if (state === "ready")
1427
+ state = "draining";
1428
+ closePromise = (async () => {
1429
+ await Promise.all([closeServer(http), closeServer(publicHttp)]);
1430
+ await plane.close().catch(() => { });
1431
+ await callNc.close().catch(() => { });
1432
+ })();
1433
+ return closePromise;
1434
+ };
1435
+ const ended = Promise.race([
1436
+ callNc.closed().then((err) => ({ kind: "broker", ...(err ? { cause: err.message } : {}) })),
1437
+ plane.fenced.then((reason) => ({ kind: "fenced", cause: reason })),
1438
+ ]).then((end) => {
1439
+ // A clean broker close after this context's own close() is the orderly end, not a failure.
1440
+ if (end.kind === "fenced" || end.cause !== undefined || closePromise === undefined) {
1441
+ state = "unavailable";
1442
+ cause = end.kind === "fenced" ? end.cause : `broker connection closed${end.cause !== undefined ? ` (${end.cause})` : ""}`;
1443
+ void close();
1444
+ }
1445
+ return end;
1011
1446
  });
1012
- const paddr = publicHttp.address();
1013
- const boundPublic = typeof paddr === "object" && paddr ? paddr.port : publicPort;
1014
- publicUrl = publicUrlFlag ?? `http://127.0.0.1:${boundPublic}`;
1015
- finalizeUserBundleEndpoint(bundle, publicUrl);
1447
+ const context = o.context ?? { accountPublicKey: keys.dataAccount.pub, lifecycleUid: "" };
1448
+ const service = {
1449
+ url,
1450
+ ...(publicUrl !== undefined ? { publicUrl } : {}),
1451
+ readiness() {
1452
+ if (state === "unavailable")
1453
+ return { state, context, cause: cause ?? "auth-service context is unavailable" };
1454
+ return { state: closePromise === undefined ? "ready" : "draining", context };
1455
+ },
1456
+ drain: close,
1457
+ close,
1458
+ };
1459
+ return { url, ...(publicUrl !== undefined ? { publicUrl } : {}), cap, handle: service, ended };
1016
1460
  }
1017
- // All planes bound — NOW write the discovery file (its existence is the readiness signal).
1018
- saveAuthServiceInfo(dir, { url, pid: process.pid, cap, ...(publicUrl !== undefined ? { publicUrl } : {}) });
1019
- console.log(`✓ auth service up (space ${space}) - callout on ${server}, exchange/JWKS at ${url}${publicUrl !== undefined ? `, public exchange at ${publicUrl}` : ""}`);
1020
- const stop = async () => {
1021
- clearAuthServiceInfo(dir); // a dead service must not satisfy the next start's readiness poll
1022
- http.close();
1023
- publicHttp?.close();
1461
+ catch (e) {
1462
+ // A failed start releases only what THIS context acquired, in reverse order.
1463
+ await closeServer(publicHttp).catch(() => { });
1464
+ await closeServer(http).catch(() => { });
1465
+ await nc?.close().catch(() => { });
1024
1466
  await plane.close().catch(() => { });
1025
- await nc.close().catch(() => { });
1026
- process.exit(0);
1027
- };
1028
- process.on("SIGINT", () => void stop());
1029
- process.on("SIGTERM", () => void stop());
1030
- // A dropped broker connection is fatal-loud, not a zombie: the supervising `up`/`down` lifecycle
1031
- // owns restarts, and a callout that silently stopped answering would hang every user connect.
1032
- // A FENCED plane is equally fatal (SPEC 13.13): its scanners are no longer whole, every authority
1033
- // operation already refuses, and a daemon that stayed up would look healthy while a successor
1034
- // reclaims — down the whole service instead.
1035
- await Promise.race([
1036
- nc.closed().then((err) => {
1037
- clearAuthServiceInfo(dir);
1038
- if (err) {
1039
- console.error(`✗ auth-service: broker connection closed (${err.message}) - exiting`);
1040
- process.exit(1);
1041
- }
1042
- process.exit(0);
1043
- }),
1044
- plane.fenced.then((reason) => {
1045
- clearAuthServiceInfo(dir);
1046
- console.error(`✗ auth-service: ${reason} - exiting`);
1047
- process.exit(1);
1048
- }),
1049
- ]);
1467
+ throw e;
1468
+ }
1050
1469
  }
1051
1470
  /** Dispatch one already-authenticated manager-authority body through the fixed host validator. */
1052
1471
  export async function dispatchManagerAuthorityRequest(ctx, owner, body) {
@@ -1084,6 +1503,10 @@ export async function dispatchManagerAuthorityRequest(ctx, owner, body) {
1084
1503
  return ctx.scanManagerGoalIndex({ owner, scope: row.scope ?? [], request: body.request });
1085
1504
  if (request.kind === "manager-admin-authorization")
1086
1505
  return ctx.authorizeManagerAdmin({ owner, scope: row.scope ?? [], request: body.request });
1506
+ if (request.kind === "manager-run-admission")
1507
+ return ctx.admitManagerRun({ owner, scope: row.scope ?? [], request: body.request });
1508
+ if (request.kind === "manager-run-attempt")
1509
+ return ctx.issueManagerRunAttempt({ owner, scope: row.scope ?? [], request: body.request });
1087
1510
  if (request.kind === "manager-service-maintenance")
1088
1511
  return ctx.maintainRemoteManager({ owner, scope: row.scope ?? [], request: body.request });
1089
1512
  return ctx.managerServiceAuthority({ owner, scope: row.scope ?? [], request: body.request });
@@ -1496,7 +1919,7 @@ async function handleExchange(req, res, ctx, policy) {
1496
1919
  }
1497
1920
  /** Loopback/operator-only typed manager authority exchange. The public route table never includes
1498
1921
  * this path, and the loopback capability is checked here in addition to the route separation. */
1499
- async function handleManagerServiceAuthority(req, res, ctx, policy) {
1922
+ export async function handleManagerServiceAuthority(req, res, ctx, policy) {
1500
1923
  if (req.method !== "POST")
1501
1924
  return send(res, 405, { error: "POST only" });
1502
1925
  if (req.headers.origin !== undefined)