@zackbart/connecta 0.40.0 → 0.42.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.
Files changed (195) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/bin/connecta.mjs +26 -17
  3. package/bin/meta-tool-names.d.mts +3 -1
  4. package/bin/meta-tool-names.mjs +4 -1
  5. package/bin/version.mjs +1 -1
  6. package/dist/activity.d.ts +32 -5
  7. package/dist/activity.js +32 -6
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-observation.js +24 -2
  10. package/dist/agent-routes.js +33 -44
  11. package/dist/auth/clerk.d.ts +6 -1
  12. package/dist/auth/clerk.js +16 -0
  13. package/dist/auth/cloudflare-access.js +49 -7
  14. package/dist/auth/downstream-oauth/consent.js +79 -29
  15. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  16. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  17. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  18. package/dist/auth/downstream-oauth.js +15 -4
  19. package/dist/auth/oauth-request-not-sent.js +4 -0
  20. package/dist/auth/static-oauth.js +77 -39
  21. package/dist/auth-elicitation.js +118 -61
  22. package/dist/authorization-handoff.js +36 -3
  23. package/dist/catalog-cache.js +352 -43
  24. package/dist/catalog-freshness.js +25 -0
  25. package/dist/catalog-service.js +408 -78
  26. package/dist/catalog.js +216 -62
  27. package/dist/config-defaults.d.ts +2 -1
  28. package/dist/config-defaults.js +4 -1
  29. package/dist/config.d.ts +53 -21
  30. package/dist/config.js +56 -17
  31. package/dist/connectors/remote-mcp/call.js +1 -1
  32. package/dist/connectors/remote-mcp/catalog.js +52 -28
  33. package/dist/connectors/remote-mcp/connection.js +113 -22
  34. package/dist/connectors/remote-mcp/oauth.js +2 -2
  35. package/dist/connectors/remote-mcp/resources.js +5 -2
  36. package/dist/connectors/remote-mcp/transport.js +24 -6
  37. package/dist/connectors/remote-mcp.js +14 -5
  38. package/dist/credential-contract.d.ts +3 -1
  39. package/dist/describe-config.d.ts +4 -2
  40. package/dist/describe-config.js +1 -0
  41. package/dist/discovery-health.js +26 -11
  42. package/dist/downstream-input.js +11 -2
  43. package/dist/errors.d.ts +60 -4
  44. package/dist/errors.js +88 -3
  45. package/dist/execute.js +491 -202
  46. package/dist/executor-admission.js +2 -0
  47. package/dist/executor-contract.js +6 -0
  48. package/dist/executor-result.js +3 -3
  49. package/dist/executors/quickjs-child.js +14 -5
  50. package/dist/executors/quickjs-runtime.d.ts +2 -0
  51. package/dist/executors/quickjs-runtime.js +37 -20
  52. package/dist/executors/quickjs.js +24 -7
  53. package/dist/guarded-transport.js +2 -0
  54. package/dist/guest-functions.js +5 -4
  55. package/dist/guest-runtime.js +37 -1
  56. package/dist/guest-types.d.ts +18 -0
  57. package/dist/http/client.js +14 -2
  58. package/dist/http/errors.js +9 -4
  59. package/dist/http/fields.js +17 -5
  60. package/dist/http/hatch.js +11 -3
  61. package/dist/http/node-transport.js +110 -4
  62. package/dist/http/oauth.js +17 -6
  63. package/dist/http/paging.js +1 -1
  64. package/dist/http/provider.js +2 -6
  65. package/dist/http/vendor.js +6 -1
  66. package/dist/index.d.ts +1 -1
  67. package/dist/index.js +15 -21
  68. package/dist/invocation-failure.js +28 -0
  69. package/dist/invocation.js +167 -64
  70. package/dist/membership.d.ts +1 -1
  71. package/dist/meta-output.js +24 -18
  72. package/dist/meta-tool-names.d.ts +1 -1
  73. package/dist/meta-tool-names.js +1 -1
  74. package/dist/meta-tools.js +286 -73
  75. package/dist/module-contracts.d.ts +3 -1
  76. package/dist/native-recovery.js +4 -1
  77. package/dist/native-tools.js +177 -55
  78. package/dist/node.d.ts +7 -0
  79. package/dist/node.js +85 -4
  80. package/dist/oauth-handoff.js +53 -43
  81. package/dist/operation-activity.js +104 -0
  82. package/dist/operation-vocabulary.js +6 -0
  83. package/dist/operator-record.d.ts +9 -1
  84. package/dist/operator-record.js +16 -0
  85. package/dist/operator-ui/generated.js +2 -2
  86. package/dist/program-result.js +2 -0
  87. package/dist/program-source.js +1 -1
  88. package/dist/program-writes.js +112 -19
  89. package/dist/provider.d.ts +1 -14
  90. package/dist/provider.js +0 -3
  91. package/dist/providers/_shared/google/kit.js +2 -2
  92. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  93. package/dist/providers/breeze/api.js +2 -2
  94. package/dist/providers/breeze/index.js +1 -1
  95. package/dist/providers/breeze/skill.generated.js +1 -1
  96. package/dist/providers/ccb/api.js +2 -2
  97. package/dist/providers/ccb/index.js +1 -1
  98. package/dist/providers/ccb/skill.generated.js +1 -1
  99. package/dist/providers/cloudflare/api.js +2 -2
  100. package/dist/providers/cloudflare/index.js +3 -1
  101. package/dist/providers/cloudflare/skill.generated.js +1 -1
  102. package/dist/providers/docs/index.js +1 -1
  103. package/dist/providers/docs/skill.generated.js +1 -1
  104. package/dist/providers/drive/index.js +1 -1
  105. package/dist/providers/drive/skill.generated.js +1 -1
  106. package/dist/providers/forms/index.js +2 -1
  107. package/dist/providers/forms/skill.generated.js +1 -1
  108. package/dist/providers/github/api.js +98 -6
  109. package/dist/providers/github/index.js +8 -3
  110. package/dist/providers/github/lib/auth.d.ts +3 -1
  111. package/dist/providers/github/lib/auth.js +43 -12
  112. package/dist/providers/github/lib/discovery.js +11 -1
  113. package/dist/providers/github/lib/operations.js +8 -0
  114. package/dist/providers/github/safety.js +39 -0
  115. package/dist/providers/github/skill.generated.js +1 -1
  116. package/dist/providers/github/tools.js +12 -5
  117. package/dist/providers/gmail/index.js +2 -1
  118. package/dist/providers/gmail/skill.generated.js +1 -1
  119. package/dist/providers/granola/index.js +1 -1
  120. package/dist/providers/granola/skill.generated.js +1 -1
  121. package/dist/providers/overflow/index.js +3 -1
  122. package/dist/providers/overflow/skill.generated.js +1 -1
  123. package/dist/providers/planning-center/index.js +12 -6
  124. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  125. package/dist/providers/planning-center/skill.generated.js +1 -1
  126. package/dist/providers/quickbooks/api.js +28 -25
  127. package/dist/providers/quickbooks/index.js +3 -6
  128. package/dist/providers/quickbooks/lib/writes.js +55 -0
  129. package/dist/providers/quickbooks/safety.js +17 -5
  130. package/dist/providers/quickbooks/skill.generated.js +1 -1
  131. package/dist/providers/sheets/index.js +2 -1
  132. package/dist/providers/sheets/skill.generated.js +1 -1
  133. package/dist/providers/slides/index.js +3 -3
  134. package/dist/providers/slides/skill.generated.js +1 -1
  135. package/dist/providers/stripe/index.js +2 -2
  136. package/dist/providers/stripe/skill.generated.js +1 -1
  137. package/dist/providers/tithely/index.js +2 -1
  138. package/dist/providers/tithely/skill.generated.js +1 -1
  139. package/dist/providers/vercel/api.js +3 -2
  140. package/dist/providers/vercel/skill.generated.js +1 -1
  141. package/dist/registry.d.ts +36 -4
  142. package/dist/registry.js +142 -30
  143. package/dist/resource-read.js +21 -2
  144. package/dist/result-preview.js +210 -0
  145. package/dist/result-seal.js +168 -0
  146. package/dist/route-names.js +53 -0
  147. package/dist/routes/activity-context.js +1 -0
  148. package/dist/routes/activity-disclosure.js +26 -4
  149. package/dist/routes/activity.js +2 -1
  150. package/dist/routes/connect.js +21 -13
  151. package/dist/routes/mcp.js +83 -35
  152. package/dist/routes/shared.d.ts +6 -0
  153. package/dist/routes/ui-config.js +1 -1
  154. package/dist/routes/ui-data.js +3 -2
  155. package/dist/runtime/call.js +15 -10
  156. package/dist/runtime/native-storage.js +12 -0
  157. package/dist/runtime/request-reads.js +66 -14
  158. package/dist/runtime/request-scope.js +38 -2
  159. package/dist/runtime/run.js +4 -1
  160. package/dist/sent-secrets.d.ts +20 -0
  161. package/dist/sent-secrets.js +49 -1
  162. package/dist/skill-origin.js +16 -0
  163. package/dist/skills.js +213 -57
  164. package/dist/storage/d1-driver.js +33 -5
  165. package/dist/storage/keys.js +16 -7
  166. package/dist/storage/sql-native.js +60 -3
  167. package/dist/storage/sql.js +6 -0
  168. package/dist/timing-trace.js +84 -0
  169. package/dist/tool-safety.d.ts +33 -0
  170. package/dist/tool-safety.js +51 -15
  171. package/dist/types.d.ts +36 -0
  172. package/dist/url-safety.js +38 -20
  173. package/dist/usage-guide.js +14 -13
  174. package/dist/utility-recovery.js +33 -0
  175. package/dist/validate.js +29 -10
  176. package/dist/version.d.ts +1 -1
  177. package/dist/version.js +1 -1
  178. package/dist/worker.js +60 -15
  179. package/documentation/architecture.md +152 -43
  180. package/documentation/auth.md +160 -53
  181. package/documentation/code-mode.md +396 -119
  182. package/documentation/deploying.md +35 -10
  183. package/documentation/hosted-mcp-migration.md +9 -3
  184. package/documentation/integrating.md +27 -5
  185. package/documentation/meta-tools.md +761 -157
  186. package/documentation/operating.md +91 -4
  187. package/documentation/operator-ui.md +51 -2
  188. package/documentation/providers.md +34 -20
  189. package/examples/worker/README.md +3 -3
  190. package/guides/how-it-works.md +2 -1
  191. package/guides/safety-and-trust.md +1 -3
  192. package/package.json +10 -8
  193. package/templates/node/AGENTS.md +3 -3
  194. package/templates/node/README.md +1 -1
  195. package/templates/node/package.json +1 -1
@@ -26,45 +26,8 @@
26
26
  * catalogs with the description built for its own surface.
27
27
  */
28
28
  import { Context } from "effect";
29
- const NAMES = {
30
- meta: Object.freeze({
31
- search: "search_tools",
32
- read: "call_tool",
33
- write: "call_destructive_tool",
34
- program: "execute_code",
35
- authorize: "authorize_connector",
36
- skills: "skills",
37
- }),
38
- code: Object.freeze({
39
- search: "connecta.search inside execute_code",
40
- read: "connecta.call inside execute_code",
41
- write: "connecta.call inside execute_code",
42
- program: "execute_code",
43
- authorize: "authorize_connector",
44
- skills: "skills",
45
- }),
46
- native: Object.freeze({
47
- search: "your host's tool search",
48
- read: "the original native tool",
49
- write: "the original native tool",
50
- program: "your host's tool search",
51
- authorize: "the returned authorization handoff",
52
- skills: "skills",
53
- }),
54
- "native-code": Object.freeze({
55
- search: "your host's tool search",
56
- read: "the original native tool",
57
- write: "the original native tool",
58
- program: "execute_code",
59
- authorize: "the returned authorization handoff",
60
- skills: "skills",
61
- }),
62
- };
63
- /** The six-tool surface's names: the default wherever no other surface applies. */
64
- export const META_ROUTES = NAMES.meta;
65
- export function routeNames(surface) {
66
- return NAMES[surface];
67
- }
29
+ import { META_ROUTES, routeNames } from "./route-names.js";
30
+ export { META_ROUTES, routeNames } from "./route-names.js";
68
31
  /** The names for the surface a connector operation serves; meta for contexts Connecta did not build. */
69
32
  export function routesOf(ctx) {
70
33
  return ctx?.routes ?? META_ROUTES;
@@ -86,11 +49,25 @@ const nativeAuthorize = {
86
49
  authorizeCall: () => "Give this error's nextAction to the user or operator.",
87
50
  authorizeAgain: "retry the original native call",
88
51
  };
52
+ /**
53
+ * An auth error's message, once that error carries what authorize_connector
54
+ * would return: the fixed route text stops sending the agent to fetch it again.
55
+ * Only Connecta's own phrases change; provider wording around them stays.
56
+ */
57
+ export function handoffInHand(message, connector) {
58
+ return message
59
+ .replaceAll(metaAuthorize.authorizeAndOpen(connector), nativeAuthorize.authorizeAndOpen())
60
+ .replaceAll(metaAuthorize.authorizeAndFollow(connector), nativeAuthorize.authorizeAndFollow())
61
+ .replaceAll(metaAuthorize.authorizeCall(connector), nativeAuthorize.authorizeCall())
62
+ .replaceAll(metaAuthorize.authorizeCall(), nativeAuthorize.authorizeCall())
63
+ .replaceAll(metaAuthorize.authorizeFor("recovery options"), nativeAuthorize.authorizeFor())
64
+ .replaceAll(metaAuthorize.authorizeFor("this connector"), nativeAuthorize.authorizeFor());
65
+ }
89
66
  const nativeDirect = {
90
67
  directOnly: "when calling this tool directly",
91
68
  forDirect: "when calling this tool directly",
92
69
  onlyDirectRead: "only when calling this tool directly",
93
- writeNeedsApproval: "Invoke it through its own native tool so the MCP host can request approval.",
70
+ writeNeedsApproval: "This name is bound to a read, so nothing was sent. Invoke it through its own write tool so the MCP host can request approval.",
94
71
  };
95
72
  const PHRASES = {
96
73
  meta: Object.freeze({
@@ -102,6 +79,15 @@ const PHRASES = {
102
79
  writeNeedsApproval: "Invoke it through call_destructive_tool so the MCP host can request approval.",
103
80
  ...metaAuthorize,
104
81
  }),
82
+ "read-route": Object.freeze({
83
+ program: "execute_read_code",
84
+ directOnly: "for a direct call_tool only",
85
+ forDirect: "for a direct call_tool",
86
+ onlyDirectRead: "only for a direct call_tool read",
87
+ callDirectlyAndPage: "call directly (call_tool, connecta.result to page)",
88
+ writeNeedsApproval: "Invoke it through call_destructive_tool so the MCP host can request approval.",
89
+ ...metaAuthorize,
90
+ }),
105
91
  // The eval code arm keeps the text its comparator protocol recorded.
106
92
  code: Object.freeze({
107
93
  program: "execute_code",
@@ -125,7 +111,10 @@ const PHRASES = {
125
111
  ...nativeAuthorize,
126
112
  }),
127
113
  };
128
- const SURFACES = new Map(Object.keys(NAMES).map((surface) => [NAMES[surface], surface]));
114
+ const SURFACES = new Map(["meta", "read-route", "code", "native", "native-code"].map((surface) => [
115
+ routeNames(surface),
116
+ surface,
117
+ ]));
129
118
  /** The surface a names table belongs to; meta for a table Connecta did not build. */
130
119
  function routeSurface(routes) {
131
120
  return (routes && SURFACES.get(routes)) ?? "meta";
@@ -222,10 +211,10 @@ const programRouteConfigs = new WeakMap();
222
211
  * No package entry exports it and no configuration key selects it; the eval
223
212
  * code arm marks the configuration object it passes to createConnecta.
224
213
  */
225
- export function renderRoutesAsPrograms(config) {
226
- programRouteConfigs.set(config, true);
214
+ export function renderRoutesAsPrograms(config, searchOnly = false) {
215
+ programRouteConfigs.set(config, searchOnly ? { ...META_ROUTES, search: routeNames("code").search } : routeNames("code"));
227
216
  }
228
217
  /** The names every reply of a deployment built from `config` uses outside native pools. */
229
218
  export function deploymentRoutes(config) {
230
- return programRouteConfigs.has(config) ? NAMES.code : NAMES.meta;
219
+ return programRouteConfigs.get(config) ?? META_ROUTES;
231
220
  }
@@ -4,7 +4,12 @@ export type { ClerkGateClient, ClerkUser } from "./clerk-sdk/client.js";
4
4
  export interface ClerkAuthOptions {
5
5
  publishableKey: string;
6
6
  secretKey: string;
7
- /** Public base URL of this deployment. Defaults to the request origin. */
7
+ /**
8
+ * Public base URL of this deployment: what tokens' resource and sessions'
9
+ * `azp` are checked against. Defaults to `ConnectaConfig.publicUrl`. One of
10
+ * the two is required; a request's Host never substitutes for it. A local
11
+ * deployment names its loopback origin, such as `http://localhost:8787`.
12
+ */
8
13
  publicUrl?: string;
9
14
  /**
10
15
  * OAuth client IDs dedicated to this deployment, used only when a verified
@@ -436,7 +436,22 @@ function mcpPoolSuffix(pathname) {
436
436
  const match = /^\/mcp(\/[a-z0-9_-]+)$/.exec(pathname);
437
437
  return match ? match[1] : null;
438
438
  }
439
+ function assertPublicUrl(value) {
440
+ if (value === undefined)
441
+ return;
442
+ let url;
443
+ try {
444
+ url = typeof value === "string" ? new URL(value) : undefined;
445
+ }
446
+ catch {
447
+ url = undefined;
448
+ }
449
+ if (!url || (url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
450
+ throw new Error("clerkAuth: `publicUrl` must be an absolute http(s) URL without credentials.");
451
+ }
452
+ }
439
453
  export function clerkAuth(opts) {
454
+ assertPublicUrl(opts.publicUrl);
440
455
  assertNoRetiredToolkitOptions("clerkAuth", opts);
441
456
  // Before the Clerk client, so a malformed key fails as a connecta
442
457
  // configuration error rather than however the SDK happens to treat it.
@@ -771,6 +786,7 @@ export function clerkAuth(opts) {
771
786
  return {
772
787
  kind: "clerk",
773
788
  interactiveOperator: true,
789
+ ...(opts.publicUrl === undefined ? { requiresPublicUrl: true } : {}),
774
790
  recognizesCredential: (request) => {
775
791
  if (authorizationCredential(request).kind !== "absent")
776
792
  return !isMachineCredential(request);
@@ -1,8 +1,9 @@
1
+ import { Effect } from "effect";
1
2
  import { withAuthenticationDeadline } from "../auth-admission.js";
2
3
  import { validIdentityReference } from "../identity.js";
3
4
  import { authorizationCredential } from "../inbound-credential.js";
4
5
  import { attachFailureFacts } from "../operator-record.js";
5
- import { authCall, authFlow, authInvoke, authMethod } from "./native.js";
6
+ import { authCall, authFlow, authMethod } from "./native.js";
6
7
  function identityString(identity, field) {
7
8
  const value = identity[field];
8
9
  return typeof value === "string" && validIdentityReference({ namespace: "cloudflare-access", id: value })
@@ -15,12 +16,30 @@ function unauthorized(reason) {
15
16
  response: attachFailureFacts(Response.json({ error: "Cloudflare Access authentication required" }, { status: 401, headers: { "WWW-Authenticate": 'Bearer scope="openid email"', "Cache-Control": "no-store" } }), { reason }),
16
17
  };
17
18
  }
18
- function unavailable(problem) {
19
+ function unavailable(problem, identityLookup) {
19
20
  return {
20
21
  ok: false,
21
- response: attachFailureFacts(Response.json({ error: "Cloudflare Access identity unavailable", problem }, { status: 503, headers: { "Cache-Control": "no-store", "Retry-After": "1" } }), { reason: problem === "identity_unavailable" ? "access_identity_unavailable" : problem }),
22
+ response: attachFailureFacts(Response.json({ error: "Cloudflare Access identity unavailable", problem }, { status: 503, headers: { "Cache-Control": "no-store", "Retry-After": "1" } }), {
23
+ reason: problem === "identity_unavailable" ? "access_identity_unavailable" : problem,
24
+ ...(identityLookup ? { identityLookup } : {}),
25
+ }),
22
26
  };
23
27
  }
28
+ let lookups = 0;
29
+ let isolateCold = true;
30
+ const lookupSettled = () => {
31
+ lookups--;
32
+ };
33
+ function lookupIdentity(access) {
34
+ lookups++;
35
+ try {
36
+ return Promise.resolve(access.getIdentity()).finally(lookupSettled);
37
+ }
38
+ catch (error) {
39
+ lookups--;
40
+ throw error;
41
+ }
42
+ }
24
43
  /**
25
44
  * Trust the identity Cloudflare Access attached to this direct Worker
26
45
  * invocation. Access has already validated the browser session, Managed OAuth
@@ -50,16 +69,39 @@ export function cloudflareAccessAuth() {
50
69
  if (typeof access.aud !== "string" || !/^[\x21-\x7e]{1,256}$/.test(access.aud))
51
70
  return unavailable("access_audience_invalid");
52
71
  let identity;
72
+ const started = performance.now();
73
+ const cold = isolateCold;
74
+ isolateCold = false;
75
+ let rejected = false;
53
76
  try {
54
77
  identity = (yield authCall(withAuthenticationDeadline, [
55
78
  request,
56
- authMethod(() => {
57
- return authInvoke(access, "getIdentity", []);
58
- }),
79
+ authMethod((signal) => authFlow(function* () {
80
+ for (let attempt = 0;; attempt++) {
81
+ signal.throwIfAborted();
82
+ try {
83
+ return (yield authCall(lookupIdentity, [access]));
84
+ }
85
+ catch (error) {
86
+ if (attempt === 1) {
87
+ rejected = true;
88
+ throw error;
89
+ }
90
+ }
91
+ // Only a rejected identity lookup retries. A hung lookup never
92
+ // overlaps a second one; this sleep shares the original deadline.
93
+ yield Effect.sleep(100 + Math.floor(Math.random() * 151));
94
+ }
95
+ })),
59
96
  ]));
60
97
  }
61
98
  catch {
62
- return unavailable("identity_unavailable");
99
+ return unavailable("identity_unavailable", {
100
+ outcome: rejected ? "rejected" : "timeout",
101
+ durationMs: Math.max(0, Math.round(performance.now() - started)),
102
+ cold,
103
+ inFlight: lookups,
104
+ });
63
105
  }
64
106
  // Access binds ctx.access.aud to its Worker application at the edge.
65
107
  // Service credentials can pass that edge, but connecta machines must
@@ -99,10 +99,38 @@ const FLOW_VERSION = 1;
99
99
  * flow record's fifteen minutes.
100
100
  */
101
101
  const PENDING_AUTHORIZATION_MAX_AGE_MS = 10 * 60 * 1000;
102
- export function discoveryBinding(state) {
103
- return runEdge(authProgram(discoveryBindingNative, [state]));
102
+ /**
103
+ * What a flow record without a sealer may keep of a URL: everything but its
104
+ * userinfo, query and fragment. Non-URLs and clean URLs stay exact, so an
105
+ * ordinary issuer or client id still compares byte for byte.
106
+ */
107
+ export function withoutUrlSecrets(value) {
108
+ if (Array.isArray(value))
109
+ return value.map(withoutUrlSecrets);
110
+ if (plainObject(value)) {
111
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, withoutUrlSecrets(item)]));
112
+ }
113
+ if (typeof value !== "string")
114
+ return value;
115
+ try {
116
+ const url = new URL(value);
117
+ if (!url.username && !url.password && !url.search && !url.hash)
118
+ return value;
119
+ url.username = url.password = url.search = url.hash = "";
120
+ return url.href;
121
+ }
122
+ catch {
123
+ return value;
124
+ }
125
+ }
126
+ /**
127
+ * `minimized` binds the discovery metadata without its URL secrets, for a
128
+ * record no key protects: that digest is unkeyed, so it may not cover them.
129
+ */
130
+ export function discoveryBinding(state, minimized = false) {
131
+ return runEdge(authProgram(discoveryBindingNative, [state, minimized]));
104
132
  }
105
- const discoveryBindingNative = /* @__PURE__ */ registerAuth(discoveryBinding, function (state) {
133
+ const discoveryBindingNative = /* @__PURE__ */ registerAuth(discoveryBinding, function (state, minimized = false) {
106
134
  return authFlow(function* () {
107
135
  return state
108
136
  ? {
@@ -111,7 +139,9 @@ const discoveryBindingNative = /* @__PURE__ */ registerAuth(discoveryBinding, fu
111
139
  ...(state.authorizationServerMetadata?.authorization_response_iss_parameter_supported === true
112
140
  ? { issRequired: true }
113
141
  : {}),
114
- discovery: (yield authCall(oauthStateDigest, [JSON.stringify(state)])),
142
+ discovery: (yield authCall(oauthStateDigest, [
143
+ JSON.stringify(minimized ? withoutUrlSecrets(state) : state),
144
+ ])),
115
145
  }
116
146
  : {};
117
147
  });
@@ -120,20 +150,26 @@ const discoveryBindingNative = /* @__PURE__ */ registerAuth(discoveryBinding, fu
120
150
  export const consentMethods = {
121
151
  encodeFlow: function (key, flow) {
122
152
  return authFlow(function* () {
123
- const { verifier, ...rest } = flow;
153
+ const { binding, url, verifier, ...rest } = flow;
124
154
  const stored = { connectaOAuthFlow: FLOW_VERSION, ...rest };
125
- if (verifier !== undefined) {
126
- if (this.sealer) {
127
- stored.verifier = (yield authInvoke(this, "wait", [
128
- authMethod(() => {
129
- return authInvoke(this.sealer, "seal", [this.flowLabel(key, flow.epoch), verifier]);
130
- }),
131
- ]));
132
- stored.sealed = true;
133
- }
134
- else {
155
+ if (this.sealer) {
156
+ stored.record = (yield authInvoke(this, "wait", [
157
+ authMethod(() => {
158
+ return authInvoke(this.sealer, "seal", [
159
+ this.flowRecordLabel(key, flow.epoch),
160
+ JSON.stringify({ binding, url, verifier }),
161
+ ]);
162
+ }),
163
+ ]));
164
+ }
165
+ else {
166
+ // No key to protect it with: keep only what the callback must read.
167
+ // The consent URL is the first thing to go, so a repeated start mints
168
+ // a fresh consent rather than handing this one back.
169
+ if (binding)
170
+ stored.binding = withoutUrlSecrets(binding);
171
+ if (verifier !== undefined)
135
172
  stored.verifier = verifier;
136
- }
137
173
  }
138
174
  return JSON.stringify(stored);
139
175
  }.bind(this));
@@ -146,7 +182,8 @@ export const consentMethods = {
146
182
  typeof value.epoch !== "string" ||
147
183
  typeof value.at !== "number" ||
148
184
  (value.url !== undefined && typeof value.url !== "string") ||
149
- (value.verifier !== undefined && typeof value.verifier !== "string")) {
185
+ (value.verifier !== undefined && typeof value.verifier !== "string") ||
186
+ (value.record !== undefined && typeof value.record !== "string")) {
150
187
  return undefined;
151
188
  }
152
189
  const flow = {
@@ -156,23 +193,32 @@ export const consentMethods = {
156
193
  ...(typeof value.url === "string" ? { url: value.url } : {}),
157
194
  ...(value.consumed === true ? { consumed: true } : {}),
158
195
  };
159
- if (!withVerifier || typeof value.verifier !== "string")
196
+ const sealedRecord = typeof value.record === "string";
197
+ if (!sealedRecord && (!withVerifier || typeof value.verifier !== "string"))
160
198
  return flow;
161
- if (value.sealed !== true)
199
+ if (!sealedRecord && value.sealed !== true)
162
200
  return { ...flow, verifier: value.verifier };
163
201
  try {
164
202
  if (!this.sealer)
165
203
  return undefined;
204
+ const opened = (yield authInvoke(this, "wait", [
205
+ authMethod(() => {
206
+ return authInvoke(this.sealer, "open", [
207
+ sealedRecord ? this.flowRecordLabel(key, flow.epoch) : this.flowLabel(key, flow.epoch),
208
+ (sealedRecord ? value.record : value.verifier),
209
+ ]);
210
+ }),
211
+ ]));
212
+ if (!sealedRecord)
213
+ return { ...flow, verifier: opened };
214
+ const record = parsed(opened);
215
+ if (!plainObject(record))
216
+ return undefined;
166
217
  return {
167
218
  ...flow,
168
- verifier: (yield authInvoke(this, "wait", [
169
- authMethod(() => {
170
- return authInvoke(this.sealer, "open", [
171
- this.flowLabel(key, flow.epoch),
172
- value.verifier,
173
- ]);
174
- }),
175
- ])),
219
+ ...(plainObject(record.binding) ? { binding: record.binding } : {}),
220
+ ...(typeof record.url === "string" ? { url: record.url } : {}),
221
+ ...(withVerifier && typeof record.verifier === "string" ? { verifier: record.verifier } : {}),
176
222
  };
177
223
  }
178
224
  catch {
@@ -258,7 +304,10 @@ export const consentMethods = {
258
304
  const binding = this.configuredAuthorizationBinding() ?? {
259
305
  ...(yield authInvoke(this, "wait", [
260
306
  authMethod(() => {
261
- return authCall(discoveryBinding, [this.consentDiscovery ?? grant.body.discovery]);
307
+ return authCall(discoveryBinding, [
308
+ this.consentDiscovery ?? grant.body.discovery,
309
+ !this.sealer,
310
+ ]);
262
311
  }),
263
312
  ])),
264
313
  issuer: discoveryIssuer(this.consentDiscovery) ?? grant.body.issuer,
@@ -442,7 +491,8 @@ export const consentMethods = {
442
491
  if (grant.epoch !== epoch)
443
492
  throw this.flowSuperseded();
444
493
  (yield authInvoke(this, "checkFlowBinding", [grant]));
445
- if (endpoint !== undefined && this.callback?.flow.binding?.tokenEndpoint !== String(endpoint)) {
494
+ if (endpoint !== undefined &&
495
+ this.flowValue(this.callback?.flow.binding?.tokenEndpoint) !== this.flowValue(String(endpoint))) {
446
496
  throw this.flowSuperseded();
447
497
  }
448
498
  // Opening ciphertext may yield. Re-read the exact record after opening,
@@ -16,6 +16,15 @@ const DISCONNECTED_EPOCH_PREFIX = "disconnected:";
16
16
  const GRANT_VERSION = 3;
17
17
  /** Attempts at one grant write while other writers keep winning. */
18
18
  export const MAX_GRANT_WRITES = 32;
19
+ /** Token commits and their status fence are the same CAS, never separate writes. */
20
+ export function nextTokenGrant(grant, body) {
21
+ const revision = (grant.tokenRevision ?? 0) + 1;
22
+ if (!Number.isSafeInteger(revision))
23
+ throw new Error("OAuth grant revision exhausted");
24
+ const next = { ...grant, body, tokenRevision: revision };
25
+ delete next.observation;
26
+ return next;
27
+ }
19
28
  export function plainObject(value) {
20
29
  return value !== null && typeof value === "object" && !Array.isArray(value);
21
30
  }
@@ -32,6 +41,8 @@ function storedGrant(raw) {
32
41
  return plainObject(value) &&
33
42
  value.connectaOAuth === GRANT_VERSION &&
34
43
  typeof value.epoch === "string" &&
44
+ (value.tokenRevision === undefined ||
45
+ (Number.isSafeInteger(value.tokenRevision) && value.tokenRevision >= 0)) &&
35
46
  (value.flow === undefined || typeof value.flow === "string") &&
36
47
  (value.sealed === undefined || typeof value.sealed === "string")
37
48
  ? value
@@ -105,6 +116,8 @@ export const grantStoreMethods = {
105
116
  const stored = {
106
117
  connectaOAuth: GRANT_VERSION,
107
118
  epoch: grant.epoch,
119
+ ...(grant.tokenRevision !== undefined ? { tokenRevision: grant.tokenRevision } : {}),
120
+ ...(grant.observation ? { observation: grant.observation } : {}),
108
121
  ...(grant.flow !== undefined ? { flow: grant.flow } : {}),
109
122
  };
110
123
  if (Object.keys(grant.body).length > 0) {
@@ -160,6 +173,11 @@ export const grantStoreMethods = {
160
173
  }
161
174
  const grant = {
162
175
  epoch: stored.epoch,
176
+ ...(stored.tokenRevision !== undefined ? { tokenRevision: stored.tokenRevision } : {}),
177
+ ...(stored.observation?.revision === (stored.tokenRevision ?? 0) &&
178
+ (stored.observation.outcome === "accepted" || stored.observation.outcome === "refused")
179
+ ? { observation: { revision: stored.observation.revision, outcome: stored.observation.outcome } }
180
+ : {}),
163
181
  ...(stored.flow !== undefined ? { flow: stored.flow } : {}),
164
182
  body: grantBody(body),
165
183
  };
@@ -189,7 +207,12 @@ export const grantStoreMethods = {
189
207
  authMethod(() => authStorage(this.storage, "get", [GRANT])),
190
208
  ]));
191
209
  const grant = (yield authInvoke(this, "openGrant", [raw]));
192
- return { epoch: grant.epoch, ...(grant.body.tokens ? { tokens: grant.body.tokens } : {}) };
210
+ return {
211
+ epoch: grant.epoch,
212
+ ...(grant.body.tokens ? { tokens: grant.body.tokens } : {}),
213
+ ...(grant.body.issuer !== undefined ? { issuer: grant.body.issuer } : {}),
214
+ ...(grant.observation ? { observation: grant.observation } : {}),
215
+ };
193
216
  }.bind(this));
194
217
  },
195
218
  readGrant: function (signal) {
@@ -309,7 +332,8 @@ export const grantStoreMethods = {
309
332
  if (refreshConsent)
310
333
  body.refreshConsent = refreshConsent;
311
334
  }
312
- return this.withIssuer(grant, body);
335
+ const next = this.withIssuer(grant, body);
336
+ return field === "tokens" ? nextTokenGrant(next, body) : next;
313
337
  },
314
338
  ]));
315
339
  if (field === "tokens")
@@ -318,6 +342,20 @@ export const grantStoreMethods = {
318
342
  this.seen[field] = fingerprint(field === "client" ? value.value : value);
319
343
  }.bind(this));
320
344
  },
345
+ recordTokenOutcome: function (snapshot, outcome) {
346
+ if (outcome !== "accepted" && outcome !== "refused")
347
+ return Effect.void;
348
+ return authInvoke(this, "updateGrant", [
349
+ (grant) => {
350
+ if (grant.epoch !== snapshot.epoch || (grant.tokenRevision ?? 0) !== snapshot.revision || !grant.body.tokens)
351
+ return undefined;
352
+ if (grant.observation?.revision === snapshot.revision && grant.observation.outcome === outcome)
353
+ return undefined;
354
+ return { ...grant, observation: { revision: snapshot.revision, outcome } };
355
+ },
356
+ { epoch: snapshot.epoch, quiet: true },
357
+ ]);
358
+ },
321
359
  bindingForGrant: function (grant) {
322
360
  return authFlow(function* () {
323
361
  const configured = this.configuredAuthorizationBinding();
@@ -326,7 +364,7 @@ export const grantStoreMethods = {
326
364
  : {
327
365
  ...(yield authInvoke(this, "wait", [
328
366
  authMethod(() => {
329
- return authCall(discoveryBinding, [grant.body.discovery]);
367
+ return authCall(discoveryBinding, [grant.body.discovery, !this.sealer]);
330
368
  }),
331
369
  ])),
332
370
  issuer: grant.body.issuer,
@@ -338,7 +376,8 @@ export const grantStoreMethods = {
338
376
  return authFlow(function* () {
339
377
  const binding = this.callback?.flow.binding;
340
378
  if (!binding ||
341
- fingerprint(binding) !== fingerprint((yield authInvoke(this, "bindingForGrant", [grant])))) {
379
+ fingerprint(this.flowValue(binding)) !==
380
+ fingerprint(this.flowValue((yield authInvoke(this, "bindingForGrant", [grant]))))) {
342
381
  throw this.flowSuperseded();
343
382
  }
344
383
  }.bind(this));
@@ -384,9 +423,14 @@ export const grantStoreMethods = {
384
423
  };
385
424
  }.bind(this));
386
425
  },
387
- resetAuthorization: function (operatorDisconnected = false, preserveClient = false) {
426
+ resetAuthorization: function (operatorDisconnected = false, preserveClient = false, refused) {
388
427
  return authFlow(function* () {
389
- const operation = authInvoke(this, "performReset", [operatorDisconnected, preserveClient]);
428
+ const operation = authInvoke(this, "performReset", [
429
+ operatorDisconnected,
430
+ preserveClient,
431
+ undefined,
432
+ refused,
433
+ ]);
390
434
  if (!this.onReset)
391
435
  return (yield operation);
392
436
  const reset = runEdge(operation);
@@ -404,11 +448,18 @@ export const grantStoreMethods = {
404
448
  return (yield authValue(() => reset));
405
449
  }.bind(this));
406
450
  },
407
- performReset: function (operatorDisconnected, preserveClient, revoke) {
451
+ performReset: function (operatorDisconnected, preserveClient, revoke, refused) {
408
452
  return authFlow(function* () {
409
453
  const epoch = `${operatorDisconnected ? DISCONNECTED_EPOCH_PREFIX : ACTIVE_EPOCH_PREFIX}${crypto.randomUUID()}`;
410
454
  for (let attempt = 0; attempt < MAX_GRANT_WRITES; attempt++) {
411
455
  const { raw, grant } = (yield authInvoke(this, "readGrant", [null]));
456
+ // Compare again on every CAS attempt: recovery may have changed the
457
+ // epoch, token revision, or observation since Connect read it.
458
+ if (refused &&
459
+ (grant.epoch !== refused.epoch ||
460
+ (grant.tokenRevision ?? 0) !== refused.revision ||
461
+ grant.observation?.outcome !== "refused"))
462
+ return;
412
463
  const carried = preserveClient && !operatorDisconnected ? this.carriableClient(grant) : undefined;
413
464
  const encoded = (yield authInvoke(this, "encodeGrant", [{ epoch, body: carried ?? {} }, null]));
414
465
  let written;
@@ -188,8 +188,11 @@ export class OAuthRefreshCoordinator {
188
188
  // Production adapters resolve the required host policy before a code or
189
189
  // refresh claim can consume its single-use credential. Custom SDK leaves
190
190
  // may supply their own policy; this hook performs no I/O and owns no data.
191
+ // Each also registers the request's credentials before this point, as `api()`
192
+ // does in this hook: a refresh outlives its caller once claimed, and a sealing
193
+ // run must not publish ahead of that. `remoteMcp()` does so around its SDK flows.
191
194
  if (isOAuthCredentialRequest(init))
192
- prepareCredentialRequest?.();
195
+ prepareCredentialRequest?.(input, init);
193
196
  if (isRefreshTokenRequest(init)) {
194
197
  const signal = requestSignal && init?.signal
195
198
  ? AbortSignal.any([requestSignal, init.signal])
@@ -4,7 +4,7 @@ import { fromSignal } from "../../runtime/run.js";
4
4
  import { OAUTH_REFRESH_LEASE_SECONDS, oauthRefreshActiveKeys, oauthRefreshKeys, oauthRefreshSpentKeys, } from "../../storage/keys.js";
5
5
  import { authCall, authCatch, authFlow, authInvoke, authMethod, authStorage, authStorageMany } from "../native.js";
6
6
  import { tokenExpiry } from "../oauth-grant.js";
7
- import { GRANT, MAX_GRANT_WRITES, epochOf, parsed, plainObject } from "./grant-store.js";
7
+ import { nextTokenGrant, GRANT, MAX_GRANT_WRITES, epochOf, parsed, plainObject } from "./grant-store.js";
8
8
  import { aborted, refreshTokensDigest, refreshWait } from "./refresh-coordinator.js";
9
9
  import { oauthStateDigest } from "./consent.js";
10
10
  export function storedRefreshSpent(raw) {
@@ -378,10 +378,11 @@ export const refreshRecordMethods = {
378
378
  const committed = issuer !== undefined ? { ...tokens, issuer } : tokens;
379
379
  const { expiresAt: _previous, ...body } = grant.body;
380
380
  const expiresAt = tokenExpiry(committed, issuedAt);
381
- const next = {
382
- ...grant,
383
- body: { ...body, tokens: committed, ...(expiresAt !== undefined ? { expiresAt } : {}) },
384
- };
381
+ const next = nextTokenGrant(grant, {
382
+ ...body,
383
+ tokens: committed,
384
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
385
+ });
385
386
  const encoded = (yield authInvoke(this, "encodeGrant", [next, null]));
386
387
  if ((yield authStorage(this.storage, "compareAndSet", [GRANT, raw, encoded]))) {
387
388
  this.observe(next);