@zackbart/connecta 0.24.1 → 0.24.3

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/dist/auth/bearer.js +2 -0
  3. package/dist/auth/clerk.d.ts +0 -5
  4. package/dist/auth/clerk.js +21 -8
  5. package/dist/auth/downstream-oauth.d.ts +12 -1
  6. package/dist/auth/downstream-oauth.js +147 -35
  7. package/dist/call-admission.d.ts +4 -0
  8. package/dist/call-admission.js +26 -0
  9. package/dist/catalog-drift.js +9 -4
  10. package/dist/catalog-service.d.ts +2 -0
  11. package/dist/catalog-service.js +25 -8
  12. package/dist/catalog.d.ts +2 -0
  13. package/dist/catalog.js +246 -121
  14. package/dist/connector-access.d.ts +32 -0
  15. package/dist/connector-access.js +79 -0
  16. package/dist/connectors/api.js +11 -1
  17. package/dist/connectors/guarded-fetch.d.ts +1 -1
  18. package/dist/connectors/guarded-fetch.js +27 -20
  19. package/dist/connectors/remote-mcp.js +84 -53
  20. package/dist/errors.d.ts +17 -0
  21. package/dist/errors.js +58 -0
  22. package/dist/execute.js +85 -23
  23. package/dist/executor-result.js +3 -1
  24. package/dist/executors/quickjs-child.js +5 -1
  25. package/dist/executors/quickjs-protocol.d.ts +4 -0
  26. package/dist/executors/quickjs-runtime.d.ts +1 -1
  27. package/dist/executors/quickjs-runtime.js +38 -21
  28. package/dist/executors/quickjs.js +68 -27
  29. package/dist/index.d.ts +37 -1
  30. package/dist/index.js +89 -3
  31. package/dist/invocation.js +134 -93
  32. package/dist/mcp-result.js +3 -2
  33. package/dist/meta-tools.js +118 -39
  34. package/dist/registry.d.ts +29 -1
  35. package/dist/registry.js +122 -15
  36. package/dist/routes/credentials.js +1 -0
  37. package/dist/routes/mcp.d.ts +4 -1
  38. package/dist/routes/mcp.js +112 -12
  39. package/dist/routes/oauth-management.js +1 -0
  40. package/dist/routes/oauth.js +4 -0
  41. package/dist/routes/shared.d.ts +7 -1
  42. package/dist/routes/shared.js +12 -13
  43. package/dist/routes/ui.js +2 -1
  44. package/dist/server.js +15 -3
  45. package/dist/skills.js +6 -5
  46. package/dist/storage/file.d.ts +6 -2
  47. package/dist/storage/file.js +312 -34
  48. package/dist/storage/memory.js +12 -1
  49. package/dist/validate.js +3 -3
  50. package/dist/version.d.ts +1 -1
  51. package/dist/version.js +1 -1
  52. package/documentation/architecture.md +30 -9
  53. package/documentation/auth.md +110 -6
  54. package/documentation/call-admission.md +24 -8
  55. package/documentation/code-mode.md +34 -22
  56. package/documentation/connectors.md +47 -5
  57. package/documentation/meta-tools.md +74 -6
  58. package/documentation/operations.md +20 -19
  59. package/documentation/provider-conventions.md +7 -0
  60. package/documentation/request-admission.md +38 -4
  61. package/documentation/storage-and-credentials.md +54 -1
  62. package/documentation/upgrading.md +21 -5
  63. package/ethos.md +1 -1
  64. package/package.json +1 -1
  65. package/templates/node/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,175 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
+ ## 0.24.3 — 2026-09-16
6
+
7
+ A bug-fix release from a full audit of the execution path, invocation, catalog
8
+ rendering, downstream OAuth, and the route table, plus the one MCP 2026-07-28
9
+ requirement the previous inventory had missed. Two changes can be felt by a
10
+ deployment: `/mcp` now validates the browser `Origin` header, so a browser MCP
11
+ client hosted on an origin other than `publicUrl` or loopback needs
12
+ `allowedOrigins`; and the overload and shutdown JSON-RPC error codes moved
13
+ from `-32001`/`-32002` to `-31001`/`-31002`. Everything else is a fix a
14
+ deployment can take without action. No storage format changed, but
15
+ `fileStorage` now refuses a second process on the same state file, which a
16
+ deployment sharing one file between two processes was never safe doing.
17
+
18
+ ### Added
19
+
20
+ - **`allowedOrigins`.** `/mcp` and every `/mcp/<pool>` path refuse a
21
+ disallowed `Origin` with a fixed 403 before redirects, admission, auth, or
22
+ preflight, as the Streamable HTTP transport requires against DNS rebinding.
23
+ The default admits the `publicUrl` origin and HTTP(S) loopback at any port;
24
+ a list replaces the default; `"*"` keeps unrestricted CORS. Clients that
25
+ send no `Origin` are unaffected. Allowed preflight now returns 204 without
26
+ admission or auth and echoes valid requested `mcp-param-*` header names.
27
+ - **Host-owned refresh recovery.** A valid token response is a consumed
28
+ refresh token. When the request that owned a refresh is cancelled,
29
+ redirected to authorization, or invalidated before the SDK saves the
30
+ response, the coordinator persists the rotation itself, holds contenders
31
+ behind the pending-mutation marker until that write lands, and hands them
32
+ the saved rotation. A retired refresh token is never redeemed twice, and two
33
+ deterministic waves of eight overlapping scopes pin one grant per wave
34
+ (#526).
35
+ - Downstream JSON-RPC `-32602` refusals map to `invalid_args`, and 4xx
36
+ responses with a JSON message keep that message, bounded, without inferring
37
+ retryability from prose. 429 stays `rate_limited` and 408 becomes `timeout`.
38
+ - `Retry-After` accepts the HTTP-date form as well as delta-seconds.
39
+ - Personal connectors admit calls through their principal registry's own
40
+ budget instead of sharing one root budget; shutdown closes both, and
41
+ eviction never discards a live budget.
42
+ - `records/mcp-2026-07-28.md` gains rows for Origin validation, deterministic
43
+ `tools/list` order, the bearer challenge, SEP-2243 parameter headers, and
44
+ trace propagation, and corrects the stale extensions row.
45
+ - **Bounded `get_result` stash.** `results.maxStashBytes` (default 8 MiB)
46
+ and `results.maxStashEntries` (default 64) bound the paging stash per
47
+ runtime, with accounting that reserves capacity before concurrent writes
48
+ finish and reclaims expired entries before reuse. A refused stash keeps the
49
+ call successful with its preview and the paging-unavailable notice.
50
+ - **Byte-range paging.** Stashed results are stored as a byte-addressable
51
+ envelope, so `get_result` decodes only the requested page instead of
52
+ re-encoding the whole result on every call. Entries stashed before the
53
+ upgrade still page until their TTL expires.
54
+ - **Identity-partitioned results.** The stash partition derives from any
55
+ authenticated subject or principal, no longer only from providers that
56
+ declare `activityActorNamespace`. Open deployments share one partition.
57
+ - **Sanitized `unavailable` detail.** Unreachable downstreams may carry
58
+ `details.host` (origin only) and `details.code` (a closed allowlist of
59
+ network errnos, or `timeout`) so an outage is distinguishable from a typo
60
+ without leaking a path, query, or credential. Activity stays payload-free.
61
+ - **`fileStorage` writer lock.** A second instance or process opening the
62
+ same state file fails at construction naming the holder. The lock is
63
+ heartbeat-based, so a container restart that reuses a pid cannot wedge a
64
+ deployment, and each write uses a unique temp file. The returned store now
65
+ has `close()`.
66
+ - **Logs survive every executor failure.** QuickJS streams console entries
67
+ to the parent, so a program that is cancelled, killed at the deadline,
68
+ crashed, or lost to an IPC failure still returns what it printed.
69
+
70
+ ### Changed
71
+
72
+ - `/health` no longer names connectors: drift reports are keyed by a truncated
73
+ SHA-256 of the connector id and downstream admission is summed without ids.
74
+ `connecta doctor` keeps its stale-allowlist signal.
75
+ - Overload and shutdown JSON-RPC codes are `-31001` and `-31002`, outside the
76
+ reserved range, per the specification's allocation policy.
77
+ - `connecta.search` and `connecta.describe` spend the same host-call budget as
78
+ `connecta.call`; only `connecta.emit` is exempt (L4, M7).
79
+ - One per-call deadline now covers catalog resolution, admission, and the
80
+ connector call, so a hung connector fails with a catchable `timeout` instead
81
+ of consuming the whole execution wall clock.
82
+ - Compact describe caps each shape at 8,192 UTF-8 bytes and sets
83
+ `inputSchemaTruncated` / `outputSchemaTruncated`; use `format: "json"` for
84
+ the exact schema.
85
+ - Top-level discovery measures the complete tool result, both copies and JSON
86
+ escaping, against the 256,000-byte ceiling.
87
+ - Downstream `isError` text is bounded at 512 UTF-8 bytes, and error framing
88
+ fits the call's result cap in both result modes.
89
+ - Open deployments warn whenever any connector is configured, since `api()`
90
+ headers can carry secrets without declaring credential hooks.
91
+
92
+ ### Fixed
93
+
94
+ - **A long downstream error could leak the sandbox's per-run secret to guest
95
+ code and let a program forge a typed failure.** The QuickJS bridge sliced a
96
+ rejected host call at 4,000 characters, which clipped the authenticated
97
+ failure frame mid-JSON. Details are bounded before framing, the bridge
98
+ refuses an oversized frame whole, the prelude hides a malformed frame, and
99
+ the raw transport lives in a private closure so guest code cannot swap
100
+ `Error` or reach the bridge.
101
+ - **A completed destructive call was reported as a retryable failure when the
102
+ `get_result` stash write failed.** The truncated preview is returned with a
103
+ paging-unavailable notice, activity records success, and
104
+ `result_processing_failed` is never retryable.
105
+ - **The OAuth refresh gate wedged permanently** when a token fetch succeeded
106
+ but the SDK's save never ran, answering 503 to every later refresh in the
107
+ isolate until a forced reauthorization.
108
+ - **Compact schema rendering went quartic on `allOf`-of-`$ref` schemas.** A
109
+ 3.5 KB downstream schema took over four seconds of synchronous CPU, and the
110
+ describe path produced a 1.9 MB string from 1.8 KB. Rendering now spends a
111
+ shared 2,000-visit budget with memoized `$ref` expansion.
112
+ - `call_tool` in MCP result mode returned `{"content":[]}` for a downstream
113
+ result carrying only `structuredContent`; a text block is now synthesized,
114
+ and a `null` structured value survives unwrapping.
115
+ - In-program `connecta.search` returned `offset: null` and an empty page for a
116
+ non-numeric offset, and threw a raw `TypeError` for a non-string query; both
117
+ are `invalid_args`.
118
+ - Caller-authored text in `get_result`, `authorize_connector`, `skills`, and
119
+ `search_tools` refusals is bounded; a `connector` over 512 bytes is
120
+ `invalid_args`. A failed stash read is a typed `unavailable`.
121
+ - Legacy `mcp-session-id` DELETE now runs on credential rotation, generation
122
+ change, disconnect, and abandoned connects, not only on scope close.
123
+ - Pool names outside `[a-z0-9_-]` fell through to a generic 404 without CORS
124
+ or authentication; every `/mcp/` suffix now reaches the identical pool 404
125
+ after auth.
126
+ - The absent-grant warning set was unbounded and keyed by caller-derivable
127
+ text; it is capped at 1,024 entries.
128
+ - `requiredInputKeys` could name keys the schema did not declare.
129
+ - Validation error detail embedded whole enums; drift digests recursed without
130
+ a depth bound; refresh token responses were read without a byte ceiling;
131
+ credential revision races could loop without bound.
132
+ - Containment matching for escaped failures ignores messages under eight
133
+ characters, and a QuickJS host-result reply that cannot be serialized settles
134
+ the call instead of hanging until the wall deadline.
135
+ - Every `connecta.call` attempt spends one host call on entry, so unknown
136
+ addresses cannot loop for free; the escaped-failure list keeps the most
137
+ recent 64; an empty terminal error string is a failure, not a success.
138
+ - The compact renderer renders `prefixItems` as a tuple, marks
139
+ `dependentSchemas` and `if`/`then`/`else` shapes conditional with the
140
+ truncation flag, and resolves `$dynamicRef` like `$ref`.
141
+ - The guarded transport's no-body-stream path enforces the byte ceiling on
142
+ `text()` and `json()`.
143
+ - The pool-name timing oracle is accepted and documented; `authorize_connector`
144
+ honoring tool-level grants is documented and pinned.
145
+
146
+ ## 0.24.2 — 2026-09-16
147
+
148
+ `connectorAccess` can now grant individual tools, and a deployment can declare
149
+ named pools served at `/mcp/<pool>`. Nothing changes for a deployment that
150
+ returns `"all"` or connector ids and declares no pools.
151
+
152
+ ### Added
153
+
154
+ - **Named tool pools at `/mcp/<pool>`.** `ConnectaConfig.pools` declares a
155
+ slice of connector ids and exact `connector.tool` addresses plus a `grant`
156
+ predicate over the authenticated identity, denied by default. The endpoint
157
+ serves the pool intersected with the identity's `connectorAccess`, so it can
158
+ only narrow. An undeclared name, a refusing grant, and a throwing grant are
159
+ one identical 404. Misdeclared pools refuse to boot. Clerk's 401 challenge
160
+ and protected-resource metadata follow the pool path so OAuth discovery
161
+ matches the URL the client used. Ethos records the decision.
162
+
163
+ - **Tool-level grants in `identity.connectorAccess`.** Entries may be a
164
+ connector id (every tool) or an exact `connector.tool` address (that tool
165
+ only); grants are additive. The scoped registry view filters below the
166
+ catalog service, so `search_tools`, `describe_tools`, `call_tool`,
167
+ `call_destructive_tool`, a program's `connecta.search` and `connecta.call`,
168
+ and the connection UI all see the same list, and an ungranted tool fails as
169
+ `unknown_tool` exactly like an absent one. There is no wildcard: a remote
170
+ catalog that drifts cannot widen a grant. An address the catalog lacks is
171
+ unreachable and warned once per isolate. An unparseable entry refuses the
172
+ request with 403 rather than failing open.
173
+
5
174
  ## 0.24.1 — 2026-09-08
6
175
 
7
176
  ### Added
@@ -39,6 +39,8 @@ export function bearerToken(secret, options = {}) {
39
39
  status: 401,
40
40
  headers: {
41
41
  "Content-Type": "application/json",
42
+ // A configured secret has no OAuth metadata or issuer to
43
+ // advertise; interactive adapters own resource discovery.
42
44
  "WWW-Authenticate": "Bearer",
43
45
  },
44
46
  }),
@@ -27,10 +27,5 @@ export interface ClerkAuthOptions {
27
27
  /** Optional hosted Account Portal sign-up URL for operator pages. Absolute https only. */
28
28
  signUpUrl?: string;
29
29
  }
30
- /**
31
- * Clerk inbound auth.
32
- *
33
- * `allowedDomains` and `gate` decide who is admitted; both must pass.
34
- */
35
30
  export declare function clerkAuth(opts: ClerkAuthOptions): InboundAuth;
36
31
  export {};
@@ -207,6 +207,11 @@ function emailDomain(email) {
207
207
  *
208
208
  * `allowedDomains` and `gate` decide who is admitted; both must pass.
209
209
  */
210
+ /** `"/<pool>"` for a pool endpoint path, null for `/mcp` and anything else. */
211
+ function mcpPoolSuffix(pathname) {
212
+ const match = /^\/mcp(\/[a-z0-9_-]+)$/.exec(pathname);
213
+ return match ? match[1] : null;
214
+ }
210
215
  export function clerkAuth(opts) {
211
216
  assertNoRetiredToolkitOptions("clerkAuth", opts);
212
217
  // Before the Clerk client, so a malformed key fails as a connecta
@@ -284,9 +289,13 @@ export function clerkAuth(opts) {
284
289
  pendingActivityLabels.set(userId, lookup);
285
290
  return lookup;
286
291
  };
287
- const unauthorized = (baseUrl, tokenPresent) => {
292
+ const unauthorized = (baseUrl, tokenPresent, request) => {
288
293
  const error = tokenPresent ? `error="invalid_token", ` : "";
289
- const meta = `${resolveBase(baseUrl)}/.well-known/oauth-protected-resource`;
294
+ // A pool endpoint is its own protected resource: the challenge names the
295
+ // metadata document whose `resource` matches the URL the client used, or
296
+ // RFC 9728 tells it to reject the mismatch.
297
+ const pool = mcpPoolSuffix(new URL(request.url).pathname);
298
+ const meta = `${resolveBase(baseUrl)}/.well-known/oauth-protected-resource${pool ? `/mcp${pool}` : ""}`;
290
299
  return new Response(JSON.stringify({ error: "unauthorized" }), {
291
300
  status: 401,
292
301
  headers: {
@@ -384,10 +393,14 @@ export function clerkAuth(opts) {
384
393
  return new Response(null, { status: 204, headers: CORS_HEADERS });
385
394
  }
386
395
  const base = resolveBase(baseUrl);
396
+ const pool = pathname.startsWith("/.well-known/oauth-protected-resource/mcp/")
397
+ ? mcpPoolSuffix(pathname.slice("/.well-known/oauth-protected-resource".length))
398
+ : null;
387
399
  if (pathname === "/.well-known/oauth-protected-resource" ||
388
- pathname === "/.well-known/oauth-protected-resource/mcp") {
400
+ pathname === "/.well-known/oauth-protected-resource/mcp" ||
401
+ pool) {
389
402
  return Response.json({
390
- resource: `${base}/mcp`,
403
+ resource: `${base}/mcp${pool ?? ""}`,
391
404
  authorization_servers: [frontendApiUrl],
392
405
  bearer_methods_supported: ["header"],
393
406
  scopes_supported: scopes,
@@ -430,7 +443,7 @@ export function clerkAuth(opts) {
430
443
  ` tokenShape=${tokenShape(request)}`);
431
444
  return {
432
445
  ok: false,
433
- response: unauthorized(baseUrl, tokenPresent),
446
+ response: unauthorized(baseUrl, tokenPresent, request),
434
447
  };
435
448
  }
436
449
  // Session JWTs carry `azp` (the origin they were minted for); pin it
@@ -444,7 +457,7 @@ export function clerkAuth(opts) {
444
457
  console.warn(`[connecta] session token azp mismatch: azp=${azp} expected=${origin}`);
445
458
  return {
446
459
  ok: false,
447
- response: unauthorized(baseUrl, tokenPresent),
460
+ response: unauthorized(baseUrl, tokenPresent, request),
448
461
  };
449
462
  }
450
463
  }
@@ -452,10 +465,10 @@ export function clerkAuth(opts) {
452
465
  }
453
466
  catch (error) {
454
467
  console.warn(`[connecta] clerk authenticateRequest threw: ${error instanceof Error ? error.message : String(error)} tokenShape=${tokenShape(request)}`);
455
- return { ok: false, response: unauthorized(baseUrl, true) };
468
+ return { ok: false, response: unauthorized(baseUrl, true, request) };
456
469
  }
457
470
  if (!userId) {
458
- return { ok: false, response: unauthorized(baseUrl, true) };
471
+ return { ok: false, response: unauthorized(baseUrl, true, request) };
459
472
  }
460
473
  if (!(await checkGate(userId))) {
461
474
  return { ok: false, response: forbidden() };
@@ -13,6 +13,16 @@ interface OAuthRefreshFlight {
13
13
  release: (outcome: OAuthRefreshFlightOutcome) => void;
14
14
  stopObservingOwnerAbort: () => void;
15
15
  mutationId: object;
16
+ writing: boolean;
17
+ /**
18
+ * Set once the token endpoint answered with valid tokens: the authorization
19
+ * server has consumed the rotating refresh token, so if the SDK's own
20
+ * saveTokens never arrives (owner cancelled, redirected, or invalidated),
21
+ * `fail` persists this copy itself rather than stranding the marker or
22
+ * letting a contender redeem the retired token.
23
+ */
24
+ acceptedTokens?: OAuthTokens;
25
+ persist?: (tokens: OAuthTokens) => Promise<void>;
16
26
  }
17
27
  /**
18
28
  * Share one rotating-token redemption within one connector runtime and OAuth
@@ -41,6 +51,8 @@ export declare class OAuthRefreshCoordinator {
41
51
  /** @internal Opaque basis for issuer-aware provider token reads. */
42
52
  successfulRefreshIdentity(generation: string): object | undefined;
43
53
  coordinatedFetch(provider: KvOAuthProvider, baseFetch: FetchLike, requestSignal?: AbortSignal): FetchLike;
54
+ /** Start storage only while this owner still holds its exact flight. */
55
+ beginMutation(generation: string, flight: OAuthRefreshFlight): boolean;
44
56
  /** Publish one exact owner's successful save without disturbing a newer try. */
45
57
  succeedMutation(generation: string, flight: OAuthRefreshFlight): void;
46
58
  /** Give joined callers a fetch/flow failure, without rejecting the gate. */
@@ -92,7 +104,6 @@ export declare class KvOAuthProvider implements OAuthClientProvider {
92
104
  flowGeneration(): Promise<string>;
93
105
  /** @internal Record the refresh attempt this provider owns. */
94
106
  captureRefreshFlight(generation: string, flight: OAuthRefreshFlight): void;
95
- private succeedRefreshFlight;
96
107
  private failRefreshFlight;
97
108
  /** True when another request saved a refresh result after this flow's read. */
98
109
  refreshBasisChanged(current: OAuthTokens, generation: string): boolean;
@@ -63,18 +63,66 @@ function sdkAcceptsOAuthTokens(value) {
63
63
  }
64
64
  return true;
65
65
  }
66
- async function refreshResponseFailure(response) {
66
+ const MAX_REFRESH_RESPONSE_BYTES = 65_536;
67
+ class OversizedRefreshResponse extends Error {
68
+ constructor() {
69
+ super(`OAuth refresh response exceeded ${MAX_REFRESH_RESPONSE_BYTES} bytes.`);
70
+ }
71
+ }
72
+ async function readRefreshResponse(response) {
73
+ if (Number(response.headers.get("content-length")) > MAX_REFRESH_RESPONSE_BYTES) {
74
+ void response.body?.cancel().catch(() => { });
75
+ throw new OversizedRefreshResponse();
76
+ }
77
+ const reader = response.clone().body?.getReader();
78
+ if (!reader)
79
+ return undefined;
80
+ const decoder = new TextDecoder();
81
+ let size = 0;
82
+ let text = "";
83
+ try {
84
+ while (true) {
85
+ const { done, value } = await reader.read();
86
+ if (done)
87
+ break;
88
+ size += value.byteLength;
89
+ if (size > MAX_REFRESH_RESPONSE_BYTES) {
90
+ // A cloned body's cancellation can await its sibling. Cancel both,
91
+ // without making the refusal wait for the provider to finish sending.
92
+ void reader.cancel().catch(() => { });
93
+ void response.body?.cancel().catch(() => { });
94
+ throw new OversizedRefreshResponse();
95
+ }
96
+ text += decoder.decode(value, { stream: true });
97
+ }
98
+ return JSON.parse(text + decoder.decode());
99
+ }
100
+ finally {
101
+ reader.releaseLock();
102
+ }
103
+ }
104
+ /**
105
+ * Classify the token endpoint's answer once, on a clone, and keep the parsed
106
+ * tokens: a valid response has already consumed the rotating refresh token, so
107
+ * the host persists it (`coordinatedFetch`) rather than trusting the SDK's
108
+ * later `saveTokens` to arrive on a request that may already be cancelled.
109
+ */
110
+ async function refreshResponseOutcome(response) {
67
111
  if (!response.ok) {
68
- return new Error(`OAuth refresh failed with HTTP ${response.status}.`);
112
+ return { failure: new Error(`OAuth refresh failed with HTTP ${response.status}.`) };
69
113
  }
114
+ let parsed;
70
115
  try {
71
- return sdkAcceptsOAuthTokens(await response.clone().json())
72
- ? undefined
73
- : new Error("OAuth refresh response did not match the token schema.");
116
+ parsed = await readRefreshResponse(response);
74
117
  }
75
- catch {
76
- return new Error("OAuth refresh response did not contain JSON tokens.");
118
+ catch (error) {
119
+ if (error instanceof OversizedRefreshResponse)
120
+ throw error;
121
+ return { failure: new Error("OAuth refresh response did not contain JSON tokens.") };
77
122
  }
123
+ return sdkAcceptsOAuthTokens(parsed)
124
+ ? { tokens: parsed }
125
+ : { failure: new Error("OAuth refresh response did not match the token schema.") };
78
126
  }
79
127
  function refreshMutationPendingResponse() {
80
128
  return Response.json({
@@ -189,7 +237,9 @@ export class OAuthRefreshCoordinator {
189
237
  const requestedRefreshToken = init?.body instanceof URLSearchParams
190
238
  ? init.body.get("refresh_token")
191
239
  : new URLSearchParams(String(init?.body ?? "")).get("refresh_token");
192
- while (true) {
240
+ for (let attempt = 0; attempt < 64; attempt++) {
241
+ if (requestSignal?.aborted)
242
+ throw aborted(requestSignal);
193
243
  const revisionBeforeReads = this.stateRevision;
194
244
  const activeGeneration = await provider.generation();
195
245
  this.observeAuthoritativeGeneration(activeGeneration);
@@ -258,6 +308,8 @@ export class OAuthRefreshCoordinator {
258
308
  release: (outcome) => release(outcome),
259
309
  stopObservingOwnerAbort: () => { },
260
310
  mutationId: {},
311
+ writing: false,
312
+ persist: (tokens) => provider.saveTokens(tokens),
261
313
  };
262
314
  this.flights.set(generation, flight);
263
315
  this.advanceStateRevision();
@@ -280,18 +332,35 @@ export class OAuthRefreshCoordinator {
280
332
  }
281
333
  try {
282
334
  const response = await baseFetch(input, requestSignal ? { ...init, signal: requestSignal } : init);
283
- // These responses never reach a successful saveTokens callback. Give
335
+ // Failed responses never reach a successful saveTokens callback. Give
284
336
  // current waiters a bounded failure now while leaving the owner's
285
337
  // response untouched for the SDK to parse and classify itself.
286
- const failure = await refreshResponseFailure(response);
287
- if (failure) {
288
- this.fail(generation, flight, failure);
338
+ const outcome = await refreshResponseOutcome(response);
339
+ if (outcome.failure) {
340
+ this.fail(generation, flight, outcome.failure);
341
+ return response;
289
342
  }
290
- else if (requestSignal?.aborted ||
291
- !this.markMutationPending(generation, flight)) {
292
- throw requestSignal?.aborted
293
- ? aborted(requestSignal)
294
- : new Error("OAuth refresh ended before tokens could be saved.");
343
+ // A valid response means the authorization server has consumed the
344
+ // rotating refresh token. The SDK's saveTokens normally persists it;
345
+ // keep this copy so `fail` can persist it instead if that callback
346
+ // never comes (the SDK merges the old refresh token the same way).
347
+ const rotatedRefreshToken = outcome.tokens.refresh_token ?? currentTokens?.refresh_token;
348
+ flight.acceptedTokens = {
349
+ ...outcome.tokens,
350
+ ...(rotatedRefreshToken !== undefined
351
+ ? { refresh_token: rotatedRefreshToken }
352
+ : {}),
353
+ };
354
+ if (!this.markMutationPending(generation, flight)) {
355
+ // Already settled (the owner was cancelled first). Still write the
356
+ // rotation: losing it would leave a dead credential.
357
+ void flight.persist?.(flight.acceptedTokens).catch(() => { });
358
+ throw new Error("OAuth refresh ended before tokens could be saved.");
359
+ }
360
+ if (requestSignal?.aborted) {
361
+ // Same recovery as an abort landing later: `fail` persists.
362
+ this.fail(generation, flight, aborted(requestSignal));
363
+ throw aborted(requestSignal);
295
364
  }
296
365
  return response;
297
366
  }
@@ -300,8 +369,16 @@ export class OAuthRefreshCoordinator {
300
369
  throw error;
301
370
  }
302
371
  }
372
+ return refreshMutationPendingResponse();
303
373
  };
304
374
  }
375
+ /** Start storage only while this owner still holds its exact flight. */
376
+ beginMutation(generation, flight) {
377
+ if (this.flights.get(generation) !== flight)
378
+ return false;
379
+ flight.writing = true;
380
+ return true;
381
+ }
305
382
  /** Publish one exact owner's successful save without disturbing a newer try. */
306
383
  succeedMutation(generation, flight) {
307
384
  if (this.finishMutation(generation, flight)) {
@@ -312,6 +389,37 @@ export class OAuthRefreshCoordinator {
312
389
  }
313
390
  /** Give joined callers a fetch/flow failure, without rejecting the gate. */
314
391
  fail(generation, flight, error) {
392
+ // Only saveTokens owns a live credential write. If it has started, its
393
+ // success/failure callback clears the marker even after owner cancellation.
394
+ if (flight.writing) {
395
+ this.settle(generation, flight, { status: "failed", error });
396
+ return;
397
+ }
398
+ // The token endpoint already answered with valid tokens but the SDK will
399
+ // not save them (the owner was cancelled, redirected, or invalidated).
400
+ // The old refresh token is spent, so the host persists the rotation
401
+ // itself and keeps contenders behind the marker until it lands. Joined
402
+ // callers then receive the saved rotation, never a retired token to
403
+ // redeem again. saveTokens' own bookkeeping settles the flight when the
404
+ // provider still holds it; otherwise settle here once the write ends.
405
+ const tokens = flight.acceptedTokens;
406
+ if (tokens !== undefined &&
407
+ flight.persist !== undefined &&
408
+ this.pendingMutations.get(generation) === flight.mutationId) {
409
+ flight.writing = true;
410
+ void flight.persist(tokens).then(() => {
411
+ if (this.finishMutation(generation, flight)) {
412
+ this.settle(generation, flight, { status: "refreshed" });
413
+ }
414
+ }, (writeError) => {
415
+ if (this.finishMutation(generation, flight)) {
416
+ this.settle(generation, flight, { status: "failed", error: writeError });
417
+ }
418
+ });
419
+ return;
420
+ }
421
+ // A response alone is not a write and must never strand the generation.
422
+ this.finishMutation(generation, flight);
315
423
  this.settle(generation, flight, { status: "failed", error });
316
424
  }
317
425
  /** Finish an exact failed credential write, then publish its failure. */
@@ -417,23 +525,11 @@ export class KvOAuthProvider {
417
525
  captureRefreshFlight(generation, flight) {
418
526
  this.refreshFlight = { generation, flight };
419
527
  }
420
- succeedRefreshFlight() {
421
- const owned = this.refreshFlight;
422
- this.refreshFlight = undefined;
423
- if (owned) {
424
- this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
425
- }
426
- }
427
- failRefreshFlight(error, mutationFinished = false) {
528
+ failRefreshFlight(error) {
428
529
  const owned = this.refreshFlight;
429
530
  this.refreshFlight = undefined;
430
531
  if (owned) {
431
- if (mutationFinished) {
432
- this.refreshCoordinator?.failMutation(owned.generation, owned.flight, error);
433
- }
434
- else {
435
- this.refreshCoordinator?.fail(owned.generation, owned.flight, error);
436
- }
532
+ this.refreshCoordinator?.fail(owned.generation, owned.flight, error);
437
533
  }
438
534
  }
439
535
  /** True when another request saved a refresh result after this flow's read. */
@@ -650,14 +746,30 @@ export class KvOAuthProvider {
650
746
  return tokens;
651
747
  }
652
748
  async saveTokens(tokens, ctx) {
749
+ // A retired flight (the owner was cancelled or superseded after the
750
+ // token response) still writes: the authorization server has already
751
+ // consumed the old refresh token, so dropping the rotated one would leave a
752
+ // dead credential. writeValue itself refuses when the generation moved.
753
+ // Only the coordinator bookkeeping belongs to the exact live flight.
754
+ const owned = this.refreshFlight;
755
+ const coordinated = owned !== undefined &&
756
+ this.refreshCoordinator?.beginMutation(owned.generation, owned.flight) === true;
653
757
  try {
654
758
  await this.writeValue("oauth:tokens", tokens, (value) => JSON.stringify(value), ctx?.issuer);
655
- this.succeedRefreshFlight();
759
+ if (coordinated)
760
+ this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
656
761
  }
657
762
  catch (error) {
658
- this.failRefreshFlight(error, true);
763
+ if (coordinated)
764
+ this.refreshCoordinator?.failMutation(owned.generation, owned.flight, error);
659
765
  throw error;
660
766
  }
767
+ finally {
768
+ // The write owns this identity even if an SDK failure callback has
769
+ // already detached the provider's flight while storage was pending.
770
+ if (this.refreshFlight === owned)
771
+ this.refreshFlight = undefined;
772
+ }
661
773
  }
662
774
  /**
663
775
  * OAuth `state`. The SDK calls this (when present) and appends the value to
@@ -701,9 +813,9 @@ export class KvOAuthProvider {
701
813
  return stored.value;
702
814
  }
703
815
  async redirectToAuthorization(authorizationUrl) {
704
- if (!this.allowAuthorization)
705
- throw new UnauthorizedError("Authorization required. Use authorize_connector or Connect to start consent.");
706
816
  try {
817
+ if (!this.allowAuthorization)
818
+ throw new UnauthorizedError("Authorization required. Use authorize_connector or Connect to start consent.");
707
819
  await this.writeValue("oauth:pending", authorizationUrl.toString(), (raw) => raw);
708
820
  this.failRefreshFlight(new Error("OAuth refresh required reauthorization before tokens were saved."));
709
821
  }
@@ -36,6 +36,8 @@ export interface ConnectorCallAdmissionSnapshot {
36
36
  max: number;
37
37
  };
38
38
  }
39
+ /** Sum gauges and counters without publishing connector or principal keys. */
40
+ export declare function aggregateCallAdmissionSnapshots(snapshots: readonly ConnectorCallAdmissionSnapshot[]): ConnectorCallAdmissionSnapshot;
39
41
  /**
40
42
  * Per-runtime, per-connector call admission. State contains only bounded
41
43
  * partition keys, counters, timestamps, signals, and promise continuations;
@@ -64,6 +66,8 @@ export declare class ConnectorCallAdmissionController {
64
66
  acquire(input: Readonly<ConnectorCallAdmissionInput> & {
65
67
  signal?: AbortSignal;
66
68
  }): Promise<CallAdmissionPermit>;
69
+ /** A principal registry may be evicted only after calls and budgets drain. */
70
+ isIdle(): boolean;
67
71
  snapshot(): ConnectorCallAdmissionSnapshot;
68
72
  close(): void;
69
73
  private admit;
@@ -22,6 +22,27 @@ export class CallAdmissionError extends ConnectorCallError {
22
22
  export function isCallAdmissionError(error) {
23
23
  return error instanceof CallAdmissionError;
24
24
  }
25
+ /** Sum gauges and counters without publishing connector or principal keys. */
26
+ export function aggregateCallAdmissionSnapshots(snapshots) {
27
+ const aggregate = {
28
+ rules: 0, partitions: 0, active: 0, queued: 0, closed: snapshots.length > 0,
29
+ totals: { admitted: 0, queued: 0, rejected: 0, rateLimited: 0, cancelled: 0 },
30
+ queueWaitMs: { count: 0, total: 0, max: 0 },
31
+ };
32
+ for (const snapshot of snapshots) {
33
+ for (const key of ["rules", "partitions", "active", "queued"]) {
34
+ aggregate[key] += snapshot[key];
35
+ }
36
+ aggregate.closed &&= snapshot.closed;
37
+ for (const key of ["admitted", "queued", "rejected", "rateLimited", "cancelled"]) {
38
+ aggregate.totals[key] += snapshot.totals[key];
39
+ }
40
+ aggregate.queueWaitMs.count += snapshot.queueWaitMs.count;
41
+ aggregate.queueWaitMs.total += snapshot.queueWaitMs.total;
42
+ aggregate.queueWaitMs.max = Math.max(aggregate.queueWaitMs.max, snapshot.queueWaitMs.max);
43
+ }
44
+ return aggregate;
45
+ }
25
46
  function positiveWhole(value, name) {
26
47
  if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
27
48
  throw new TypeError(`${name} must be a positive whole number.`);
@@ -194,6 +215,11 @@ export class ConnectorCallAdmissionController {
194
215
  waiter.onAbort();
195
216
  });
196
217
  }
218
+ /** A principal registry may be evicted only after calls and budgets drain. */
219
+ isIdle() {
220
+ this.evictIdlePartitions(Date.now());
221
+ return this.partitions.size === 0;
222
+ }
197
223
  snapshot() {
198
224
  let active = 0;
199
225
  let queued = 0;
@@ -36,15 +36,20 @@ export function boundedCatalogDrift(report) {
36
36
  }
37
37
  const encoder = new TextEncoder();
38
38
  /** Deterministic JSON: object keys sorted, so key order is not a schema change. */
39
- function canonicalize(value) {
40
- if (Array.isArray(value))
41
- return value.map(canonicalize);
39
+ function canonicalize(value, depth = 0) {
40
+ // Drift is advisory. Beyond this bound compare an explicit marker instead
41
+ // of letting a downstream schema exhaust the host stack.
42
+ if (depth > 64)
43
+ return "[schema depth truncated]";
44
+ if (Array.isArray(value)) {
45
+ return value.map((item) => canonicalize(item, depth + 1));
46
+ }
42
47
  if (value === null || typeof value !== "object")
43
48
  return value;
44
49
  const entries = Object.entries(value)
45
50
  .filter(([, item]) => item !== undefined)
46
51
  .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
47
- return Object.fromEntries(entries.map(([key, item]) => [key, canonicalize(item)]));
52
+ return Object.fromEntries(entries.map(([key, item]) => [key, canonicalize(item, depth + 1)]));
48
53
  }
49
54
  /**
50
55
  * Digest the schemas of one downstream tool.