@cotal-ai/auth 0.90.1 → 0.92.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 +70 -3
- package/dist/authority-client.d.ts.map +1 -1
- package/dist/authority-client.js +113 -9
- 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 +527 -125
- 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/manager-authority.d.ts +59 -3
- package/dist/manager-authority.d.ts.map +1 -1
- package/dist/manager-authority.js +185 -4
- package/dist/manager-authority.js.map +1 -1
- package/dist/native-service-gate.d.ts +14 -3
- package/dist/native-service-gate.d.ts.map +1 -1
- package/dist/native-service-gate.js +34 -20
- package/dist/native-service-gate.js.map +1 -1
- package/dist/native-service-name.d.ts +7 -0
- package/dist/native-service-name.d.ts.map +1 -1
- package/dist/native-service-name.js +15 -6
- package/dist/native-service-name.js.map +1 -1
- package/dist/permissions.js +4 -0
- package/dist/permissions.js.map +1 -1
- package/dist/platform-control.d.ts.map +1 -1
- package/dist/platform-control.js +1 -0
- package/dist/platform-control.js.map +1 -1
- package/dist/provider.d.ts +23 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +252 -44
- package/dist/provider.js.map +1 -1
- package/dist/run-cancel-observation.d.ts +50 -0
- package/dist/run-cancel-observation.d.ts.map +1 -0
- package/dist/run-cancel-observation.js +146 -0
- package/dist/run-cancel-observation.js.map +1 -0
- 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-custody.d.ts +29 -20
- package/dist/service-custody.d.ts.map +1 -1
- package/dist/service-custody.js +37 -167
- package/dist/service-custody.js.map +1 -1
- package/dist/service-endpoint-authority.d.ts +70 -0
- package/dist/service-endpoint-authority.d.ts.map +1 -0
- package/dist/service-endpoint-authority.js +257 -0
- package/dist/service-endpoint-authority.js.map +1 -0
- package/dist/service-host-contract.d.ts +158 -16
- package/dist/service-host-contract.d.ts.map +1 -1
- package/dist/service-host-contract.js +330 -1
- package/dist/service-host-contract.js.map +1 -1
- package/dist/service-host-ops.d.ts +550 -0
- package/dist/service-host-ops.d.ts.map +1 -0
- package/dist/service-host-ops.js +2180 -0
- package/dist/service-host-ops.js.map +1 -0
- package/dist/service-write.d.ts +141 -0
- package/dist/service-write.d.ts.map +1 -0
- package/dist/service-write.js +179 -0
- package/dist/service-write.js.map +1 -0
- package/dist/service.d.ts +115 -3
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +697 -92
- 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 +16 -7
package/dist/service.js
CHANGED
|
@@ -29,7 +29,8 @@ import { createToolResultConsentSigner, makeToolResultConsentRecorder } from "./
|
|
|
29
29
|
* 127.0.0.1; TLS terminates at a reverse proxy — in-process TLS was rejected: it duplicates cert
|
|
30
30
|
* renewal and forks deploy). It serves ONLY `GET /health`, `GET /jwks`, `POST /exchange`,
|
|
31
31
|
* `POST /manager-service-authority`, and `GET /.well-known/cotal-mesh` (the generated discovery
|
|
32
|
-
* bundle)
|
|
32
|
+
* bundle), plus, only with `serviceEndpoints`, `/service-endpoint-authority` on both faces;
|
|
33
|
+
* everything else 404s. Its
|
|
33
34
|
* `/exchange` demands NO capability — honestly: the 0600 cap is a same-uid file-ACL boundary with
|
|
34
35
|
* no remote meaning, so requiring its bytes from a remote caller would prove nothing. The proof on
|
|
35
36
|
* the public face is the credential itself: the human arm presents an EdDSA IdP JWT verified
|
|
@@ -62,7 +63,7 @@ import { resolve } from "node:path";
|
|
|
62
63
|
import { connect, credsAuthenticator } from "@nats-io/transport-node";
|
|
63
64
|
import { jetstream, jetstreamManager } from "@nats-io/jetstream";
|
|
64
65
|
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";
|
|
66
|
+
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, parseServiceAuthorityEnvelope, SERVICE_AUTHORITY_MAX_BYTES, epgateKey, isCasLoss, sameManagedGrantCustody, parseServiceRegistrationWitness, serviceWitnessDigest } from "@cotal-ai/core";
|
|
66
67
|
import { findCotalRoot, loadManagerInstanceIdentity, userAuthStateDir, workspaceSecretStore, createAuthInstanceIdentity, loadAuthInstanceIdentity } from "@cotal-ai/workspace";
|
|
67
68
|
import { decodeJwt } from "jose";
|
|
68
69
|
import { deriveOwnerForIdpSubject, platformControlOwner } from "./derive.js";
|
|
@@ -71,11 +72,11 @@ import { OwnedConnections } from "./owned-connections.js";
|
|
|
71
72
|
import { makePlatformSupervisorAuthority, makePlatformSupervisorEnd, platformSupervisorEnded, requirePlatformSupervisorAdmin } from "./platform-supervisor.js";
|
|
72
73
|
import { startAuthCallout } from "./callout.js";
|
|
73
74
|
import { createIdpBridge, verifyIdpToken, verifyToolResultHumanProof } from "./idp.js";
|
|
74
|
-
import { PUBLIC_EXCHANGE_VIEWS, assertTransferWriterClaim } from "./token.js";
|
|
75
|
+
import { PUBLIC_EXCHANGE_VIEWS, assertTransferWriterClaim, validateUserToken } from "./token.js";
|
|
75
76
|
import { grantCoordinates, verifySessionRedemption } from "./session-redemption.js";
|
|
76
77
|
import { pinnedJwksResolver } from "./issuer.js";
|
|
77
78
|
import { calloutPermissions } from "./permissions.js";
|
|
78
|
-
import { admitRemoteRun, authorizeRemoteManagerRenewal, authorizeRemoteRunAttempt, holdsRunFence, issueRemoteManagerAuthority, observedRunRequest, parseRemoteRunAdmissionRequest, parseRemoteRunAttemptRequest, parseRemoteRunRevokeRequest, revokeRemoteRun } from "./manager-authority.js";
|
|
79
|
+
import { handleRemoteRunCancel, parseRemoteRunCancelRequest, admitRemoteRun, handleRemoteRunSubmission, authorizeRemoteManagerRenewal, authorizeRemoteRunAttempt, holdsRunFence, issueRemoteManagerAuthority, observedRunRequest, parseRemoteRunAdmissionRequest, parseRemoteRunAttemptRequest, parseRemoteRunRevokeRequest, revokeRemoteRun } from "./manager-authority.js";
|
|
79
80
|
import { authorizeRemoteRetainedAgentValidation, completeRemoteRetainedAgentValidation, remoteManagerCurrentRegistrationProof } from "./retained-manager-validation.js";
|
|
80
81
|
import { authorizeRemoteManagedAgentEnrollment, authorizeRemoteManagedAgentPrepareRetirement, authorizeRemoteManagedAgentRuntimeCreate, authorizeRemoteManagedAgentRuntimeStatus } from "./managed-agent-enrollment.js";
|
|
81
82
|
import { authorizeRemoteManagerGoalIndexScan, completeRemoteManagerGoalIndexScan } from "./manager-goal-index.js";
|
|
@@ -83,11 +84,16 @@ import { authorizeRemoteManagerAdmin } from "./manager-admin-authorization.js";
|
|
|
83
84
|
import { authorizeRemoteManagerMaintenance, completeRemoteManagerMaintenance } from "./manager-maintenance.js";
|
|
84
85
|
import { validateRetainedManagedAgent } from "./continuity.js";
|
|
85
86
|
import { reconstructRemoteManagerServeGrant, remoteManagerServeGrantFromCluster, remoteManagerSurface } from "./manager-contract.js";
|
|
86
|
-
import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, platformReadinessGrants, remoteManagerIssuerGrants, remoteManagerRegistrationProof, registrationExecutorGrants, servedRunRequestSubjects } from "./authority-client.js";
|
|
87
|
+
import { authorityBarrierGrants, authorityWriterGrants, openAuthorityClient, openSupervisedConnectReader, platformReadinessGrants, remoteManagerIssuerGrants, remoteManagerRegistrationProof, registrationExecutorGrants, servedRunRequestSubjects, servedRunCancelRequestSubject, serviceCalloutWriterGrants, serviceHostDiscoveryGrants } from "./authority-client.js";
|
|
88
|
+
import { createNativeServiceCalloutAuthority } from "./native-service-callout.js";
|
|
87
89
|
import { authorizeConnectCredential } from "./connect-reader.js";
|
|
90
|
+
import { chatAwareAclResolver, openChatSessionAuthority } from "./chat-session-authority.js";
|
|
88
91
|
import { makeNativeIssuanceRecorder, pruneNativeIssuance } from "./native-issuance.js";
|
|
89
92
|
import { ensureRootCredential } from "./root-credential.js";
|
|
90
93
|
import { activateLifecycleAtUid, observeGate, openLifecycleRegistry, readLifecycleHeadForOperation } from "./lifecycle-registry.js";
|
|
94
|
+
import { openServiceHostEffect, openServiceHostOperations } from "./service-host-ops.js";
|
|
95
|
+
import { SERVICE_ENDPOINT_AUTHORITY_PATH, answerServiceAuthority, serviceAuthorityDenied, serviceAuthorityInfo } from "./service-endpoint-authority.js";
|
|
96
|
+
import { validateServiceRenewalProof } from "./service-renewal.js";
|
|
91
97
|
import { openAuthLedgerScannerCandidate } from "./ledger-scanner.js";
|
|
92
98
|
import { openRecordsScannerCandidate } from "./records-scanner.js";
|
|
93
99
|
import { acquirePlaneClaim, makeDeliveryAdminPlaneOracle, makeDeliveryAdminPrincipalOracle, scannerDeathCopy, planeClaimRefusal } from "./plane-claim.js";
|
|
@@ -97,16 +103,21 @@ import { resumeAgentRetirement, runAgentRetirementBarrier } from "./retirement-b
|
|
|
97
103
|
import { makeRetirementCleaners } from "./retirement-cleaner.js";
|
|
98
104
|
import { makeDrainRepairers } from "./drain-repair.js";
|
|
99
105
|
import { serializedFor } from "./serialized.js";
|
|
106
|
+
import { RunCancelObservations } from "./run-cancel-observation.js";
|
|
107
|
+
import { readCancellationAdmission } from "@cotal-ai/core";
|
|
100
108
|
import { joinOrStartRetirement, openAuthAdminListener } from "./auth-admin.js";
|
|
101
109
|
import { AUTH_SERVICE_ENDPOINT, authClusterArtifacts, authContractArtifactValues } from "./auth-service-contract.js";
|
|
102
110
|
import { drainTargetForEndpoint, openAdmissionMediator } from "./admission-mediator.js";
|
|
103
|
-
import { AGENT_BEARER_TTL_SEC, assertWithinSpawnerGrant, findInteractiveActor, findManagedActor, findActorUnified,
|
|
111
|
+
import { AGENT_BEARER_TTL_SEC, assertWithinSpawnerGrant, findInteractiveActor, findManagedActor, findActorUnified, ledgerAclResolver, ledgerActorSourceIsLive, ledgerAuthorizeAgentExchange, ledgerAuthorizeConnect, ledgerAuthorizeGrant, revokeManagedActorIf, withManagedGrantTransaction, } from "./ledger.js";
|
|
104
112
|
import { AUTH_PROVIDER_NAME, claimAuthInstanceIdentity, clearAuthServiceInfo, dataAccountAuth, loadCalloutAuth, loadIssuer, loadOwnerSecret, loadPinnedIdp, loadServiceKeys, saveAuthServiceInfo, spaceIssuer, } from "./store.js";
|
|
105
113
|
/** JWKS max-age seconds — the cache contract's knob. Exported so a rotation tool can compute the
|
|
106
114
|
* retire floor (max-age + max bearer TTL) from it. */
|
|
107
115
|
export const JWKS_MAX_AGE_SEC = 300;
|
|
108
116
|
/** Loopback-only operator route used by `cotal actor grant/revoke` before rotating or deleting an interactive lifecycle. */
|
|
109
117
|
export const INTERACTIVE_RETIRE_PATH = "/interactive-lifecycle/retire";
|
|
118
|
+
/** The user-mode chat-session routes, on the loopback face (capability-gated) and the public face.
|
|
119
|
+
* Each takes a fresh IdP proof; `bearer` and `retire` also take the session's holder proof. */
|
|
120
|
+
export const CHAT_SESSION_PATHS = Object.freeze({ open: "/chat-session/open", bearer: "/chat-session/bearer", retire: "/chat-session/retire" });
|
|
110
121
|
/** Loopback-only host route that finishes a managed lifecycle's terminal retirement after the host
|
|
111
122
|
* revoked its grant, when the remote manager that should have requested it is gone (#2070). */
|
|
112
123
|
export const MANAGED_RETIRE_PATH = "/managed-lifecycle/retire";
|
|
@@ -227,6 +238,14 @@ async function supervisorGeneration(session, space, owner, instanceId, lifecycle
|
|
|
227
238
|
throw new EpEnvelopeError("permission-denied", "platform supervisor accepted row names another owner or lifecycle");
|
|
228
239
|
return accepted.ref;
|
|
229
240
|
}
|
|
241
|
+
function runtimeSelection(options) {
|
|
242
|
+
if (!options || typeof options !== "object" || Array.isArray(options))
|
|
243
|
+
throw new Error("auth-service runtime options must be an object");
|
|
244
|
+
const selection = options.interactiveManagerSelection;
|
|
245
|
+
if (selection !== undefined && selection !== "local-primary")
|
|
246
|
+
throw new Error('interactiveManagerSelection must be "local-primary" when present');
|
|
247
|
+
return selection;
|
|
248
|
+
}
|
|
230
249
|
/**
|
|
231
250
|
* Open the authority plane — the PRODUCTION connect/exchange composition (exported so the live
|
|
232
251
|
* deny-new smoke exercises exactly what the daemon runs). Boot order is the readiness contract:
|
|
@@ -242,6 +261,28 @@ async function supervisorGeneration(session, space, owner, instanceId, lifecycle
|
|
|
242
261
|
export async function openAuthAuthorityPlane(opts) {
|
|
243
262
|
return await openAuthAuthorityPlaneWithCustody(opts);
|
|
244
263
|
}
|
|
264
|
+
/** The hosted door over a plane's service endpoint host: refused before entry unless the context is
|
|
265
|
+
* ready, and mapped to the same engine so the internal accessor still finds it. */
|
|
266
|
+
function hostedServiceEndpoints(host, ready) {
|
|
267
|
+
const door = async (request) => { ready(); return await host(request); };
|
|
268
|
+
const engine = SERVICE_HOST_ENGINES.get(host), callout = SERVICE_CALLOUTS.get(host);
|
|
269
|
+
if (engine !== undefined)
|
|
270
|
+
SERVICE_HOST_ENGINES.set(door, engine);
|
|
271
|
+
if (callout !== undefined)
|
|
272
|
+
SERVICE_CALLOUTS.set(door, callout);
|
|
273
|
+
return door;
|
|
274
|
+
}
|
|
275
|
+
/** Package-internal (not exported from the package index): the engine behind a plane's service
|
|
276
|
+
* endpoint host, whose register admission only a later owner-verifying route may call. */
|
|
277
|
+
const SERVICE_HOST_ENGINES = new WeakMap();
|
|
278
|
+
export function serviceHostEngineOf(host) {
|
|
279
|
+
return SERVICE_HOST_ENGINES.get(host);
|
|
280
|
+
}
|
|
281
|
+
/** Package-internal: the native service callout authority composed with a plane's service host. */
|
|
282
|
+
const SERVICE_CALLOUTS = new WeakMap();
|
|
283
|
+
export function serviceHostCalloutOf(host) {
|
|
284
|
+
return SERVICE_CALLOUTS.get(host);
|
|
285
|
+
}
|
|
245
286
|
/** The context's custody callback is private. Failed construction unwinds from one place. */
|
|
246
287
|
async function openAuthAuthorityPlaneWithCustody(opts, onConnection) {
|
|
247
288
|
const unwind = [];
|
|
@@ -256,7 +297,9 @@ async function openAuthAuthorityPlaneWithCustody(opts, onConnection) {
|
|
|
256
297
|
}
|
|
257
298
|
/** The body of the plane opener, which owns the unwind of a failed construction. */
|
|
258
299
|
async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
300
|
+
const interactiveManagerSelection = runtimeSelection(opts);
|
|
259
301
|
const { server, space, dataAccount, log } = opts;
|
|
302
|
+
const cancelObservations = new RunCancelObservations(space, opts.runCancelLimits);
|
|
260
303
|
const standingTtl = opts.standingRenewableTtlSeconds ?? STANDING_RENEWABLE_TTL_SEC;
|
|
261
304
|
if (!Number.isSafeInteger(standingTtl) || standingTtl < 5 || standingTtl > STANDING_RENEWABLE_TTL_SEC) {
|
|
262
305
|
throw new Error(`standingRenewableTtlSeconds must be an integer between 5 and ${STANDING_RENEWABLE_TTL_SEC} seconds (got ${standingTtl})`);
|
|
@@ -354,12 +397,17 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
|
354
397
|
// Mid-life disconnect = the FENCING event (security req: an owned scanner that dies must not
|
|
355
398
|
// leave the plane half-running). First death wins: fence the guard with the ux state-3 copy,
|
|
356
399
|
// close the sibling so a successor's reclaim isn't blocked by a half-dead pair, log loud.
|
|
400
|
+
let serviceHost;
|
|
357
401
|
const fenceOnDeath = (role, sibling) => {
|
|
358
|
-
if (closing)
|
|
359
|
-
return;
|
|
360
402
|
const copy = scannerDeathCopy(space, role);
|
|
403
|
+
// A closing plane does not go fatal, but a service host call it is still draining stops here.
|
|
404
|
+
if (closing) {
|
|
405
|
+
serviceHost?.trip(copy);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
361
408
|
hold?.fence(copy);
|
|
362
409
|
fatalReason = fatalReason ?? copy;
|
|
410
|
+
serviceHost?.trip(copy);
|
|
363
411
|
fireFatal(copy);
|
|
364
412
|
log(`auth-plane: ${copy}`);
|
|
365
413
|
void sibling().catch(() => { });
|
|
@@ -435,11 +483,20 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
|
435
483
|
unwind.push(() => authAdmin.close());
|
|
436
484
|
const recordToolResultConsent = makeToolResultConsentRecorder(registryStores(registry).authKv, () => refuseIfFenced(), space);
|
|
437
485
|
const fileArm = ledgerAuthorizeConnect(opts.dir);
|
|
486
|
+
// A chat principal (its alias is in the chat row space) is checked by the chat arm: its active
|
|
487
|
+
// row, its parent pin and its holder index. Every other principal by the grant-file arm.
|
|
488
|
+
const connectArm = async (t) => {
|
|
489
|
+
if (chat.isChatPrincipal(t.owner, t.act.actor))
|
|
490
|
+
await chat.authorizeConnect(t);
|
|
491
|
+
else
|
|
492
|
+
fileArm(t);
|
|
493
|
+
};
|
|
438
494
|
// 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.
|
|
495
|
+
// in the same stream before the minted JWT may leave. For a chat session this re-read is the
|
|
496
|
+
// post-sign fence: its own head and root credential, its parent pin and its holder index.
|
|
440
497
|
const recordIssuance = makeNativeIssuanceRecorder(registry, async (t) => {
|
|
441
498
|
refuseIfFenced();
|
|
442
|
-
|
|
499
|
+
await connectArm(t);
|
|
443
500
|
await authorizeConnectCredential(reader.current(), t, Date.now);
|
|
444
501
|
});
|
|
445
502
|
// Expired evidence goes at boot (a failure refuses the open) and then on the plane's own timer;
|
|
@@ -455,11 +512,15 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
|
455
512
|
return;
|
|
456
513
|
pruneTimer = setTimeout(() => {
|
|
457
514
|
pruneFlight = pruneNativeIssuance(registry, Math.floor(Date.now() / 1000)).then(schedulePrune, (e) => {
|
|
458
|
-
if (closing)
|
|
459
|
-
return;
|
|
460
515
|
const copy = `native-issuance cleanup failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
516
|
+
// As for a scanner death: a closing plane does not go fatal, but a call it is draining stops.
|
|
517
|
+
if (closing) {
|
|
518
|
+
serviceHost?.trip(copy);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
461
521
|
hold?.fence(copy);
|
|
462
522
|
fatalReason = fatalReason ?? copy;
|
|
523
|
+
serviceHost?.trip(copy);
|
|
463
524
|
fireFatal(copy);
|
|
464
525
|
log(`auth-plane: ${copy}`);
|
|
465
526
|
});
|
|
@@ -468,6 +529,84 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
|
468
529
|
const stopPrune = async () => { clearTimeout(pruneTimer); await pruneFlight; };
|
|
469
530
|
unwind.push(async () => { closing = true; await stopPrune(); });
|
|
470
531
|
schedulePrune();
|
|
532
|
+
// The service endpoint host, default off. Its discovery client only reads, on the ordinary
|
|
533
|
+
// renewing profile: a broker loss makes its reads fail until it reconnects, and a renewal failure
|
|
534
|
+
// closes it for good, after which decide and revoke answer unavailable until the plane is opened
|
|
535
|
+
// again. The engine shares this plane's hold; both fatal sites above trip it.
|
|
536
|
+
let discoveryClient, calloutWriter;
|
|
537
|
+
// Every effect connection the host opened whose close has not succeeded, kept past its call's
|
|
538
|
+
// settlement: plane close and a failed construction close it again and fail while any remains.
|
|
539
|
+
const openEffects = new Set();
|
|
540
|
+
const closeEffects = async () => {
|
|
541
|
+
const failed = [];
|
|
542
|
+
for (const e of [...openEffects]) {
|
|
543
|
+
try {
|
|
544
|
+
await e.close();
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
failed.push(err);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (failed.length > 0)
|
|
551
|
+
throw new AggregateError(failed, `${failed.length} service host effect connection(s) did not close`);
|
|
552
|
+
};
|
|
553
|
+
if (opts.serviceEndpoints !== undefined) {
|
|
554
|
+
const label = `cotal:service-host-discovery:${space}`;
|
|
555
|
+
const d = discoveryClient = await openAuthorityClient({ onConnection, server, space, dataAccount, label, grants: (id) => serviceHostDiscoveryGrants(space, id), log,
|
|
556
|
+
onRenewalFailure: () => {
|
|
557
|
+
log(`${label}: credential renewal failed - service host discovery is closed until the auth plane is opened again`);
|
|
558
|
+
void discoveryClient?.close().catch(() => { });
|
|
559
|
+
} });
|
|
560
|
+
unwind.push(() => d.close());
|
|
561
|
+
unwind.push(() => closeEffects());
|
|
562
|
+
const discovery = await openLifecycleRegistry(d.nc, space);
|
|
563
|
+
await opts.probeServiceHost?.discovery?.({ registry: discovery, kill: () => d.close() });
|
|
564
|
+
const probeEffect = opts.probeServiceHost?.effect;
|
|
565
|
+
// Serving reads registered closures from the contract store on the issuer's connection, which
|
|
566
|
+
// holds only the store's exact-subject Direct Get.
|
|
567
|
+
const contractStore = await contractStoreContext(remoteIssuer.nc, space);
|
|
568
|
+
const readContractArtifact = async (digest) => {
|
|
569
|
+
const bytes = await fetchContractArtifact(contractStore, contractRefToHex(digest));
|
|
570
|
+
if (!bytes)
|
|
571
|
+
throw new EpEnvelopeError("failed-precondition", `the registered contract artifact ${digest} is not readable`);
|
|
572
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
573
|
+
};
|
|
574
|
+
const engine = await openServiceHostOperations({
|
|
575
|
+
discovery, hold: hold, accountPublicKey: dataAccount.pub, ledgerDir: opts.dir, startup: opts.serviceEndpoints.startup,
|
|
576
|
+
serving: { readContractArtifact },
|
|
577
|
+
openEffect: async (scope) => {
|
|
578
|
+
const opened = await openServiceHostEffect({ onConnection, server, space, dataAccount, log }, scope);
|
|
579
|
+
const effect = { ...opened, close: async () => { await opened.close(); openEffects.delete(effect); } };
|
|
580
|
+
openEffects.add(effect);
|
|
581
|
+
if (probeEffect !== undefined) {
|
|
582
|
+
try {
|
|
583
|
+
await probeEffect(effect, scope);
|
|
584
|
+
}
|
|
585
|
+
catch (e) {
|
|
586
|
+
await effect.close().catch(() => { });
|
|
587
|
+
throw e;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return effect;
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
unwind.push(() => engine.close());
|
|
594
|
+
serviceHost = engine;
|
|
595
|
+
SERVICE_HOST_ENGINES.set(engine.handle, engine);
|
|
596
|
+
// A fence while the engine was opening latches it now.
|
|
597
|
+
if (fatalReason !== undefined)
|
|
598
|
+
engine.trip(fatalReason);
|
|
599
|
+
// The native service callout: a service bearer's connect mints only for the current bound
|
|
600
|
+
// assignment its witness names, read from the engine, on a writer that stages and touches the
|
|
601
|
+
// endpoint gate and credential rows. A fenced plane or a latched engine refuses it.
|
|
602
|
+
const calloutLabel = `cotal:service-callout:${space}`;
|
|
603
|
+
const cw = calloutWriter = await openAuthorityClient({ onConnection, server, space, dataAccount, label: calloutLabel, grants: (id) => serviceCalloutWriterGrants(space, id), log });
|
|
604
|
+
unwind.push(() => cw.close());
|
|
605
|
+
SERVICE_CALLOUTS.set(engine.handle, createNativeServiceCalloutAuthority({
|
|
606
|
+
registry: await openLifecycleRegistry(cw.nc, space), reader: () => reader.current(), dataAccount,
|
|
607
|
+
resolveAssignment: (witness) => engine.serviceBinding(witness), readClusterArtifact: readContractArtifact, assertOpen: () => refuseIfFenced(),
|
|
608
|
+
}));
|
|
609
|
+
}
|
|
471
610
|
const recordsJsm = await jetstreamManager(remoteIssuer.nc);
|
|
472
611
|
const authKv = await new Kvm(remoteIssuer.nc).open(epAuthBucket(space));
|
|
473
612
|
const managerIssuanceGate = (instanceId) => serveIssuanceGateKv(authKv, space, { endpoint: "manager", instanceId });
|
|
@@ -561,6 +700,24 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
|
561
700
|
throw new Error(`manager instance ${instanceId} is ${verdict}`);
|
|
562
701
|
return instanceId;
|
|
563
702
|
}
|
|
703
|
+
// A trusted host may choose the local Manager up front for human defaults. Explicit pins
|
|
704
|
+
// above and managed-agent calls keep their existing selection; no remote is a substitute.
|
|
705
|
+
if (caller === "human" && interactiveManagerSelection === "local-primary") {
|
|
706
|
+
let local;
|
|
707
|
+
try {
|
|
708
|
+
local = loadLocalManager();
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
throw new Error("interactive local-primary manager identity could not be read");
|
|
712
|
+
}
|
|
713
|
+
if (!local)
|
|
714
|
+
throw new Error("interactive local-primary manager identity is absent");
|
|
715
|
+
const instanceId = assertLifecycleToken(local.instanceId, "managerInstanceId");
|
|
716
|
+
const verdict = await managerGate(owner, instanceId);
|
|
717
|
+
if (verdict !== "candidate")
|
|
718
|
+
throw new Error(`interactive local-primary manager instance ${instanceId} is ${verdict}`);
|
|
719
|
+
return instanceId;
|
|
720
|
+
}
|
|
564
721
|
const { instances } = await listManagerInstances(owner);
|
|
565
722
|
const remoteInstances = instances.filter((i) => i.placement === "remote").map((i) => i.instanceId);
|
|
566
723
|
const remoteLive = instances.filter((i) => i.placement === "remote" && i.state === "candidate").map((i) => i.instanceId);
|
|
@@ -593,6 +750,12 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
|
593
750
|
// envelope asked (SPEC 14.8): the forward's coordinates are the manager's word. The manager
|
|
594
751
|
// forwards from inside the handler serving the request, so an observation older than the window
|
|
595
752
|
// has no forward left to bind; the window also bounds the table.
|
|
753
|
+
remoteIssuer.nc.subscribe(servedRunCancelRequestSubject(space), {
|
|
754
|
+
callback: (error, message) => {
|
|
755
|
+
if (!error)
|
|
756
|
+
cancelObservations.observe(message.subject, message.data);
|
|
757
|
+
},
|
|
758
|
+
});
|
|
596
759
|
const observedRunRequests = new Map();
|
|
597
760
|
for (const subject of servedRunRequestSubjects(space))
|
|
598
761
|
remoteIssuer.nc.subscribe(subject, {
|
|
@@ -626,12 +789,94 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
|
626
789
|
const creds = await mintCreds(issuerAuth(), newIdentity(), "deprovisioner", { deprovisionTarget: { principal: targetId, lifecycleUid, memberChannels } });
|
|
627
790
|
await deprovisionAgent({ servers: server, space, targetId, lifecycleUid, memberChannels, creds });
|
|
628
791
|
};
|
|
792
|
+
// A chat session's footprint (admission a7): its own bind-only DM and DELIVER durables and the
|
|
793
|
+
// read-ACL row the delivery daemon authorizes its history and durable joins from, written by a
|
|
794
|
+
// short-lived provisioner and read back on that same connection (part of a8).
|
|
795
|
+
const provisionChatFootprint = async ({ owner, actor, lifecycleUid, read }) => {
|
|
796
|
+
const identity = newIdentity();
|
|
797
|
+
let conn;
|
|
798
|
+
const provisioner = new CotalEndpoint({
|
|
799
|
+
space,
|
|
800
|
+
servers: server,
|
|
801
|
+
onConnection: (nc) => { conn = nc; onConnection?.(nc, `cotal:chat-provisioner:${space}`); },
|
|
802
|
+
channels: [],
|
|
803
|
+
creds: await mintCreds(issuerAuth(), identity, "provisioner"),
|
|
804
|
+
card: { id: identity.id, name: "provisioner", role: "provisioner", kind: "endpoint" },
|
|
805
|
+
registerPresence: false,
|
|
806
|
+
watchPresence: false,
|
|
807
|
+
watchChannels: false,
|
|
808
|
+
consume: false,
|
|
809
|
+
});
|
|
810
|
+
await provisioner.start();
|
|
811
|
+
try {
|
|
812
|
+
await provisionAgentDurables(provisioner, { owner, actor, lifecycleUid }, { subscribe: [], allowSubscribe: read });
|
|
813
|
+
if (conn === undefined)
|
|
814
|
+
throw new EpEnvelopeError("unavailable", "the chat provisioner connection is unavailable for the read-back", undefined, "unknown");
|
|
815
|
+
const jsm = await jetstreamManager(conn);
|
|
816
|
+
await jsm.consumers.info(dmStream(space), dmDurable(owner, actor, lifecycleUid));
|
|
817
|
+
await jsm.consumers.info(dlvStream(space), dlvDurable(owner, actor, lifecycleUid));
|
|
818
|
+
const acl = await provisioner.aclForOwner(principalKey(owner, actor).key, lifecycleUid);
|
|
819
|
+
if (acl === undefined || JSON.stringify(acl) !== JSON.stringify(read))
|
|
820
|
+
throw new EpEnvelopeError("unavailable", `the chat session's read-ACL row did not read back for ${lifecycleUid}`, undefined, "unknown");
|
|
821
|
+
}
|
|
822
|
+
finally {
|
|
823
|
+
await provisioner.stop();
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
const chat = openChatSessionAuthority({
|
|
827
|
+
space,
|
|
828
|
+
dir: opts.dir,
|
|
829
|
+
registry,
|
|
830
|
+
managerInstance: `auth-service:${space}`,
|
|
831
|
+
retireLifecycle: async ({ owner, actor, lifecycleUid, opId }) => {
|
|
832
|
+
const flight = joinOrStartRetirement(retirementFlights, barrierReg, { owner, actor, lifecycleUid, opId, frontierStreams: retirementFrontierStreams(space) }, retirement);
|
|
833
|
+
if (flight === undefined)
|
|
834
|
+
throw new EpEnvelopeError("conflict", `chat retirement operation ${opId} is already in flight for different coordinates`);
|
|
835
|
+
await flight;
|
|
836
|
+
},
|
|
837
|
+
provisionFootprint: provisionChatFootprint,
|
|
838
|
+
deprovisionFootprint: ({ owner, actor, lifecycleUid, memberChannels }) => deprovisionManagedFootprint(owner, actor, lifecycleUid, memberChannels),
|
|
839
|
+
now: Date.now,
|
|
840
|
+
log,
|
|
841
|
+
});
|
|
842
|
+
// One bounded chat sweep pass per interval. A failed pass is logged and the next one retries; it
|
|
843
|
+
// never fences the plane, because every chat authority decision re-reads its own records.
|
|
844
|
+
const chatSweepMs = opts.chatSweepMs ?? 60_000;
|
|
845
|
+
if (!Number.isSafeInteger(chatSweepMs) || chatSweepMs < 100 || chatSweepMs > 3_600_000)
|
|
846
|
+
throw new Error(`chatSweepMs must be an integer between 100 and 3600000 (got ${chatSweepMs})`);
|
|
847
|
+
let chatSweepTimer;
|
|
848
|
+
let chatSweepFlight = Promise.resolve();
|
|
849
|
+
const scheduleChatSweep = () => {
|
|
850
|
+
if (closing || fatalReason !== undefined)
|
|
851
|
+
return;
|
|
852
|
+
chatSweepTimer = setTimeout(() => {
|
|
853
|
+
chatSweepFlight = chat.sweepPass().catch((e) => {
|
|
854
|
+
if (!closing)
|
|
855
|
+
log(`auth-plane: chat sweep pass failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
856
|
+
}).then(scheduleChatSweep);
|
|
857
|
+
}, chatSweepMs);
|
|
858
|
+
};
|
|
859
|
+
const stopChatSweep = async () => { clearTimeout(chatSweepTimer); await chatSweepFlight; };
|
|
860
|
+
unwind.push(async () => { closing = true; await stopChatSweep(); });
|
|
861
|
+
scheduleChatSweep();
|
|
629
862
|
return {
|
|
630
863
|
authorizeConnect: async (t) => {
|
|
631
864
|
refuseIfFenced();
|
|
632
|
-
|
|
865
|
+
await connectArm(t);
|
|
633
866
|
await authorizeConnectCredential(reader.current(), t, Date.now);
|
|
634
867
|
},
|
|
868
|
+
openChatSession: async (args) => {
|
|
869
|
+
refuseIfFenced();
|
|
870
|
+
return chat.admit(args);
|
|
871
|
+
},
|
|
872
|
+
chatSessionBearer: async (args) => {
|
|
873
|
+
refuseIfFenced();
|
|
874
|
+
return chat.exchange(args);
|
|
875
|
+
},
|
|
876
|
+
retireChatSession: async (args) => {
|
|
877
|
+
refuseIfFenced();
|
|
878
|
+
return chat.retire(args);
|
|
879
|
+
},
|
|
635
880
|
recordIssuance: (i) => {
|
|
636
881
|
refuseIfFenced();
|
|
637
882
|
return recordIssuance(i);
|
|
@@ -696,11 +941,18 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
|
696
941
|
}, principal, claim);
|
|
697
942
|
},
|
|
698
943
|
retireInteractiveLifecycle: async (args) => {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
944
|
+
// Everything before the barrier only reads, so a refusal there retired nothing.
|
|
945
|
+
let owner, actor, lifecycleUid;
|
|
946
|
+
try {
|
|
947
|
+
refuseIfFenced();
|
|
948
|
+
owner = assertDerivedOwnerToken(args.owner);
|
|
949
|
+
actor = assertValidOwnerToken(args.actor);
|
|
950
|
+
lifecycleUid = assertLifecycleToken(args.lifecycleUid);
|
|
951
|
+
}
|
|
952
|
+
catch (e) {
|
|
953
|
+
throw notExecuted(e, "bad-request");
|
|
954
|
+
}
|
|
955
|
+
const head = await readLifecycleHeadForOperation(barrierReg, owner, actor).catch((e) => { throw notExecuted(e, "unavailable"); });
|
|
704
956
|
if (head === undefined)
|
|
705
957
|
return { retired: false, lifecycleUid, notStarted: true };
|
|
706
958
|
if (head.mapping.lifecycleUid !== lifecycleUid) {
|
|
@@ -709,7 +961,7 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
|
709
961
|
// An active/retiring foreign uid is different: the ledger and authority disagree, so fail.
|
|
710
962
|
if (head.mapping.state === "retired")
|
|
711
963
|
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}
|
|
964
|
+
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
965
|
}
|
|
714
966
|
if (head.mapping.state === "retired")
|
|
715
967
|
return { retired: true, lifecycleUid, alreadyRetired: true };
|
|
@@ -1157,31 +1409,48 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
|
1157
1409
|
const t = r.target;
|
|
1158
1410
|
const subscribe = t.subscribe ?? [];
|
|
1159
1411
|
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
|
-
|
|
1412
|
+
// The custody this enrollment attests: the instance the authorized request names, checked
|
|
1413
|
+
// against its current registration above. Never a field of the participant's target.
|
|
1414
|
+
const custody = { kind: "remote-manager", instanceId: r.instanceId };
|
|
1415
|
+
// ONE synchronous transaction for the read, the decision and the write, so an
|
|
1416
|
+
// enrollment in another process (or a provider grant) cannot land between them.
|
|
1417
|
+
const decided = withManagedGrantTransaction(opts.dir, (tx) => {
|
|
1418
|
+
const current = tx.read(owner, t.actor);
|
|
1419
|
+
const held = current?.kind === "managed-agent" ? current : undefined;
|
|
1420
|
+
// The token digest names one enrollment. A held row with this request's digest is that
|
|
1421
|
+
// enrollment retried, so it answers the same uid; any other held row is a lifecycle that
|
|
1422
|
+
// still runs, and taking its grant would strand it.
|
|
1423
|
+
if (held !== undefined && held.tokenHash !== t.tokenHash)
|
|
1424
|
+
throw new EpEnvelopeError("conflict", `managed agent "${t.actor}" is enrolled at lifecycle ${held.lifecycleUid}; prepare that lifecycle's retirement before enrolling a successor`);
|
|
1425
|
+
// A retry is this enrollment only if the held row records this exact custody. A row with
|
|
1426
|
+
// none, or with another, is never upgraded or adopted here.
|
|
1427
|
+
if (held !== undefined && (held.custody === undefined || !sameManagedGrantCustody(held.custody, custody)))
|
|
1428
|
+
throw new EpEnvelopeError("conflict", `managed agent "${t.actor}" is held at lifecycle ${held.lifecycleUid} under another custody; it was not adopted by this enrollment`);
|
|
1429
|
+
// A retry walks the held grant again: its spawner chain may have narrowed since the
|
|
1430
|
+
// first answer, and provisioning would restore what it took away.
|
|
1431
|
+
if (held !== undefined) {
|
|
1432
|
+
assertWithinSpawnerGrant(opts.dir, held, "spawn");
|
|
1433
|
+
return { lifecycleUid: held.lifecycleUid, row: held, wrote: false };
|
|
1434
|
+
}
|
|
1435
|
+
// A fresh uid is never one a retirement tombstoned, and only the host can make that
|
|
1436
|
+
// promise. The grant's envelope walk refuses an over-wide request before any durable exists.
|
|
1437
|
+
const lifecycleUid = mintLifecycleUid();
|
|
1438
|
+
const row = tx.grantManaged({
|
|
1439
|
+
owner,
|
|
1440
|
+
actor: t.actor,
|
|
1441
|
+
scope: t.capabilities ?? [],
|
|
1442
|
+
allowSubscribe: t.allowSubscribe ?? [],
|
|
1443
|
+
allowPublish: t.allowPublish ?? [],
|
|
1444
|
+
...(t.role !== undefined ? { role: t.role } : {}),
|
|
1445
|
+
...(t.label !== undefined ? { label: t.label } : {}),
|
|
1446
|
+
parent: principalKey(owner, r.actor).key,
|
|
1447
|
+
lifecycleUid,
|
|
1448
|
+
tokenHash: t.tokenHash,
|
|
1449
|
+
custody,
|
|
1450
|
+
});
|
|
1451
|
+
return { lifecycleUid, row, wrote: true };
|
|
1184
1452
|
});
|
|
1453
|
+
const { lifecycleUid, row } = decided;
|
|
1185
1454
|
try {
|
|
1186
1455
|
const identity = newIdentity();
|
|
1187
1456
|
const provisioner = new CotalEndpoint({
|
|
@@ -1207,9 +1476,10 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
|
1207
1476
|
catch (e) {
|
|
1208
1477
|
// A retried enrollment's first answer may already have reached the participant, so its
|
|
1209
1478
|
// 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
|
-
|
|
1479
|
+
// retired: it and any durable it got must go now. The revoke removes only the row this
|
|
1480
|
+
// call wrote: same uid AND same custody, read under the grant lock.
|
|
1481
|
+
if (decided.wrote) {
|
|
1482
|
+
revokeManagedActorIf(opts.dir, owner, t.actor, { lifecycleUid, custody });
|
|
1213
1483
|
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
1484
|
}
|
|
1215
1485
|
throw e;
|
|
@@ -1249,8 +1519,17 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
|
1249
1519
|
const row = findManagedActor(opts.dir, owner, actor);
|
|
1250
1520
|
if (row?.lifecycleUid === lifecycleUid) {
|
|
1251
1521
|
// Footprint before grant: while the row stands, a retry still knows which channels to purge.
|
|
1522
|
+
// The footprint work is async, so it runs outside the grant lock.
|
|
1252
1523
|
await deprovisionManagedFootprint(owner, actor, lifecycleUid, row.allowSubscribe.filter(isConcreteChannel));
|
|
1253
|
-
|
|
1524
|
+
// The decision and the write are one transaction: the row removed is the one read above
|
|
1525
|
+
// only if it still names this uid and the custody it carried then. A row written before
|
|
1526
|
+
// custody existed is removed by uid alone, as before.
|
|
1527
|
+
withManagedGrantTransaction(opts.dir, (tx) => {
|
|
1528
|
+
if (row.custody !== undefined)
|
|
1529
|
+
tx.revokeManagedIf(owner, actor, { lifecycleUid, custody: row.custody });
|
|
1530
|
+
else
|
|
1531
|
+
tx.revokeManagedAt(owner, actor, lifecycleUid);
|
|
1532
|
+
});
|
|
1254
1533
|
}
|
|
1255
1534
|
});
|
|
1256
1535
|
return {
|
|
@@ -1301,6 +1580,34 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
|
1301
1580
|
observeManagerGate: observeLegacyManagerGate,
|
|
1302
1581
|
});
|
|
1303
1582
|
},
|
|
1583
|
+
submitManagerRun: async ({ request, ...holder }) => {
|
|
1584
|
+
refuseIfFenced();
|
|
1585
|
+
const { owner } = holder;
|
|
1586
|
+
requireManagerAuthorityHolder(holder, String(request?.instanceId), 'keyed run submission needs scope "supervise"');
|
|
1587
|
+
return withIssuerSession({ servers: server, space, auth: issuerAuth(), tls: false }, session => handleRemoteRunSubmission({
|
|
1588
|
+
request, owner, space, accountPublicKey: dataAccount.pub, proofSecret: dataAccount.signingSeed, endpoint: "manager",
|
|
1589
|
+
observeManagerGate: observeLegacyManagerGate, issued: session.store, sourceIsLive: composedSourceIsLive(session),
|
|
1590
|
+
takeObserved: takeObservedRunRequest,
|
|
1591
|
+
registeredCommand: async (instanceId, registrationRevision, command) => {
|
|
1592
|
+
const registered = remoteManagerSurface(await registeredManagerCluster(owner, instanceId, { registrationRevision }))[command];
|
|
1593
|
+
if (!registered)
|
|
1594
|
+
throw new EpEnvelopeError("failed-precondition", "registered manager has no keyed contract");
|
|
1595
|
+
return registered;
|
|
1596
|
+
},
|
|
1597
|
+
withStore: async (runId, submission, work) => {
|
|
1598
|
+
const nc = await connect({ servers: server, ...standaloneConnectOpts({
|
|
1599
|
+
creds: await mintCreds(issuerAuth(), newIdentity(), "run-admitter", { runAdmitter: { endpoint: "manager", runId, submission }, expiresInSeconds: 60 }), tls: false,
|
|
1600
|
+
}), maxReconnectAttempts: 0 });
|
|
1601
|
+
onConnection?.(nc, `cotal:keyed-admitter:${space}`);
|
|
1602
|
+
try {
|
|
1603
|
+
return await work(await new Kvm(nc).open(admissionBucket(space)), await jetstreamManager(nc));
|
|
1604
|
+
}
|
|
1605
|
+
finally {
|
|
1606
|
+
await nc.drain().catch(() => nc.close());
|
|
1607
|
+
}
|
|
1608
|
+
},
|
|
1609
|
+
}));
|
|
1610
|
+
},
|
|
1304
1611
|
admitManagerRun: async ({ request, ...holder }) => {
|
|
1305
1612
|
refuseIfFenced();
|
|
1306
1613
|
const { owner } = holder;
|
|
@@ -1333,6 +1640,46 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
|
1333
1640
|
}
|
|
1334
1641
|
});
|
|
1335
1642
|
},
|
|
1643
|
+
cancelManagerRun: async ({ request, ...holder }) => {
|
|
1644
|
+
refuseIfFenced();
|
|
1645
|
+
const req = parseRemoteRunCancelRequest(request);
|
|
1646
|
+
requireManagerAuthorityHolder(holder, req.instanceId, 'manager run cancellation needs scope "supervise"');
|
|
1647
|
+
return withIssuerSession({ servers: server, space, auth: issuerAuth(), tls: false }, session => handleRemoteRunCancel({
|
|
1648
|
+
request: req, owner: holder.owner, space, endpoint: "manager", accountPublicKey: dataAccount.pub,
|
|
1649
|
+
proofSecret: dataAccount.signingSeed, observeManagerGate: observeLegacyManagerGate,
|
|
1650
|
+
observations: cancelObservations, flush: () => remoteIssuer.nc.flush(), assertServing: refuseIfFenced,
|
|
1651
|
+
issued: session.store, sourceIsLive: composedSourceIsLive(session),
|
|
1652
|
+
interactiveScope: async (caller, sources) => {
|
|
1653
|
+
if (sources.length !== 1 || sources[0].space !== space)
|
|
1654
|
+
return undefined;
|
|
1655
|
+
const source = parseActorLedgerSource(sources[0]);
|
|
1656
|
+
if (!source || source.owner !== caller.owner || source.actor !== caller.actor || source.lifecycleUid !== caller.uid)
|
|
1657
|
+
return undefined;
|
|
1658
|
+
const row = findActorUnified(opts.dir, caller.owner, caller.actor);
|
|
1659
|
+
return row?.kind === "interactive" && row.lifecycleUid === caller.uid ? [...row.scope] : undefined;
|
|
1660
|
+
},
|
|
1661
|
+
registeredCommand: async (instanceId, registrationRevision, command) => {
|
|
1662
|
+
const registered = remoteManagerSurface(await registeredManagerCluster(holder.owner, instanceId, { registrationRevision }))[command];
|
|
1663
|
+
if (!registered)
|
|
1664
|
+
throw new EpEnvelopeError("failed-precondition", "registered manager has no run-cancel contract");
|
|
1665
|
+
return registered;
|
|
1666
|
+
},
|
|
1667
|
+
readAdmission: runId => readCancellationAdmission(recordsJsm, space, "manager", runId),
|
|
1668
|
+
readRevocation: runId => readRunRevocation(recordsJsm, space, "manager", runId),
|
|
1669
|
+
withStore: async (runId, work) => {
|
|
1670
|
+
const nc = await connect({ servers: server, ...standaloneConnectOpts({
|
|
1671
|
+
creds: await mintCreds(issuerAuth(), newIdentity(), "run-admitter", { runAdmitter: { endpoint: "manager", runId }, expiresInSeconds: 60 }), tls: false,
|
|
1672
|
+
}), maxReconnectAttempts: 0 });
|
|
1673
|
+
onConnection?.(nc, `cotal:run-cancel:${space}`);
|
|
1674
|
+
try {
|
|
1675
|
+
return await work(await new Kvm(nc).open(admissionBucket(space)));
|
|
1676
|
+
}
|
|
1677
|
+
finally {
|
|
1678
|
+
await nc.drain().catch(() => nc.close());
|
|
1679
|
+
}
|
|
1680
|
+
},
|
|
1681
|
+
}));
|
|
1682
|
+
},
|
|
1336
1683
|
revokeManagerRun: async ({ request, ...holder }) => {
|
|
1337
1684
|
refuseIfFenced();
|
|
1338
1685
|
const req = parseRemoteRunRevokeRequest(request);
|
|
@@ -1502,13 +1849,24 @@ async function buildAuthAuthorityPlane(opts, unwind, onConnection) {
|
|
|
1502
1849
|
return await serveIssuanceGateKv(authKv, space, { endpoint, instanceId }).observe();
|
|
1503
1850
|
},
|
|
1504
1851
|
fenced,
|
|
1852
|
+
...(serviceHost !== undefined ? { serviceEndpointHost: serviceHost.handle } : {}),
|
|
1505
1853
|
close: async () => {
|
|
1506
1854
|
// Clean-close order (SPEC 13.13): the rail stops answering first, then scan-capable
|
|
1507
1855
|
// clients down, then `held → released` (never released while either scanner can still
|
|
1508
1856
|
// act), then the barrier that wrote it. An unreachable release keeps the barrier alive
|
|
1509
1857
|
// for a later close retry; successful scanner teardown is not run a second time.
|
|
1510
1858
|
closing = true;
|
|
1859
|
+
// Before the first await: no service host call enters once close has begun.
|
|
1860
|
+
serviceHost?.refuse();
|
|
1511
1861
|
await stopPrune();
|
|
1862
|
+
await stopChatSweep();
|
|
1863
|
+
// Every service host call that entered returns, discovery and effects included, then every
|
|
1864
|
+
// effect connection that did not close is closed again, before discovery closes and before
|
|
1865
|
+
// the claim is released. A failure here rejects with the claim still held; close retries it.
|
|
1866
|
+
await serviceHost?.close();
|
|
1867
|
+
await closeEffects();
|
|
1868
|
+
await calloutWriter?.close();
|
|
1869
|
+
await discoveryClient?.close();
|
|
1512
1870
|
const scannerFlight = closeScanners ??= (async () => {
|
|
1513
1871
|
await authAdmin?.close();
|
|
1514
1872
|
const results = await Promise.allSettled([reader.close(), recordsScanner.close(), scanner.close()]);
|
|
@@ -1609,19 +1967,8 @@ async function resumeOpenOperations(reg, evictPrincipal, retirement, log) {
|
|
|
1609
1967
|
}
|
|
1610
1968
|
}
|
|
1611
1969
|
}
|
|
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) {
|
|
1970
|
+
/** Pure native daemon input validation, shared with its explicit service launch plan. */
|
|
1971
|
+
export function authServiceConfiguration(args) {
|
|
1625
1972
|
const v = args.values;
|
|
1626
1973
|
const space = v.space;
|
|
1627
1974
|
if (!space)
|
|
@@ -1652,9 +1999,27 @@ export async function runAuthService(args, store) {
|
|
|
1652
1999
|
const badFace = publicFace && checkPublicFace(publicFace);
|
|
1653
2000
|
if (badFace)
|
|
1654
2001
|
throw new Error(badFace);
|
|
2002
|
+
return { space, server, port, publicFace };
|
|
2003
|
+
}
|
|
2004
|
+
/** Run the auth service. Flags: `--space` (required), `--server` (broker URL, required), `--port`
|
|
2005
|
+
* (loopback HTTP port; default ephemeral). All persisted material must already exist (the
|
|
2006
|
+
* provider's `prepareServer` ran at `cotal up`) — a missing piece is a fail-loud config error
|
|
2007
|
+
* naming the fix, never a silent partial service; this daemon LOADS the four secret kinds and
|
|
2008
|
+
* never generates them.
|
|
2009
|
+
*
|
|
2010
|
+
* `store` is the hosted composition's injection point for the space's SECRET material (callout
|
|
2011
|
+
* account, issuer keys, owner secret, service key projection) — when given, it is those kinds'
|
|
2012
|
+
* ONLY source (no flag or path can point this daemon at other secret material; an absent key is
|
|
2013
|
+
* the hard error below). Absent, the daemon composes the local workspace store over `.cotal/`.
|
|
2014
|
+
* NON-SEAM state (the actor ledger, the IdP pin, the discovery file) still lives in the local
|
|
2015
|
+
* state dir either way — hosting those is a later, separate seam. */
|
|
2016
|
+
export async function runAuthService(args, store, options = {}) {
|
|
2017
|
+
const interactiveManagerSelection = runtimeSelection(options);
|
|
2018
|
+
const lifecycle = "root" in options ? options : undefined;
|
|
2019
|
+
const { space, server, port, publicFace } = authServiceConfiguration(args);
|
|
1655
2020
|
// The provider's space-scoped state dir for NON-SEAM material (ledger, IdP pin, discovery). The
|
|
1656
2021
|
// layout fact is workspace-owned (userAuthStateDir); this daemon never touches `.cotal/auth/auth.json`.
|
|
1657
|
-
const root = findCotalRoot();
|
|
2022
|
+
const root = lifecycle?.root ?? findCotalRoot();
|
|
1658
2023
|
const dir = userAuthStateDir(root, space);
|
|
1659
2024
|
const secrets = store ?? workspaceSecretStore(root);
|
|
1660
2025
|
// Scrub any stale discovery file FIRST — a dead prior daemon's entry must never satisfy a
|
|
@@ -1686,25 +2051,44 @@ export async function runAuthService(args, store) {
|
|
|
1686
2051
|
// The CLI composition's local manager is this workspace root's persisted instance, re-read per
|
|
1687
2052
|
// selection exactly as before. A hosted context passes no local manager at all.
|
|
1688
2053
|
localManager: () => loadManagerInstanceIdentity(root, space),
|
|
2054
|
+
...(interactiveManagerSelection !== undefined ? { interactiveManagerSelection } : {}),
|
|
2055
|
+
...(options.runCancelLimits !== undefined ? { runCancelLimits: options.runCancelLimits } : {}),
|
|
1689
2056
|
});
|
|
1690
2057
|
// All planes bound — NOW write the discovery file (its existence is the readiness signal).
|
|
1691
2058
|
const { url, publicUrl, cap } = started.handle;
|
|
1692
2059
|
saveAuthServiceInfo(dir, { url, pid: process.pid, cap, ...(publicUrl !== undefined ? { publicUrl } : {}) });
|
|
1693
|
-
|
|
2060
|
+
if (!lifecycle?.quiet)
|
|
2061
|
+
console.log(`✓ auth service up (space ${space}) - callout on ${server}, exchange/JWKS at ${url}${publicUrl !== undefined ? `, public exchange at ${publicUrl}` : ""}`);
|
|
1694
2062
|
// The CLI wrapper, not the context, owns process signals and exit codes.
|
|
1695
|
-
|
|
2063
|
+
let stopping;
|
|
2064
|
+
const stop = () => stopping ??= (async () => {
|
|
1696
2065
|
clearAuthServiceInfo(dir); // a dead service must not satisfy the next start's readiness poll
|
|
1697
2066
|
try {
|
|
1698
2067
|
await started.handle.close();
|
|
2068
|
+
await lifecycle?.onClosed();
|
|
1699
2069
|
}
|
|
1700
2070
|
catch (e) {
|
|
1701
|
-
console.error(`✗ auth-service: ${e.message} - exiting`);
|
|
2071
|
+
console.error(lifecycle ? "✗ native auth-service shutdown did not confirm completion; custody was retained" : `✗ auth-service: ${e.message} - exiting`);
|
|
1702
2072
|
process.exit(1);
|
|
1703
2073
|
}
|
|
1704
2074
|
process.exit(0);
|
|
1705
|
-
};
|
|
2075
|
+
})();
|
|
1706
2076
|
process.on("SIGINT", () => void stop());
|
|
1707
2077
|
process.on("SIGTERM", () => void stop());
|
|
2078
|
+
if (lifecycle) {
|
|
2079
|
+
try {
|
|
2080
|
+
await lifecycle.onReady();
|
|
2081
|
+
}
|
|
2082
|
+
catch {
|
|
2083
|
+
clearAuthServiceInfo(dir);
|
|
2084
|
+
try {
|
|
2085
|
+
await started.handle.close();
|
|
2086
|
+
await lifecycle.onClosed();
|
|
2087
|
+
}
|
|
2088
|
+
catch { /* Unknown close keeps custody. */ }
|
|
2089
|
+
throw new Error("the native auth-service startup receipt was not published; no new launch was retried");
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
1708
2092
|
// A dropped broker connection is fatal-loud, not a zombie: the supervising `up`/`down` lifecycle
|
|
1709
2093
|
// owns restarts, and a callout that silently stopped answering would hang every user connect.
|
|
1710
2094
|
// A FENCED plane is equally fatal (SPEC 13.13): its scanners are no longer whole, every authority
|
|
@@ -1713,13 +2097,14 @@ export async function runAuthService(args, store) {
|
|
|
1713
2097
|
const ended = await started.ended;
|
|
1714
2098
|
clearAuthServiceInfo(dir);
|
|
1715
2099
|
if (ended.kind === "fenced") {
|
|
1716
|
-
console.error(`✗ auth-service: ${ended.cause} - exiting`);
|
|
2100
|
+
console.error(lifecycle ? "✗ native auth-service authority was fenced; custody was retained" : `✗ auth-service: ${ended.cause} - exiting`);
|
|
1717
2101
|
process.exit(1);
|
|
1718
2102
|
}
|
|
1719
2103
|
if (ended.cause !== undefined) {
|
|
1720
|
-
console.error(`✗ auth-service: broker connection closed (${ended.cause}) - exiting`);
|
|
2104
|
+
console.error(lifecycle ? "✗ native auth-service broker connection closed; custody was retained" : `✗ auth-service: broker connection closed (${ended.cause}) - exiting`);
|
|
1721
2105
|
process.exit(1);
|
|
1722
2106
|
}
|
|
2107
|
+
await lifecycle?.onClosed();
|
|
1723
2108
|
process.exit(0);
|
|
1724
2109
|
}
|
|
1725
2110
|
/** Start one account-scoped auth-service context. Every input is explicit: the state dir, the
|
|
@@ -1772,6 +2157,7 @@ export async function startAuthService(inputs) {
|
|
|
1772
2157
|
...(inputs.platformSupervisor !== undefined ? { platformSupervisor: inputs.platformSupervisor } : {}),
|
|
1773
2158
|
...(inputs.standingRenewableTtlSeconds !== undefined ? { standingRenewableTtlSeconds: inputs.standingRenewableTtlSeconds } : {}),
|
|
1774
2159
|
...(inputs.toolResultConsents !== undefined ? { toolResultConsents: inputs.toolResultConsents } : {}),
|
|
2160
|
+
...(inputs.serviceEndpoints !== undefined ? { serviceEndpoints: inputs.serviceEndpoints } : {}),
|
|
1775
2161
|
});
|
|
1776
2162
|
return { ...started.handle, readiness: () => ({ ...started.status(), context: inputs.context }) };
|
|
1777
2163
|
}
|
|
@@ -1811,7 +2197,10 @@ async function startAuthContext(o) {
|
|
|
1811
2197
|
dataAccount: { pub: keys.dataAccount.pub, signingSeed: keys.dataAccount.signingSeed },
|
|
1812
2198
|
log: (l) => console.error(l),
|
|
1813
2199
|
localManager: o.localManager,
|
|
2200
|
+
...(o.interactiveManagerSelection !== undefined ? { interactiveManagerSelection: o.interactiveManagerSelection } : {}),
|
|
2201
|
+
...(o.runCancelLimits !== undefined ? { runCancelLimits: o.runCancelLimits } : {}),
|
|
1814
2202
|
...(o.standingRenewableTtlSeconds !== undefined ? { standingRenewableTtlSeconds: o.standingRenewableTtlSeconds } : {}),
|
|
2203
|
+
...(o.serviceEndpoints !== undefined ? { serviceEndpoints: o.serviceEndpoints } : {}),
|
|
1815
2204
|
}, onConnection);
|
|
1816
2205
|
let nc;
|
|
1817
2206
|
let http;
|
|
@@ -1836,7 +2225,9 @@ async function startAuthContext(o) {
|
|
|
1836
2225
|
name: `cotal:auth-service:${space}`,
|
|
1837
2226
|
});
|
|
1838
2227
|
onConnection(nc, `cotal:auth-service:${space}`);
|
|
2228
|
+
const serviceCallout = plane.serviceEndpointHost !== undefined ? serviceHostCalloutOf(plane.serviceEndpointHost) : undefined;
|
|
1839
2229
|
startAuthCallout(nc, {
|
|
2230
|
+
...(serviceCallout !== undefined ? { serviceAuthority: serviceCallout } : {}),
|
|
1840
2231
|
xkeySeed: callout.xkey.seed,
|
|
1841
2232
|
authAccount: { pub: callout.account.pub, signingSeed: callout.account.signingSeed },
|
|
1842
2233
|
dataAccount: { pub: keys.dataAccount.pub, signingSeed: keys.dataAccount.signingSeed },
|
|
@@ -1844,7 +2235,9 @@ async function startAuthContext(o) {
|
|
|
1844
2235
|
token: { key: issuer.localKeySet(), issuer: issuer.issuer },
|
|
1845
2236
|
authorizeActor: plane.authorizeConnect,
|
|
1846
2237
|
recordIssuance: plane.recordIssuance,
|
|
1847
|
-
|
|
2238
|
+
// A chat principal resolves from its closed row (the agent leaf, no role); every other
|
|
2239
|
+
// principal from the grant ledger.
|
|
2240
|
+
permissionsFor: calloutPermissions(chatAwareAclResolver(dir, ledgerAclResolver(dir)), plane.authorizeManagerCaller, plane.verifySession, plane.issueUserCaller),
|
|
1848
2241
|
log: (l) => console.error(l),
|
|
1849
2242
|
});
|
|
1850
2243
|
// The subscription must be ON the broker before readiness is signaled — an `up` that recorded a
|
|
@@ -1859,6 +2252,7 @@ async function startAuthContext(o) {
|
|
|
1859
2252
|
issuer,
|
|
1860
2253
|
authorizeActor: ledgerAuthorizeGrant(dir),
|
|
1861
2254
|
mintConnectCredential: plane.mintConnectCredential,
|
|
2255
|
+
...(idp.dmReaderAudience !== undefined ? { dmReaderAudience: idp.dmReaderAudience } : {}),
|
|
1862
2256
|
});
|
|
1863
2257
|
const cap = randomBytes(32).toString("hex"); // per-start exchange capability (rotates with the daemon)
|
|
1864
2258
|
// Set once the public face binds. An enrolled child's bearer exchanges there without the
|
|
@@ -1870,8 +2264,10 @@ async function startAuthContext(o) {
|
|
|
1870
2264
|
space, accountPublicKey: keys.dataAccount.pub, dir, store: secrets, idp: bridgeIdp, ownerSecret,
|
|
1871
2265
|
signer: o.toolResultConsents, record: plane.recordToolResultConsent,
|
|
1872
2266
|
});
|
|
2267
|
+
const serviceEngine = plane.serviceEndpointHost !== undefined ? serviceHostEngineOf(plane.serviceEndpointHost) : undefined;
|
|
1873
2268
|
const ctx = {
|
|
1874
2269
|
...(issueToolResultConsent ? { issueToolResultConsent } : {}),
|
|
2270
|
+
...(serviceEngine !== undefined ? { serviceAuthority: { space, accountPublicKey: keys.dataAccount.pub, engine: serviceEngine, material: issuer } } : {}),
|
|
1875
2271
|
issuer,
|
|
1876
2272
|
bridge,
|
|
1877
2273
|
bridgeIdp,
|
|
@@ -1891,7 +2287,9 @@ async function startAuthContext(o) {
|
|
|
1891
2287
|
scanManagerGoalIndex: plane.scanManagerGoalIndex,
|
|
1892
2288
|
authorizeManagerAdmin: plane.authorizeManagerAdmin,
|
|
1893
2289
|
admitManagerRun: plane.admitManagerRun,
|
|
2290
|
+
submitManagerRun: plane.submitManagerRun,
|
|
1894
2291
|
revokeManagerRun: plane.revokeManagerRun,
|
|
2292
|
+
cancelManagerRun: plane.cancelManagerRun,
|
|
1895
2293
|
issueManagerRunAttempt: plane.issueManagerRunAttempt,
|
|
1896
2294
|
secrets,
|
|
1897
2295
|
retireInteractiveLifecycle: plane.retireInteractiveLifecycle,
|
|
@@ -1905,6 +2303,9 @@ async function startAuthContext(o) {
|
|
|
1905
2303
|
selectManagerInstance: plane.selectManagerInstance,
|
|
1906
2304
|
listManagerInstances: plane.listManagerInstances,
|
|
1907
2305
|
verifySession: plane.verifySession,
|
|
2306
|
+
openChatSession: plane.openChatSession,
|
|
2307
|
+
chatSessionBearer: plane.chatSessionBearer,
|
|
2308
|
+
retireChatSession: plane.retireChatSession,
|
|
1908
2309
|
};
|
|
1909
2310
|
const loopback = createServer((req, res) => void handle(req, res, ctx));
|
|
1910
2311
|
http = loopback;
|
|
@@ -2073,6 +2474,7 @@ async function startAuthContext(o) {
|
|
|
2073
2474
|
cap,
|
|
2074
2475
|
closed: owned.closed,
|
|
2075
2476
|
connections: () => owned.connections(),
|
|
2477
|
+
...(plane.serviceEndpointHost !== undefined ? { serviceEndpointHost: hostedServiceEndpoints(plane.serviceEndpointHost, refuseUnlessReady) } : {}),
|
|
2076
2478
|
...(o.platformSupervisor !== undefined ? {
|
|
2077
2479
|
platformSupervisorAuthority: async (owner) => {
|
|
2078
2480
|
refuseUnlessReady();
|
|
@@ -2103,6 +2505,7 @@ async function startAuthContext(o) {
|
|
|
2103
2505
|
case "manager-goal-index-scan": return plane.scanManagerGoalIndex({ ...holder, request });
|
|
2104
2506
|
case "manager-admin-authorization": return plane.authorizeManagerAdmin({ ...holder, request });
|
|
2105
2507
|
case "manager-run-admission": return plane.admitManagerRun({ ...holder, request });
|
|
2508
|
+
case "manager-run-submission": return plane.submitManagerRun({ ...holder, request });
|
|
2106
2509
|
case "manager-run-attempt": return plane.issueManagerRunAttempt({ ...holder, request });
|
|
2107
2510
|
case "manager-retained-agent-validation": return validateRetainedForHolder(holder, request);
|
|
2108
2511
|
}
|
|
@@ -2199,6 +2602,16 @@ export async function dispatchManagerAuthorityRequest(ctx, owner, body) {
|
|
|
2199
2602
|
return ctx.authorizeManagerAdmin({ owner, scope: row.scope ?? [], request: body.request });
|
|
2200
2603
|
if (request.kind === "manager-run-admission")
|
|
2201
2604
|
return ctx.admitManagerRun({ owner, scope: row.scope ?? [], request: body.request });
|
|
2605
|
+
if (request.kind === "manager-run-submission") {
|
|
2606
|
+
if (!ctx.submitManagerRun)
|
|
2607
|
+
throw new EpEnvelopeError("unimplemented", "issuing host has no keyed submission handler");
|
|
2608
|
+
return ctx.submitManagerRun({ owner, scope: row.scope ?? [], request: body.request });
|
|
2609
|
+
}
|
|
2610
|
+
if (request.kind === "manager-run-cancel") {
|
|
2611
|
+
if (!ctx.cancelManagerRun)
|
|
2612
|
+
throw new EpEnvelopeError("unimplemented", "this issuing host has no run-cancel handler");
|
|
2613
|
+
return ctx.cancelManagerRun({ owner, scope: row.scope ?? [], request: body.request });
|
|
2614
|
+
}
|
|
2202
2615
|
if (request.kind === "manager-run-revoke") {
|
|
2203
2616
|
if (ctx.revokeManagerRun === undefined)
|
|
2204
2617
|
throw new EpEnvelopeError("unimplemented", "this issuing host has no run revoke handler");
|
|
@@ -2283,6 +2696,13 @@ class RequestBodyError extends Error {
|
|
|
2283
2696
|
this.status = status;
|
|
2284
2697
|
}
|
|
2285
2698
|
}
|
|
2699
|
+
/** `e`, raised before any write, as a refusal that states so (SPEC 13.3). An envelope error keeps
|
|
2700
|
+
* its code; any other error takes `code`. */
|
|
2701
|
+
function notExecuted(e, code) {
|
|
2702
|
+
return e instanceof EpEnvelopeError
|
|
2703
|
+
? new EpEnvelopeError(e.code, e.message, e.details, "not-executed")
|
|
2704
|
+
: new EpEnvelopeError(code, e instanceof Error ? e.message : String(e), undefined, "not-executed");
|
|
2705
|
+
}
|
|
2286
2706
|
function sendRequestError(res, e) {
|
|
2287
2707
|
if (e instanceof RequestBodyError)
|
|
2288
2708
|
return send(res, e.status, { error: e.message });
|
|
@@ -2306,18 +2726,22 @@ const ROUTES = new Map([
|
|
|
2306
2726
|
["/exchange", (req, res, ctx) => handleExchange(req, res, ctx, LOOPBACK_POLICY)],
|
|
2307
2727
|
["/manager-service-authority", (req, res, ctx) => handleManagerServiceAuthority(req, res, ctx, LOOPBACK_POLICY)],
|
|
2308
2728
|
["/manager-instances", (req, res, ctx) => handleManagerInstances(req, res, ctx, LOOPBACK_POLICY)],
|
|
2729
|
+
[SERVICE_ENDPOINT_AUTHORITY_PATH, (req, res, ctx) => handleServiceEndpointAuthority(req, res, ctx, LOOPBACK_POLICY)],
|
|
2309
2730
|
[INTERACTIVE_RETIRE_PATH, handleInteractiveLifecycleRetirement],
|
|
2310
2731
|
[MANAGED_RETIRE_PATH, handleManagedLifecycleRetirement],
|
|
2311
2732
|
[VERIFY_ENROLLMENT_PATH, handleVerifyManagedAgentEnrollment],
|
|
2312
2733
|
[TOOL_RESULT_HUMAN_PROOF_PATH, handleToolResultHumanProof],
|
|
2313
2734
|
[TOOL_RESULT_CONSENT_PATH, handleToolResultConsent],
|
|
2735
|
+
[CHAT_SESSION_PATHS.open, (req, res, ctx) => handleChatSession(req, res, ctx, LOOPBACK_POLICY, "open")],
|
|
2736
|
+
[CHAT_SESSION_PATHS.bearer, (req, res, ctx) => handleChatSession(req, res, ctx, LOOPBACK_POLICY, "bearer")],
|
|
2737
|
+
[CHAT_SESSION_PATHS.retire, (req, res, ctx) => handleChatSession(req, res, ctx, LOOPBACK_POLICY, "retire")],
|
|
2314
2738
|
]);
|
|
2315
2739
|
/** Route one HTTP request against the loopback route table. Errors are JSON `{ error }`. */
|
|
2316
2740
|
async function handle(req, res, ctx) {
|
|
2317
2741
|
try {
|
|
2318
2742
|
const route = ROUTES.get(req.url ?? "");
|
|
2319
2743
|
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" });
|
|
2744
|
+
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
2745
|
await route(req, res, ctx);
|
|
2322
2746
|
}
|
|
2323
2747
|
catch (e) {
|
|
@@ -2325,34 +2749,50 @@ async function handle(req, res, ctx) {
|
|
|
2325
2749
|
}
|
|
2326
2750
|
}
|
|
2327
2751
|
const INTERACTIVE_RETIRE_KEYS = new Set(["owner", "actor", "lifecycleUid"]);
|
|
2752
|
+
/** The interactive retirement door. Every refusal before the barrier starts retired nothing and
|
|
2753
|
+
* answers `outcome: "not-executed"` (SPEC 13.3), with the plane's native `code` when the plane
|
|
2754
|
+
* raised it. A failure inside the barrier answers 400 with no outcome, which reads as unknown. */
|
|
2328
2755
|
async function handleInteractiveLifecycleRetirement(req, res, ctx) {
|
|
2756
|
+
const before = { outcome: "not-executed" };
|
|
2329
2757
|
if (req.method !== "POST")
|
|
2330
|
-
return send(res, 405, { error: "POST only" });
|
|
2758
|
+
return send(res, 405, { error: "POST only", ...before });
|
|
2331
2759
|
if (req.headers.origin !== undefined)
|
|
2332
|
-
return send(res, 403, { error: "browser-origin requests are not served here" });
|
|
2760
|
+
return send(res, 403, { error: "browser-origin requests are not served here", ...before });
|
|
2333
2761
|
if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
|
|
2334
|
-
return send(res, 415, { error: "content-type must be application/json" });
|
|
2762
|
+
return send(res, 415, { error: "content-type must be application/json", ...before });
|
|
2335
2763
|
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" });
|
|
2764
|
+
return send(res, 401, { error: "missing/invalid exchange capability - interactive lifecycle retirement is a loopback operator action", ...before });
|
|
2337
2765
|
const body = await readJsonBody(req);
|
|
2338
2766
|
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
2339
|
-
return send(res, 400, { error: "interactive lifecycle retirement needs { owner, actor, lifecycleUid }" });
|
|
2767
|
+
return send(res, 400, { error: "interactive lifecycle retirement needs { owner, actor, lifecycleUid }", ...before });
|
|
2340
2768
|
for (const key of Object.keys(body))
|
|
2341
2769
|
if (!INTERACTIVE_RETIRE_KEYS.has(key))
|
|
2342
|
-
return send(res, 400, { error: `interactive lifecycle retirement carries the unknown field "${key}"
|
|
2770
|
+
return send(res, 400, { error: `interactive lifecycle retirement carries the unknown field "${key}"`, ...before });
|
|
2343
2771
|
const raw = body;
|
|
2344
2772
|
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
|
-
|
|
2773
|
+
return send(res, 400, { error: "interactive lifecycle retirement needs string { owner, actor, lifecycleUid }", ...before });
|
|
2774
|
+
let owner, actor, lifecycleUid;
|
|
2775
|
+
try {
|
|
2776
|
+
owner = assertDerivedOwnerToken(raw.owner);
|
|
2777
|
+
actor = assertValidOwnerToken(raw.actor);
|
|
2778
|
+
lifecycleUid = assertLifecycleToken(raw.lifecycleUid);
|
|
2779
|
+
}
|
|
2780
|
+
catch (e) {
|
|
2781
|
+
return send(res, 400, { error: e instanceof Error ? e.message : String(e), ...before });
|
|
2782
|
+
}
|
|
2349
2783
|
const row = findInteractiveActor(ctx.dir, owner, actor);
|
|
2350
2784
|
if (!row)
|
|
2351
|
-
return send(res, 404, { error: `interactive actor "${owner}/${actor}" is not granted
|
|
2785
|
+
return send(res, 404, { error: `interactive actor "${owner}/${actor}" is not granted`, ...before });
|
|
2352
2786
|
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
|
-
|
|
2787
|
+
return send(res, 409, { error: `interactive actor "${owner}/${actor}" is current at lifecycle ${row.lifecycleUid ?? "<missing>"}, not ${lifecycleUid}`, ...before });
|
|
2788
|
+
try {
|
|
2789
|
+
return send(res, 200, await ctx.retireInteractiveLifecycle({ owner, actor, lifecycleUid }));
|
|
2790
|
+
}
|
|
2791
|
+
catch (e) {
|
|
2792
|
+
if (e instanceof EpEnvelopeError && e.outcome === "not-executed")
|
|
2793
|
+
return send(res, REFUSAL_STATUS[e.code] ?? 500, { error: e.message, code: e.code, outcome: e.outcome });
|
|
2794
|
+
throw e;
|
|
2795
|
+
}
|
|
2356
2796
|
}
|
|
2357
2797
|
/** The host's terminal step for a managed lifecycle whose remote manager is gone: the same request
|
|
2358
2798
|
* guards as the interactive door, but it requires the managed grant to be REVOKED first (the
|
|
@@ -2487,15 +2927,15 @@ async function handleVerifyManagedAgentEnrollment(req, res, ctx) {
|
|
|
2487
2927
|
}
|
|
2488
2928
|
/** Refuse a missing/invalid loopback capability, audited and counted in its own window (see
|
|
2489
2929
|
* BAD_CAP_PER_MIN): 401 with `reason` until the window is full, then 429. */
|
|
2490
|
-
function refuseCapability(res, ctx, reason) {
|
|
2930
|
+
function refuseCapability(res, ctx, reason, extra = {}) {
|
|
2491
2931
|
const now = Date.now();
|
|
2492
2932
|
while (ctx.badCaps.length && now - ctx.badCaps[0] > 60_000)
|
|
2493
2933
|
ctx.badCaps.shift();
|
|
2494
2934
|
ctx.badCaps.push(now);
|
|
2495
2935
|
console.error("auth-service: rejected an exchange with a missing/invalid capability");
|
|
2496
2936
|
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 });
|
|
2937
|
+
return send(res, 429, { error: "too many invalid-capability attempts - wait a minute and retry", ...extra });
|
|
2938
|
+
send(res, 401, { error: reason, ...extra });
|
|
2499
2939
|
}
|
|
2500
2940
|
/** The exchange body, shared by every face; `policy` says how this face proves and attributes the
|
|
2501
2941
|
* caller. Behavior on the loopback face is unchanged from the pre-route-table handler. */
|
|
@@ -2646,6 +3086,93 @@ async function handleExchange(req, res, ctx, policy) {
|
|
|
2646
3086
|
return send(res, 401, { error: reason });
|
|
2647
3087
|
}
|
|
2648
3088
|
}
|
|
3089
|
+
/** Each chat-session route's CLOSED body: an unknown or missing field refuses before any read. */
|
|
3090
|
+
const CHAT_SESSION_FIELDS = {
|
|
3091
|
+
open: { required: ["idpToken", "holderHash", "read", "post", "ttlSec"], optional: ["label"] },
|
|
3092
|
+
bearer: { required: ["idpToken", "actor", "holderProof"], optional: [] },
|
|
3093
|
+
retire: { required: ["idpToken", "actor", "lifecycleUid", "holderProof"], optional: [] },
|
|
3094
|
+
};
|
|
3095
|
+
/** HTTP status for a door refusal's native code; the body carries the code itself. */
|
|
3096
|
+
const REFUSAL_STATUS = {
|
|
3097
|
+
"bad-request": 400, unauthenticated: 401, "permission-denied": 403, "not-found": 404, conflict: 409,
|
|
3098
|
+
"failed-precondition": 412, "resource-exhausted": 429, unimplemented: 501, unavailable: 503,
|
|
3099
|
+
};
|
|
3100
|
+
/**
|
|
3101
|
+
* The chat-session routes, on both faces. The owner is derived from a fresh IdP proof exactly as
|
|
3102
|
+
* `/exchange` derives it; the body never names one. `open` admits, `bearer` exchanges the holder
|
|
3103
|
+
* proof for one short bearer, `retire` retires. Every refusal answers `{ error, code, outcome }`:
|
|
3104
|
+
* `not-executed` when nothing was written, `executed` when this request wrote before refusing, and
|
|
3105
|
+
* `unknown` when a write may have landed (a retry with the SAME holder resolves it). The holder proof
|
|
3106
|
+
* and the bearer never reach a log line.
|
|
3107
|
+
*/
|
|
3108
|
+
async function handleChatSession(req, res, ctx, policy, op) {
|
|
3109
|
+
// Every answer before the dispatch is refused with nothing read or written.
|
|
3110
|
+
const before = { outcome: "not-executed" };
|
|
3111
|
+
if (req.method !== "POST")
|
|
3112
|
+
return send(res, 405, { error: "POST only", code: "bad-request", ...before });
|
|
3113
|
+
if (req.headers.origin !== undefined)
|
|
3114
|
+
return send(res, 403, { error: "browser-origin requests are not served here", code: "permission-denied", ...before });
|
|
3115
|
+
if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
|
|
3116
|
+
return send(res, 415, { error: "content-type must be application/json", code: "bad-request", ...before });
|
|
3117
|
+
if (policy.requireCapability && (req.headers.authorization ?? "") !== `Bearer ${ctx.cap}`)
|
|
3118
|
+
return refuseCapability(res, ctx, "missing/invalid exchange capability - read it from the space's auth-service.json", { code: "unauthenticated", ...before });
|
|
3119
|
+
const peer = policy.peerKey(req);
|
|
3120
|
+
const peerThrottled = policy.throttled(ctx, peer);
|
|
3121
|
+
const body = await readJsonBody(req);
|
|
3122
|
+
const malformed = (why) => send(res, 400, { error: `chat-session ${op}: ${why}`, code: "bad-request", ...before });
|
|
3123
|
+
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
3124
|
+
return malformed("the body must be a JSON object");
|
|
3125
|
+
const b = body;
|
|
3126
|
+
const fields = CHAT_SESSION_FIELDS[op];
|
|
3127
|
+
const extra = Object.keys(b).find((k) => !fields.required.includes(k) && !fields.optional.includes(k));
|
|
3128
|
+
if (extra !== undefined)
|
|
3129
|
+
return malformed(`unknown field "${extra}"`);
|
|
3130
|
+
const missing = fields.required.find((k) => !Object.hasOwn(b, k));
|
|
3131
|
+
if (missing !== undefined)
|
|
3132
|
+
return malformed(`missing field "${missing}"`);
|
|
3133
|
+
if (typeof b.idpToken !== "string" || b.idpToken === "")
|
|
3134
|
+
return malformed("idpToken must be a non-empty string");
|
|
3135
|
+
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")))
|
|
3136
|
+
return malformed("open takes { idpToken, holderHash: string, read: string[], post: string[], ttlSec: number, label?: string }");
|
|
3137
|
+
if (op !== "open" && (typeof b.actor !== "string" || typeof b.holderProof !== "string" || (op === "retire" && typeof b.lifecycleUid !== "string")))
|
|
3138
|
+
return malformed(op === "bearer" ? "bearer takes { idpToken, actor: string, holderProof: string }" : "retire takes { idpToken, actor: string, lifecycleUid: string, holderProof: string }");
|
|
3139
|
+
// A refusal keeps the effect outcome its source stated; one that states none is unknown.
|
|
3140
|
+
const refuse = (status, message, code, outcome = "unknown") => {
|
|
3141
|
+
policy.recordFailure(ctx, peer);
|
|
3142
|
+
console.error(`auth-service: refused a chat-session ${op}: ${message}`);
|
|
3143
|
+
if (peerThrottled)
|
|
3144
|
+
return send(res, 429, { error: "too many refused exchanges - wait a minute and retry", code: "resource-exhausted", outcome });
|
|
3145
|
+
return send(res, status, { error: message, code, outcome });
|
|
3146
|
+
};
|
|
3147
|
+
let owner;
|
|
3148
|
+
let idpExp;
|
|
3149
|
+
try {
|
|
3150
|
+
const verified = await verifyIdpToken(b.idpToken, ctx.bridgeIdp);
|
|
3151
|
+
owner = deriveOwnerForIdpSubject(ctx.ownerSecret, ctx.bridgeIdp.issuer, verified.sub);
|
|
3152
|
+
idpExp = verified.exp;
|
|
3153
|
+
}
|
|
3154
|
+
catch (e) {
|
|
3155
|
+
return refuse(401, e instanceof Error ? e.message : String(e), "unauthenticated", "not-executed");
|
|
3156
|
+
}
|
|
3157
|
+
try {
|
|
3158
|
+
if (op === "open")
|
|
3159
|
+
return send(res, 200, await ctx.openChatSession({
|
|
3160
|
+
owner, holderHash: b.holderHash, read: b.read, post: b.post, ttlSec: b.ttlSec,
|
|
3161
|
+
...(typeof b.label === "string" ? { label: b.label } : {}),
|
|
3162
|
+
}));
|
|
3163
|
+
if (op === "bearer") {
|
|
3164
|
+
const minted = await ctx.chatSessionBearer({ owner, actor: b.actor, holderProof: b.holderProof, idpExp, issuer: ctx.issuer });
|
|
3165
|
+
return send(res, 200, { token: minted.token, owner, exp: minted.exp });
|
|
3166
|
+
}
|
|
3167
|
+
return send(res, 200, await ctx.retireChatSession({ owner, actor: b.actor, lifecycleUid: b.lifecycleUid, holderProof: b.holderProof }));
|
|
3168
|
+
}
|
|
3169
|
+
catch (e) {
|
|
3170
|
+
if (e instanceof EpEnvelopeError)
|
|
3171
|
+
return refuse(REFUSAL_STATUS[e.code] ?? 500, e.message, e.code, e.outcome ?? "unknown");
|
|
3172
|
+
// Anything else came from a store, broker or file read partway through: its effect is unknown.
|
|
3173
|
+
return refuse(503, e instanceof Error ? e.message : String(e), "unavailable", "unknown");
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
2649
3176
|
/** The signed-in person's own Manager instances, read-only, on both faces. The proof is the IdP JWT
|
|
2650
3177
|
* the human exchange verifies, and the owner is derived from it the same way, so a caller lists only
|
|
2651
3178
|
* its own owner's gates. A browser-origin request is refused as on every exchange route; an
|
|
@@ -2685,6 +3212,80 @@ async function handleManagerInstances(req, res, ctx, policy) {
|
|
|
2685
3212
|
return send(res, 503, { error: `the manager instance list is unavailable: ${e instanceof Error ? e.message : String(e)}` });
|
|
2686
3213
|
}
|
|
2687
3214
|
}
|
|
3215
|
+
/** A service renewal or access proof, verified against this context's own signing keys: the
|
|
3216
|
+
* signed witness it binds. A request that carries a witness must name the proof's; a status query
|
|
3217
|
+
* carries none, so its proof's own witness is the expected one. Current native state is the
|
|
3218
|
+
* engine's check, never the proof's. */
|
|
3219
|
+
async function verifyServiceProof(proof, request, issuer, space) {
|
|
3220
|
+
const key = issuer.localKeySet();
|
|
3221
|
+
if (proof.kind === "service-renewal") {
|
|
3222
|
+
const expected = "witness" in request ? request.witness : parseServiceRegistrationWitness(decodeJwt(proof.token).witness);
|
|
3223
|
+
return (await validateServiceRenewalProof(proof.token, { issuer: issuer.issuer, key, witness: expected })).witness;
|
|
3224
|
+
}
|
|
3225
|
+
const token = await validateUserToken(proof.token, { issuer: issuer.issuer, audience: space, key });
|
|
3226
|
+
if (token.act.view !== "service-endpoint" || token.act.service?.duty !== "serve")
|
|
3227
|
+
throw new Error("the access proof is not a service bearer");
|
|
3228
|
+
const witness = parseServiceRegistrationWitness(token.act.service.witness);
|
|
3229
|
+
if (token.owner !== witness.principal.owner || token.act.owner !== witness.principal.owner || token.act.actor !== witness.principal.actor
|
|
3230
|
+
|| token.act.lifecycleUid !== witness.principal.lifecycleUid || token.credentialId !== witness.rootCredentialId)
|
|
3231
|
+
throw new Error("the access proof does not bind its witness");
|
|
3232
|
+
if ("witness" in request && serviceWitnessDigest(request.witness) !== serviceWitnessDigest(witness))
|
|
3233
|
+
throw new Error("the access proof binds another witness");
|
|
3234
|
+
return witness;
|
|
3235
|
+
}
|
|
3236
|
+
/** The public service endpoint authority route, on both faces and only with `serviceEndpoints`: GET
|
|
3237
|
+
* reads the account info, POST takes one `{request, proof}` envelope. The proof is the credential on
|
|
3238
|
+
* either face; the loopback capability is never read or accepted here. An IdP proof verifies against
|
|
3239
|
+
* the pinned issuer and exact audience, and its subject derives the owner. A service renewal or
|
|
3240
|
+
* access proof verifies against this context's own issuer and binds a witness, never an owner's
|
|
3241
|
+
* authority. A refused proof counts against the face's refusal budget; a valid proof does not,
|
|
3242
|
+
* whatever the answer. */
|
|
3243
|
+
async function handleServiceEndpointAuthority(req, res, ctx, policy) {
|
|
3244
|
+
const door = ctx.serviceAuthority;
|
|
3245
|
+
if (door === undefined)
|
|
3246
|
+
return send(res, 404, { error: "service endpoint authority is not enabled for this space" });
|
|
3247
|
+
if (req.headers.origin !== undefined)
|
|
3248
|
+
return send(res, 403, { error: "browser-origin requests are not served here" });
|
|
3249
|
+
const noStore = { "cache-control": "no-store" };
|
|
3250
|
+
if (req.method === "GET")
|
|
3251
|
+
return send(res, 200, serviceAuthorityInfo(door), noStore);
|
|
3252
|
+
if (req.method !== "POST")
|
|
3253
|
+
return send(res, 405, { error: "GET or POST only" });
|
|
3254
|
+
if (!/^application\/json\b/.test(req.headers["content-type"] ?? ""))
|
|
3255
|
+
return send(res, 415, { error: "content-type must be application/json" });
|
|
3256
|
+
const peer = policy.peerKey(req);
|
|
3257
|
+
const peerThrottled = policy.throttled(ctx, peer);
|
|
3258
|
+
let envelope;
|
|
3259
|
+
try {
|
|
3260
|
+
envelope = parseServiceAuthorityEnvelope(await readJsonBody(req, SERVICE_AUTHORITY_MAX_BYTES));
|
|
3261
|
+
}
|
|
3262
|
+
catch (e) {
|
|
3263
|
+
return sendRequestError(res, e);
|
|
3264
|
+
}
|
|
3265
|
+
const { request, proof } = envelope;
|
|
3266
|
+
const answer = (r) => send(res, r.status, r.body, noStore);
|
|
3267
|
+
if (request.space !== door.space || request.accountPublicKey !== door.accountPublicKey)
|
|
3268
|
+
return answer(serviceAuthorityDenied(request, "account-mismatch"));
|
|
3269
|
+
let caller;
|
|
3270
|
+
try {
|
|
3271
|
+
caller = proof.kind === "idp"
|
|
3272
|
+
? { kind: "idp", owner: deriveOwnerForIdpSubject(ctx.ownerSecret, ctx.bridgeIdp.issuer, (await verifyIdpToken(proof.token, ctx.bridgeIdp)).sub) }
|
|
3273
|
+
: { kind: "service", witness: await verifyServiceProof(proof, request, ctx.issuer, door.space) };
|
|
3274
|
+
}
|
|
3275
|
+
catch (e) {
|
|
3276
|
+
policy.recordFailure(ctx, peer);
|
|
3277
|
+
console.error(`auth-service: refused a service endpoint authority proof: ${e instanceof Error ? e.message : String(e)}`);
|
|
3278
|
+
return answer(serviceAuthorityDenied(request, peerThrottled ? "rate-limited" : "unauthenticated"));
|
|
3279
|
+
}
|
|
3280
|
+
try {
|
|
3281
|
+
return answer(await answerServiceAuthority(door, request, caller));
|
|
3282
|
+
}
|
|
3283
|
+
catch (e) {
|
|
3284
|
+
// After the proof, a register may already be bound: an unexpected failure is an unknown effect.
|
|
3285
|
+
console.error(`auth-service: service endpoint authority ${request.operation} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3286
|
+
return send(res, 503, { error: "service endpoint authority did not complete; the request may be bound, so retain its request identity" }, noStore);
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
2688
3289
|
/** The typed manager authority exchange, served on both faces. The face's policy decides whether the
|
|
2689
3290
|
* loopback capability is demanded; on the public face the verified IdP JWT is the proof, and the
|
|
2690
3291
|
* dispatcher reads the caller's scope from the ledger either way. */
|
|
@@ -2891,6 +3492,10 @@ function makePublicHandler(ctx, policy, bundle) {
|
|
|
2891
3492
|
["/exchange", (req, res, c) => handleExchange(req, res, c, policy)],
|
|
2892
3493
|
["/manager-service-authority", (req, res, c) => handleManagerServiceAuthority(req, res, c, policy)],
|
|
2893
3494
|
["/manager-instances", (req, res, c) => handleManagerInstances(req, res, c, policy)],
|
|
3495
|
+
[SERVICE_ENDPOINT_AUTHORITY_PATH, (req, res, c) => handleServiceEndpointAuthority(req, res, c, policy)],
|
|
3496
|
+
[CHAT_SESSION_PATHS.open, (req, res, c) => handleChatSession(req, res, c, policy, "open")],
|
|
3497
|
+
[CHAT_SESSION_PATHS.bearer, (req, res, c) => handleChatSession(req, res, c, policy, "bearer")],
|
|
3498
|
+
[CHAT_SESSION_PATHS.retire, (req, res, c) => handleChatSession(req, res, c, policy, "retire")],
|
|
2894
3499
|
[
|
|
2895
3500
|
"/.well-known/cotal-mesh",
|
|
2896
3501
|
(req, res) => {
|
|
@@ -2923,7 +3528,7 @@ function makePublicHandler(ctx, policy, bundle) {
|
|
|
2923
3528
|
try {
|
|
2924
3529
|
const route = routes.get(req.url ?? "");
|
|
2925
3530
|
if (!route)
|
|
2926
|
-
return send(res, 404, { error: "unknown path - /health, /jwks, /exchange, /manager-service-authority, /manager-instances, /.well-known/cotal-mesh" });
|
|
3531
|
+
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
3532
|
await route(req, res, ctx);
|
|
2928
3533
|
}
|
|
2929
3534
|
catch (e) {
|
|
@@ -2934,16 +3539,16 @@ function makePublicHandler(ctx, policy, bundle) {
|
|
|
2934
3539
|
};
|
|
2935
3540
|
}
|
|
2936
3541
|
/** Read + parse a small JSON body, bounded — the exchange payload is an IdP JWT plus an actor name;
|
|
2937
|
-
* 64 KB clears any sane JWT while keeping the loopback surface un-floodable.
|
|
2938
|
-
|
|
2939
|
-
|
|
3542
|
+
* 64 KB clears any sane JWT while keeping the loopback surface un-floodable. A route with a larger
|
|
3543
|
+
* protocol envelope passes its own bound. */
|
|
3544
|
+
function readJsonBody(req, max = 64 * 1024) {
|
|
2940
3545
|
return new Promise((resolve, reject) => {
|
|
2941
3546
|
let size = 0;
|
|
2942
3547
|
let tooLarge = false;
|
|
2943
3548
|
const chunks = [];
|
|
2944
3549
|
req.on("data", (c) => {
|
|
2945
3550
|
size += c.length;
|
|
2946
|
-
if (size >
|
|
3551
|
+
if (size > max) {
|
|
2947
3552
|
// Stop retaining bytes immediately, but keep draining the request so the client receives
|
|
2948
3553
|
// the explicit 413 JSON response. Destroying the socket hid the server's decision behind
|
|
2949
3554
|
// an undici transport error and made the size-bound smoke unable to prove this guard.
|
|
@@ -2956,7 +3561,7 @@ function readJsonBody(req) {
|
|
|
2956
3561
|
});
|
|
2957
3562
|
req.on("end", () => {
|
|
2958
3563
|
if (tooLarge)
|
|
2959
|
-
return reject(new RequestBodyError(413,
|
|
3564
|
+
return reject(new RequestBodyError(413, `request body too large (maximum ${max} bytes)`));
|
|
2960
3565
|
try {
|
|
2961
3566
|
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
2962
3567
|
}
|