@zackbart/connecta 0.24.4 → 0.25.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 (107) hide show
  1. package/AGENTS.md +40 -10
  2. package/CHANGELOG.md +253 -0
  3. package/dist/advertised-schema.js +53 -0
  4. package/dist/auth/downstream-oauth.js +490 -106
  5. package/dist/call-admission.js +139 -113
  6. package/dist/catalog-service.js +141 -105
  7. package/dist/catalog.js +258 -33
  8. package/dist/concurrency.js +24 -23
  9. package/dist/connector-scope.js +6 -37
  10. package/dist/connectors/api.d.ts +2 -2
  11. package/dist/connectors/remote-mcp.d.ts +6 -5
  12. package/dist/connectors/remote-mcp.js +317 -241
  13. package/dist/credential-contract.d.ts +9 -0
  14. package/dist/credential-rules.js +2 -2
  15. package/dist/credentials.d.ts +6 -1
  16. package/dist/credentials.js +45 -3
  17. package/dist/execute.js +363 -257
  18. package/dist/executor-admission.js +67 -50
  19. package/dist/executors/quickjs-runtime.js +4 -2
  20. package/dist/executors/quickjs.js +251 -210
  21. package/dist/fix-prompt.js +37 -0
  22. package/dist/index.d.ts +10 -1
  23. package/dist/index.js +51 -20
  24. package/dist/invocation.js +324 -285
  25. package/dist/meta-tools.js +206 -92
  26. package/dist/oauth-callback-outcome.js +94 -0
  27. package/dist/oauth-sealing.js +29 -0
  28. package/dist/operator-ui/fix-prompts.js +87 -0
  29. package/dist/operator-ui/generated.js +2 -2
  30. package/dist/operator-ui/model.d.ts +46 -1
  31. package/dist/operator-ui/setup-commands.js +53 -0
  32. package/dist/operator-ui/view.js +38 -2
  33. package/dist/program-source.js +205 -0
  34. package/dist/providers/notion.js +2 -2
  35. package/dist/providers/revenuecat.js +32 -5
  36. package/dist/providers/stripe.d.ts +3 -2
  37. package/dist/providers/vercel.js +1 -1
  38. package/dist/registry.d.ts +1 -1
  39. package/dist/registry.js +305 -260
  40. package/dist/routes/activity.js +120 -137
  41. package/dist/routes/credentials.js +174 -163
  42. package/dist/routes/mcp.js +129 -70
  43. package/dist/routes/oauth-management.js +60 -71
  44. package/dist/routes/oauth.js +44 -16
  45. package/dist/routes/operator.js +82 -0
  46. package/dist/routes/shared.d.ts +2 -0
  47. package/dist/routes/ui-data.js +210 -0
  48. package/dist/routes/ui.js +64 -32
  49. package/dist/runtime/admission.js +43 -0
  50. package/dist/runtime/call-admission.js +67 -0
  51. package/dist/runtime/connector-scope.js +72 -0
  52. package/dist/runtime/run.js +174 -0
  53. package/dist/runtime/services.js +130 -0
  54. package/dist/runtime/storage.js +66 -0
  55. package/dist/server.js +111 -89
  56. package/dist/skills.js +1 -1
  57. package/dist/storage/file.js +39 -2
  58. package/dist/storage/memory.js +28 -16
  59. package/dist/timeout.js +17 -47
  60. package/dist/types.d.ts +27 -7
  61. package/dist/ui.d.ts +16 -2
  62. package/dist/ui.js +50 -166
  63. package/dist/url-safety.js +157 -0
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.js +1 -1
  66. package/documentation/architecture.md +184 -10
  67. package/documentation/auth.md +80 -0
  68. package/documentation/code-mode.md +69 -24
  69. package/documentation/meta-tools.md +164 -27
  70. package/ethos.md +44 -47
  71. package/examples/worker/README.md +51 -5
  72. package/examples/worker/src/d1-storage.ts +116 -0
  73. package/examples/worker/src/index.ts +6 -6
  74. package/examples/worker/wrangler.jsonc +16 -4
  75. package/package.json +11 -3
  76. package/templates/node/package.json +1 -1
  77. package/dist/auth/downstream-oauth.d.ts +0 -200
  78. package/dist/catalog-fingerprint.d.ts +0 -19
  79. package/dist/catalog-limits.d.ts +0 -9
  80. package/dist/catalog-service.d.ts +0 -263
  81. package/dist/catalog.d.ts +0 -87
  82. package/dist/concurrency.d.ts +0 -3
  83. package/dist/connectors/guarded-fetch.d.ts +0 -96
  84. package/dist/credential-rules.d.ts +0 -85
  85. package/dist/execute.d.ts +0 -154
  86. package/dist/executor-result.d.ts +0 -10
  87. package/dist/executors/quickjs-child.d.ts +0 -1
  88. package/dist/executors/quickjs-protocol.d.ts +0 -67
  89. package/dist/favicon.d.ts +0 -1
  90. package/dist/identity.d.ts +0 -4
  91. package/dist/invocation.d.ts +0 -74
  92. package/dist/mcp-result.d.ts +0 -8
  93. package/dist/meta-tools.d.ts +0 -129
  94. package/dist/operator-ui/generated.d.ts +0 -2
  95. package/dist/operator-ui/view.d.ts +0 -188
  96. package/dist/result-shapes.d.ts +0 -13
  97. package/dist/retired-toolkits.d.ts +0 -2
  98. package/dist/routes/activity.d.ts +0 -2
  99. package/dist/routes/credentials.d.ts +0 -2
  100. package/dist/routes/mcp.d.ts +0 -10
  101. package/dist/routes/oauth-management.d.ts +0 -2
  102. package/dist/routes/oauth.d.ts +0 -2
  103. package/dist/routes/ui.d.ts +0 -2
  104. package/dist/server.d.ts +0 -9
  105. package/dist/skills.d.ts +0 -44
  106. package/dist/timeout.d.ts +0 -21
  107. package/dist/tool-safety.d.ts +0 -9
@@ -1,10 +1,12 @@
1
1
  import { createMcpHandler, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server";
2
+ import { Effect, Exit, Result, Scope } from "effect";
2
3
  import { registerExecuteTool } from "../execute.js";
3
4
  import { ExecutorAdmissionError, } from "../executor-admission.js";
4
5
  import { registerMetaTools } from "../meta-tools.js";
5
6
  import { intersectAccess } from "../connector-access.js";
6
7
  import { instructionsFor } from "../skills.js";
7
8
  import { msg } from "../errors.js";
9
+ import { detach } from "../runtime/run.js";
8
10
  import { authorize, loggableValue, mayManageConnector, validateAuthPermissions, } from "./shared.js";
9
11
  export const MCP_CORS_HEADERS = {
10
12
  "Access-Control-Allow-Origin": "*",
@@ -73,11 +75,16 @@ function requestAdmissionFailure(error) {
73
75
  }), { status: 503, headers });
74
76
  }
75
77
  /**
76
- * A request owns its permit through the response body, not merely until the
77
- * handler returns. This is what makes slow clients and response-stream failure
78
- * part of the same bounded lifecycle as success, error, and cancellation.
78
+ * Hand a response the Scope that holds what its request acquired.
79
+ *
80
+ * A request owns its permit and its McpServer through the response body, not
81
+ * merely until the handler returns. `close` runs once, on whichever comes
82
+ * first: the body is read to its end or fails, its reader cancels it, or the
83
+ * request's signal aborts. This is what makes slow clients and response-stream
84
+ * failure part of the same bounded lifecycle as success, error, and
85
+ * cancellation.
79
86
  */
80
- function releaseAdmissionWithResponse(response, lease, signal) {
87
+ function closeWithBody(response, signal, close) {
81
88
  let released = false;
82
89
  let onAbort = () => { };
83
90
  const release = () => {
@@ -85,7 +92,7 @@ function releaseAdmissionWithResponse(response, lease, signal) {
85
92
  return;
86
93
  released = true;
87
94
  signal.removeEventListener("abort", onAbort);
88
- lease.release();
95
+ close();
89
96
  };
90
97
  if (!response.body) {
91
98
  release();
@@ -133,6 +140,52 @@ function releaseAdmissionWithResponse(response, lease, signal) {
133
140
  headers: response.headers,
134
141
  });
135
142
  }
143
+ /**
144
+ * Run a request's handler in a Scope that outlives it.
145
+ *
146
+ * The handler acquires into the scope and its response carries the scope
147
+ * out, for `closeWithBody` to close when the body ends. A handler that fails,
148
+ * or is interrupted because the request's signal aborted, closes it at once:
149
+ * nothing it acquired waits on a body that will never be read.
150
+ */
151
+ function scopedToBody(handler, signal) {
152
+ return Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
153
+ const scope = yield* Scope.make();
154
+ const exit = yield* Effect.exit(restore(Scope.provide(scope)(handler)));
155
+ if (Exit.isFailure(exit)) {
156
+ yield* Scope.close(scope, exit);
157
+ return yield* exit;
158
+ }
159
+ // Every finalizer in the scope is synchronous, so the close has run by
160
+ // the time detach returns: the permit is back, and handed to the next
161
+ // queued request, inside the read or cancel that ended the body.
162
+ return closeWithBody(exit.value, signal, () => {
163
+ void detach(Scope.close(scope, Exit.void));
164
+ });
165
+ }));
166
+ }
167
+ /**
168
+ * The request's admission permit, owned by its Scope.
169
+ *
170
+ * Admission goes through the controller's Promise rather than its Effect
171
+ * program (`acquireScoped`), on purpose. The pool is shared by every request
172
+ * in the isolate, and a queued request is handed its permit from inside the
173
+ * request whose body just ended. A fiber resumed there would authorize and
174
+ * serve this request in that one's I/O context, which workerd refuses;
175
+ * awaiting `acquire()`'s promise resumes it in its own.
176
+ *
177
+ * The wait stays interruptible, and a permit granted just as it was
178
+ * interrupted still arrives, later, to be released on arrival.
179
+ */
180
+ function admitted(controller, signal) {
181
+ return Effect.acquireRelease(Effect.suspend(() => {
182
+ const pending = controller.acquire({ signal });
183
+ return Effect.tryPromise({ try: () => pending, catch: (error) => error })
184
+ .pipe(Effect.onInterrupt(() => Effect.sync(() => {
185
+ pending.then((lease) => lease.release(), () => { });
186
+ })));
187
+ }), (lease) => Effect.sync(() => lease.release()), { interruptible: true });
188
+ }
136
189
  /**
137
190
  * 404 for any `?toolkit=` value, kept after the feature's retirement (#178).
138
191
  *
@@ -167,7 +220,11 @@ function toolkitRetired(logger) {
167
220
  },
168
221
  });
169
222
  }
170
- async function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtimeContext) {
223
+ function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtimeContext) {
224
+ // Every McpServer the request builds is fresh and closes with its scope.
225
+ // The modern handler tears its own down after the exchange; the legacy
226
+ // transport never does, and neither may stay wired to an ended request.
227
+ const servers = [];
171
228
  const createServer = () => {
172
229
  const server = new McpServer(opts.serverInfo, {
173
230
  instructions: instructionsFor(),
@@ -240,26 +297,36 @@ async function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth,
240
297
  ...(opts.hostCallTimeoutMs !== undefined
241
298
  ? { hostCallTimeoutMs: opts.hostCallTimeoutMs }
242
299
  : {}),
300
+ ...(opts.watchdogMs !== undefined
301
+ ? { watchdogMs: opts.watchdogMs }
302
+ : {}),
243
303
  });
304
+ servers.push(server);
244
305
  return server;
245
306
  };
246
- // The v2 entry's built-in legacy fallback streams 2025 results as SSE.
247
- // Connecta's established wire contract is JSON, so retain the documented
248
- // user-land legacy branch with the same transport setting while the modern
249
- // branch uses the fetch-native handler.
250
- if (!(await isLegacyRequest(request))) {
251
- return createMcpHandler(createServer, {
252
- legacy: "reject",
253
- onerror: (error) => opts.logger.error("[connecta] MCP handler error", error),
254
- }).fetch(request);
255
- }
256
- // Fresh server + transport per legacy request, stateless and JSON-shaped.
257
- const server = createServer();
258
- const transport = new WebStandardStreamableHTTPServerTransport({
259
- enableJsonResponse: true,
260
- });
261
- await server.connect(transport);
262
- return transport.handleRequest(request);
307
+ const exchange = async () => {
308
+ // The v2 entry's built-in legacy fallback streams 2025 results as SSE.
309
+ // Connecta's established wire contract is JSON, so retain the documented
310
+ // user-land legacy branch with the same transport setting while the modern
311
+ // branch uses the fetch-native handler.
312
+ if (!(await isLegacyRequest(request))) {
313
+ return createMcpHandler(createServer, {
314
+ legacy: "reject",
315
+ onerror: (error) => opts.logger.error("[connecta] MCP handler error", error),
316
+ }).fetch(request);
317
+ }
318
+ // Fresh server + transport per legacy request, stateless and JSON-shaped.
319
+ const server = createServer();
320
+ const transport = new WebStandardStreamableHTTPServerTransport({
321
+ enableJsonResponse: true,
322
+ });
323
+ await server.connect(transport);
324
+ return transport.handleRequest(request);
325
+ };
326
+ return Effect.addFinalizer(() => Effect.sync(() => {
327
+ for (const server of servers)
328
+ void server.close().catch(() => { });
329
+ })).pipe(Effect.andThen(Effect.promise(exchange)));
263
330
  }
264
331
  export function createMcpRoute(opts) {
265
332
  const configuredOrigins = opts.allowedOrigins;
@@ -322,10 +389,10 @@ export function createMcpRoute(opts) {
322
389
  lastAdmissionWarningAt = now;
323
390
  suppressedAdmissionWarnings = 0;
324
391
  };
325
- async function routeMcp(context) {
392
+ function routeMcp(context) {
326
393
  const { path, request, baseUrl, runtimeContext, } = context;
327
394
  if (path !== "/mcp" && !path.startsWith("/mcp/"))
328
- return null;
395
+ return Effect.succeed(null);
329
396
  const poolName = path === "/mcp" ? undefined : path.slice("/mcp/".length);
330
397
  const origin = request.headers.get("Origin");
331
398
  const allowed = origin === null || allowsOrigin(origin);
@@ -334,40 +401,39 @@ export function createMcpRoute(opts) {
334
401
  // local header check also guards OPTIONS before any provider metadata.
335
402
  const refusal = rejectOrigin(request);
336
403
  if (refusal)
337
- return refusal;
338
- if (request.method === "OPTIONS")
339
- return cors(new Response(null, { status: 204 }));
340
- let admission;
341
- try {
342
- admission = await opts.requestAdmission.acquire({
343
- signal: request.signal,
344
- });
345
- if (admission.waitMs > 0) {
346
- opts.logger.debug("[connecta] MCP request admitted after queue wait", {
347
- waitMs: admission.waitMs,
348
- active: opts.requestAdmission.activeCount,
349
- queued: opts.requestAdmission.queuedCount,
350
- });
351
- }
404
+ return Effect.succeed(refusal);
405
+ if (request.method === "OPTIONS") {
406
+ return Effect.succeed(cors(new Response(null, { status: 204 })));
352
407
  }
353
- catch (error) {
354
- if (error instanceof ExecutorAdmissionError &&
355
- error.code === "executor_cancelled") {
356
- throw request.signal.reason ?? error;
357
- }
358
- if (error instanceof ExecutorAdmissionError) {
408
+ return scopedToBody(Effect.gen(function* () {
409
+ const admission = yield* Effect.result(admitted(opts.requestAdmission, request.signal));
410
+ if (Result.isFailure(admission)) {
411
+ const error = admission.failure;
412
+ if (!(error instanceof ExecutorAdmissionError)) {
413
+ return yield* Effect.fail(error);
414
+ }
415
+ if (error.code === "executor_cancelled") {
416
+ return yield* Effect.fail(request.signal.reason ?? error);
417
+ }
359
418
  if (error.code === "executor_overloaded") {
360
419
  warnAdmissionRejected(error);
361
420
  }
362
421
  return cors(requestAdmissionFailure(error));
363
422
  }
364
- throw error;
365
- }
366
- try {
367
- const authz = await authorize(request, baseUrl, opts.auth, runtimeContext, opts.identity);
368
- if (!authz.ok) {
369
- return releaseAdmissionWithResponse(cors(authz.response), admission, request.signal);
423
+ if (admission.success.waitMs > 0) {
424
+ opts.logger.debug("[connecta] MCP request admitted after queue wait", {
425
+ waitMs: admission.success.waitMs,
426
+ active: opts.requestAdmission.activeCount,
427
+ queued: opts.requestAdmission.queuedCount,
428
+ });
370
429
  }
430
+ // Promise code the fiber stops waiting on when the caller leaves. An
431
+ // Effect `authorize` would put Effect in the /activity bundle, which
432
+ // shares it, to save only the lookups an abandoned authorization
433
+ // finishes on its own.
434
+ const authz = yield* Effect.promise(() => authorize(request, baseUrl, opts.auth, runtimeContext, opts.identity));
435
+ if (!authz.ok)
436
+ return cors(authz.response);
371
437
  // A pool endpoint narrows the identity's own view and nothing else. An
372
438
  // undeclared name, a grant that refuses, and a grant that throws are
373
439
  // one identical 404 so a credential never enumerates the other pools;
@@ -375,21 +441,18 @@ export function createMcpRoute(opts) {
375
441
  let access = authz;
376
442
  if (poolName !== undefined) {
377
443
  const pool = opts.pools?.get(poolName);
378
- let granted = false;
379
- let reason = "undeclared";
380
- if (pool) {
444
+ const reason = !pool ? "undeclared" : yield* Effect.promise(async () => {
381
445
  try {
382
- granted = (await pool.grant(authz.identity)) === true;
383
- reason = granted ? "granted" : "refused";
446
+ return (await pool.grant(authz.identity)) === true ? "granted" : "refused";
384
447
  }
385
448
  catch {
386
- reason = "grant threw";
449
+ return "grant threw";
387
450
  }
388
- }
389
- if (!pool || !granted) {
451
+ });
452
+ if (!pool || reason !== "granted") {
390
453
  opts.logger.warn(`[connecta] refused /mcp/${poolName} with 404: pool ${reason}` +
391
454
  (authz.actor.id ? ` for ${loggableValue(authz.actor.id)}` : ""));
392
- return releaseAdmissionWithResponse(cors(new Response("Not Found", { status: 404 })), admission, request.signal);
455
+ return cors(new Response("Not Found", { status: 404 }));
393
456
  }
394
457
  access = intersectAccess(authz, pool.access);
395
458
  }
@@ -404,20 +467,16 @@ export function createMcpRoute(opts) {
404
467
  });
405
468
  }
406
469
  catch (error) {
407
- return releaseAdmissionWithResponse(cors(new Response(JSON.stringify({ error: msg(error) }), {
470
+ return cors(new Response(JSON.stringify({ error: msg(error) }), {
408
471
  status: 403,
409
472
  headers: { "Content-Type": "application/json" },
410
- })), admission, request.signal);
473
+ }));
411
474
  }
412
475
  if (new URL(request.url).searchParams.has("toolkit")) {
413
- return releaseAdmissionWithResponse(cors(toolkitRetired(opts.logger)), admission, request.signal);
476
+ return cors(toolkitRetired(opts.logger));
414
477
  }
415
- return releaseAdmissionWithResponse(cors(await serveMcp(request, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); }, runtimeContext)), admission, request.signal);
416
- }
417
- catch (error) {
418
- admission.release();
419
- throw error;
420
- }
478
+ return cors(yield* serveMcp(request, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); }, runtimeContext));
479
+ }), request.signal);
421
480
  }
422
481
  return { handle: routeMcp, rejectOrigin };
423
482
  }
@@ -1,70 +1,63 @@
1
- import { closeConnectorScope } from "../connector-scope.js";
1
+ import { Effect, Result } from "effect";
2
+ import { closeScope } from "../runtime/connector-scope.js";
2
3
  import { isSafeHttpUrl } from "../ui.js";
3
- import { authorizeUiIdentity, mayManageConnector, validateAuthPermissions, isSameOrigin, msg, privateJson, } from "./shared.js";
4
- async function handleOAuthManagementRequest(context, connectorId) {
5
- const { request, baseUrl, opts, defer } = context;
6
- if (!isSameOrigin(request, baseUrl)) {
7
- return privateJson({ error: "same-origin request required" }, { status: 403 });
8
- }
9
- const authz = await authorizeUiIdentity(request, baseUrl, opts.auth, "OAuth management", context.runtimeContext, opts.identity);
10
- if (!authz.ok)
11
- return authz.response;
12
- let registry;
13
- try {
14
- validateAuthPermissions(authz, opts.registry);
15
- registry = opts.registry.scoped({
16
- connectorIds: authz.connectorIds,
17
- ...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
18
- ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
19
- ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
20
- });
21
- }
22
- catch (error) {
23
- return privateJson({ error: msg(error) }, { status: 403 });
24
- }
25
- const connector = registry.getConnector(connectorId);
26
- if (!connector?.disconnectAuth || !connector.startAuth) {
27
- return privateJson({ error: "unknown OAuth connector" }, { status: 404 });
28
- }
29
- if (!mayManageConnector(authz, connector))
30
- return privateJson({ error: "credential management is not permitted" }, { status: 403 });
31
- if (connector.authScope === "personal" && !authz.principalKey) {
32
- return privateJson({ error: "forbidden" }, { status: 403 });
33
- }
34
- if (request.method !== "DELETE" && request.method !== "POST") {
35
- return privateJson({ error: "method not allowed" }, { status: 405 });
36
- }
37
- const requestScope = {};
38
- const ctx = registry.contextFor(connectorId, baseUrl, requestScope);
39
- try {
40
- let result;
41
- let operationError;
42
- try {
43
- if (request.method === "DELETE") {
44
- await connector.disconnectAuth(ctx);
45
- }
46
- else {
47
- result = await connector.startAuth(ctx, { force: true });
48
- if (result.authorizationUrl) {
49
- await registry.bindOAuthHandoff(connectorId, result.authorizationUrl);
50
- }
51
- }
4
+ import { authorizedPerson, refuse, serveOperator, visibleRegistry, } from "./operator.js";
5
+ import { mayManageConnector, isSameOrigin, msg, privateJson, } from "./shared.js";
6
+ function oauthManagementRequest(context, connectorId) {
7
+ const { request, baseUrl, defer } = context;
8
+ return Effect.gen(function* () {
9
+ if (!isSameOrigin(request, baseUrl)) {
10
+ return yield* refuse("same-origin request required", 403);
52
11
  }
53
- catch (error) {
54
- operationError = error;
12
+ const authz = yield* authorizedPerson(context, "OAuth management");
13
+ const registry = yield* visibleRegistry(context, authz);
14
+ const connector = registry.getConnector(connectorId);
15
+ const disconnectAuth = connector?.disconnectAuth?.bind(connector);
16
+ const startAuth = connector?.startAuth?.bind(connector);
17
+ if (!connector || !disconnectAuth || !startAuth) {
18
+ return yield* refuse("unknown OAuth connector", 404);
55
19
  }
56
- // The old grant and its cached catalog are invalid after either operation,
57
- // including a partially failed physical cleanup whose epoch fence succeeded.
58
- try {
59
- await registry.invalidateStored(connectorId);
20
+ if (!mayManageConnector(authz, connector)) {
21
+ return yield* refuse("credential management is not permitted", 403);
60
22
  }
61
- catch (error) {
62
- operationError ??= error;
23
+ if (connector.authScope === "personal" && !authz.principalKey) {
24
+ return yield* refuse("forbidden", 403);
63
25
  }
64
- if (operationError) {
65
- return privateJson({ error: msg(operationError) }, { status: 400 });
26
+ const disconnecting = request.method === "DELETE";
27
+ if (!disconnecting && request.method !== "POST") {
28
+ return yield* refuse("method not allowed", 405);
66
29
  }
67
- if (request.method === "DELETE") {
30
+ const ctx = registry.contextFor(connectorId, baseUrl, {});
31
+ // The connector scope this request opened ends with it, however it ends.
32
+ yield* Effect.addFinalizer(() => closeScope(connector, ctx, defer));
33
+ const operation = yield* Effect.result(Effect.tryPromise({
34
+ try: async () => {
35
+ if (disconnecting) {
36
+ await disconnectAuth(ctx);
37
+ return undefined;
38
+ }
39
+ const started = await startAuth(ctx, { force: true });
40
+ if (started.authorizationUrl) {
41
+ await registry.bindOAuthHandoff(connectorId, started.authorizationUrl);
42
+ }
43
+ return started;
44
+ },
45
+ catch: (error) => error,
46
+ }));
47
+ // The old grant and its cached catalog are invalid after either operation,
48
+ // including a partially failed physical cleanup whose epoch fence succeeded.
49
+ const invalidated = yield* Effect.result(Effect.tryPromise({
50
+ try: () => registry.invalidateStored(connectorId),
51
+ catch: (error) => error,
52
+ }));
53
+ // A Result, not a caught value, decides whether it failed: a hook that
54
+ // rejects with no reason at all still failed.
55
+ if (Result.isFailure(operation))
56
+ return yield* refuse(msg(operation.failure), 400);
57
+ if (Result.isFailure(invalidated))
58
+ return yield* refuse(msg(invalidated.failure), 400);
59
+ const result = operation.success;
60
+ if (!result) {
68
61
  return new Response(null, {
69
62
  status: 204,
70
63
  headers: {
@@ -77,23 +70,18 @@ async function handleOAuthManagementRequest(context, connectorId) {
77
70
  ? result.authorizationUrl
78
71
  : undefined;
79
72
  if (result.state === "error") {
80
- return privateJson({ error: result.message || "OAuth authorization could not start" }, { status: 502 });
73
+ return yield* refuse(result.message || "OAuth authorization could not start", 502);
81
74
  }
82
75
  if (result.state === "auth_required" && !authorizationUrl) {
83
- return privateJson({
84
- error: result.message ||
85
- "OAuth authorization requires consent but no safe URL is available",
86
- }, { status: 502 });
76
+ return yield* refuse(result.message ||
77
+ "OAuth authorization requires consent but no safe URL is available", 502);
87
78
  }
88
79
  return privateJson({
89
80
  state: result.state,
90
81
  ...(result.message ? { message: result.message } : {}),
91
82
  ...(authorizationUrl ? { authorizationUrl } : {}),
92
83
  });
93
- }
94
- finally {
95
- await closeConnectorScope(connector, ctx, defer);
96
- }
84
+ }).pipe(Effect.scoped);
97
85
  }
98
86
  export async function routeOAuthManagement(context) {
99
87
  const match = /^\/ui\/oauth\/([a-z0-9_-]+)$/.exec(context.path);
@@ -105,5 +93,6 @@ export async function routeOAuthManagement(context) {
105
93
  if (context.request.method === "OPTIONS") {
106
94
  return privateJson({ error: "method not allowed" }, { status: 405 });
107
95
  }
108
- return handleOAuthManagementRequest(context, connectorId);
96
+ // No signal: a disconnect that has started runs through to its invalidation.
97
+ return serveOperator(oauthManagementRequest(context, connectorId));
109
98
  }
@@ -1,5 +1,7 @@
1
+ import { Effect } from "effect";
1
2
  import { oauthValueStorageKey } from "../auth/downstream-oauth.js";
2
3
  import { resolveBranding } from "../branding.js";
4
+ import { oauthCallbackOutcome, providerErrorReason, } from "../oauth-callback-outcome.js";
3
5
  import { authorizeUiIdentity, mayManageConnector, validateAuthPermissions, loggableValue, msg, } from "./shared.js";
4
6
  function escapeHtml(value) {
5
7
  return value
@@ -8,8 +10,14 @@ function escapeHtml(value) {
8
10
  .replaceAll(">", ">")
9
11
  .replaceAll('"', """);
10
12
  }
11
- /** `body` is escaped — callback params and error messages are attacker-influenced. */
12
- function html(body, status = 200, branding, hasUi = false) {
13
+ /**
14
+ * The callback's one page. It renders a reason from a closed set and that
15
+ * reason's fixed copy, so nothing the request carried — the provider's `error`
16
+ * parameter, an exchange failure's message — can reach the body. Escaping
17
+ * stays anyway: branding and the configured connector id are still strings.
18
+ */
19
+ function html(reason, branding, hasUi = false, connectorId) {
20
+ const outcome = oauthCallbackOutcome(reason, connectorId);
13
21
  const brand = resolveBranding(branding);
14
22
  const title = brand.pageTitle;
15
23
  const owner = brand.ownerName
@@ -52,7 +60,10 @@ function html(body, status = 200, branding, hasUi = false) {
52
60
  a { color: inherit; text-decoration: underline; text-decoration-thickness: 1.5px;
53
61
  text-underline-offset: .22em; }
54
62
  a:hover { text-decoration-color: transparent; }
55
- a:focus-visible { outline: 1px solid #000; outline-offset: 2px; }
63
+ a:focus-visible, summary:focus-visible { outline: 1px solid #000; outline-offset: 2px; }
64
+ summary { cursor: pointer; }
65
+ pre { font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace; margin: .75rem 0 0;
66
+ padding: .75rem; white-space: pre-wrap; border: 1px solid #000; user-select: all; }
56
67
  @media (max-width: 36.99rem) {
57
68
  .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
58
69
  .product { grid-column: 2; }
@@ -66,15 +77,16 @@ function html(body, status = 200, branding, hasUi = false) {
66
77
  ${owner}
67
78
  ${product}
68
79
  </header>
69
- <main class="shell grid">
80
+ <main class="shell grid" data-oauth-callback="${outcome.reason}">
70
81
  <h1>Connection status</h1>
71
82
  <div class="copy">
72
- <p>${escapeHtml(body)}</p>
83
+ <p>${escapeHtml(outcome.message)}</p>
84
+ ${outcome.fixPrompt ? `<details><summary>Fix prompt for a coding agent</summary><pre>${escapeHtml(outcome.fixPrompt)}</pre></details>` : ""}
73
85
  ${hasUi ? `<p><a href="/">Return to ${escapeHtml(brand.productName)}</a></p>` : ""}
74
86
  </div>
75
87
  </main>
76
88
  </body>
77
- </html>`, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
89
+ </html>`, { status: outcome.status, headers: { "Content-Type": "text/html; charset=utf-8" } });
78
90
  }
79
91
  /**
80
92
  * Pay the storage read a real downstream-OAuth refusal pays, on the refusal
@@ -107,16 +119,29 @@ async function equalizeRefusalCost(context) {
107
119
  // Deliberately ignored — see above.
108
120
  }
109
121
  }
110
- export async function routeOAuthCallback(context) {
122
+ /**
123
+ * The callback route as a step of the request's fiber, and the one step that
124
+ * the request's signal does not interrupt.
125
+ *
126
+ * An authorization code is single-use. Once a browser has delivered one, the
127
+ * handoff it consumes, the exchange, and the catalog invalidation after it are
128
+ * one commitment: abandoned halfway, a connector could hold fresh tokens
129
+ * behind a catalog still cached as unauthorized, or a consumed handoff with no
130
+ * exchange behind it. A caller that hangs up mid-exchange loses only the page.
131
+ */
132
+ export function routeOAuthCallback(context) {
133
+ if (!context.path.startsWith("/oauth/callback/"))
134
+ return Effect.succeed(null);
135
+ return Effect.uninterruptible(Effect.promise(() => finishOAuthCallback(context)));
136
+ }
137
+ async function finishOAuthCallback(context) {
111
138
  const { path, url, baseUrl, opts } = context;
112
- if (!path.startsWith("/oauth/callback/"))
113
- return null;
114
139
  const error = url.searchParams.get("error");
115
140
  if (error)
116
- return html(`Authorization denied: ${error}`, 400, opts.branding, Boolean(opts.ui));
141
+ return html(providerErrorReason(error), opts.branding, Boolean(opts.ui));
117
142
  const code = url.searchParams.get("code");
118
143
  if (!code)
119
- return html("Missing authorization code.", 400, opts.branding, Boolean(opts.ui));
144
+ return html("invalid_callback", opts.branding, Boolean(opts.ui));
120
145
  const id = path.slice("/oauth/callback/".length);
121
146
  const state = url.searchParams.get("state");
122
147
  const callbackTarget = await opts.registry.oauthCallbackView(id, state);
@@ -130,8 +155,7 @@ export async function routeOAuthCallback(context) {
130
155
  const connectorContext = callbackRegistry
131
156
  ? callbackRegistry.contextFor(id, baseUrl)
132
157
  : opts.registry.contextFor(id, baseUrl);
133
- const refused = () => html("Authorization could not be completed. Re-run authorization from " +
134
- "connecta and try again.", 400, opts.branding, Boolean(opts.ui));
158
+ const refused = () => html("invalid_callback", opts.branding, Boolean(opts.ui));
135
159
  if (!connector || !connector.finishAuth) {
136
160
  await equalizeRefusalCost(connectorContext);
137
161
  return refused();
@@ -196,15 +220,19 @@ export async function routeOAuthCallback(context) {
196
220
  opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
197
221
  `${loggableValue(id)} with 500: its principal handoff could not be ` +
198
222
  `consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`);
199
- return html("Authorization could not be completed.", 500, opts.branding, Boolean(opts.ui));
223
+ return html("handoff_failed", opts.branding, Boolean(opts.ui), id);
200
224
  }
201
225
  }
202
226
  try {
203
227
  await connector.finishAuth(code, connectorContext, url.searchParams);
204
228
  await callbackRegistry.invalidateStored(id);
205
- return html(`Connected "${id}". You can close this window.`, 200, opts.branding, Boolean(opts.ui));
229
+ return html("connected", opts.branding, Boolean(opts.ui), id);
206
230
  }
207
231
  catch (err) {
208
- return html(`Authorization failed: ${msg(err)}`, 500, opts.branding, Boolean(opts.ui));
232
+ // The page names the reason and nothing else; what the exchange threw can
233
+ // quote a token endpoint's body, so it goes only to the operator log.
234
+ opts.logger.warn(`[connecta] OAuth callback for connector ${loggableValue(id)} failed ` +
235
+ `with 500: the authorization code exchange threw ${loggableValue(msg(err))}.`);
236
+ return html("exchange_failed", opts.branding, Boolean(opts.ui), id);
209
237
  }
210
238
  }