@zackbart/connecta 0.24.3 → 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.
- package/AGENTS.md +58 -30
- package/CHANGELOG.md +317 -1
- package/README.md +5 -6
- package/dist/advertised-schema.js +53 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/branding.d.ts +31 -2
- package/dist/branding.js +116 -8
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +141 -105
- package/dist/catalog.js +258 -33
- package/dist/concurrency.js +24 -23
- package/dist/connector-scope.js +6 -37
- package/dist/connectors/api.d.ts +3 -3
- package/dist/connectors/api.js +10 -2
- package/dist/connectors/guarded-fetch.js +34 -4
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +325 -245
- package/dist/credential-contract.d.ts +9 -0
- package/dist/credential-rules.js +2 -2
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +45 -3
- package/dist/errors.d.ts +11 -3
- package/dist/errors.js +2 -1
- package/dist/execute.js +363 -257
- package/dist/executor-admission.js +67 -50
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +251 -210
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.js +63 -21
- package/dist/invocation.js +324 -285
- package/dist/meta-tools.js +304 -114
- package/dist/oauth-callback-outcome.js +94 -0
- package/dist/oauth-sealing.js +29 -0
- package/dist/operator-ui/fix-prompts.js +87 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +46 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +109 -2
- package/dist/program-source.js +205 -0
- package/dist/providers/cloudflare.d.ts +14 -2
- package/dist/providers/cloudflare.js +107 -16
- package/dist/providers/linear.d.ts +26 -4
- package/dist/providers/linear.js +19 -4
- package/dist/providers/mixpanel.d.ts +16 -3
- package/dist/providers/mixpanel.js +13 -2
- package/dist/providers/notion.d.ts +8 -1
- package/dist/providers/notion.js +85 -12
- package/dist/providers/revenuecat.d.ts +30 -4
- package/dist/providers/revenuecat.js +70 -5
- package/dist/providers/stripe.d.ts +10 -3
- package/dist/providers/stripe.js +30 -4
- package/dist/providers/vercel.js +12 -2
- package/dist/registry.d.ts +13 -5
- package/dist/registry.js +317 -258
- package/dist/routes/activity.js +120 -137
- package/dist/routes/credentials.js +174 -163
- package/dist/routes/mcp.js +129 -70
- package/dist/routes/oauth-management.js +60 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +82 -0
- package/dist/routes/shared.d.ts +2 -0
- package/dist/routes/ui-data.js +210 -0
- package/dist/routes/ui.js +64 -32
- package/dist/runtime/admission.js +43 -0
- package/dist/runtime/call-admission.js +67 -0
- package/dist/runtime/connector-scope.js +72 -0
- package/dist/runtime/run.js +174 -0
- package/dist/runtime/services.js +130 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +111 -89
- package/dist/skills.js +1 -1
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +17 -47
- package/dist/types.d.ts +64 -7
- package/dist/ui.d.ts +16 -2
- package/dist/ui.js +68 -176
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +368 -182
- package/documentation/auth.md +277 -176
- package/documentation/code-mode.md +479 -329
- package/documentation/meta-tools.md +494 -417
- package/ethos.md +44 -47
- package/examples/worker/AGENTS.md +2 -1
- package/examples/worker/README.md +62 -14
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +18 -21
- package/examples/worker/wrangler.jsonc +16 -4
- package/package.json +11 -4
- package/templates/node/.env.example +3 -3
- package/templates/node/AGENTS.md +5 -4
- package/templates/node/README.md +2 -1
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +23 -22
- package/dist/auth/downstream-oauth.d.ts +0 -200
- package/dist/catalog-fingerprint.d.ts +0 -19
- package/dist/catalog-limits.d.ts +0 -9
- package/dist/catalog-service.d.ts +0 -263
- package/dist/catalog.d.ts +0 -87
- package/dist/concurrency.d.ts +0 -3
- package/dist/connectors/guarded-fetch.d.ts +0 -92
- package/dist/credential-rules.d.ts +0 -85
- package/dist/execute.d.ts +0 -154
- package/dist/executor-result.d.ts +0 -10
- package/dist/executors/quickjs-child.d.ts +0 -1
- package/dist/executors/quickjs-protocol.d.ts +0 -67
- package/dist/favicon.d.ts +0 -1
- package/dist/identity.d.ts +0 -4
- package/dist/invocation.d.ts +0 -74
- package/dist/mcp-result.d.ts +0 -8
- package/dist/meta-tools.d.ts +0 -129
- package/dist/operator-ui/generated.d.ts +0 -2
- package/dist/operator-ui/view.d.ts +0 -151
- package/dist/result-shapes.d.ts +0 -13
- package/dist/retired-toolkits.d.ts +0 -2
- package/dist/routes/activity.d.ts +0 -2
- package/dist/routes/credentials.d.ts +0 -2
- package/dist/routes/mcp.d.ts +0 -10
- package/dist/routes/oauth-management.d.ts +0 -2
- package/dist/routes/oauth.d.ts +0 -2
- package/dist/routes/ui.d.ts +0 -2
- package/dist/server.d.ts +0 -9
- package/dist/skills.d.ts +0 -44
- package/dist/timeout.d.ts +0 -21
- package/dist/tool-safety.d.ts +0 -9
- package/documentation/call-admission.md +0 -158
- package/documentation/cloudflare.md +0 -471
- package/documentation/connector-guides.md +0 -176
- package/documentation/connectors.md +0 -431
- package/documentation/linear.md +0 -193
- package/documentation/mixpanel.md +0 -160
- package/documentation/notion.md +0 -308
- package/documentation/operations.md +0 -359
- package/documentation/operator-ui.md +0 -135
- package/documentation/optional-modules-upgrade.md +0 -243
- package/documentation/provider-conventions.md +0 -729
- package/documentation/request-admission.md +0 -204
- package/documentation/revenuecat.md +0 -305
- package/documentation/storage-and-credentials.md +0 -254
- package/documentation/stripe.md +0 -262
- package/documentation/upgrading.md +0 -768
- package/documentation/vercel.md +0 -241
package/dist/routes/mcp.js
CHANGED
|
@@ -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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
379
|
-
let reason = "undeclared";
|
|
380
|
-
if (pool) {
|
|
444
|
+
const reason = !pool ? "undeclared" : yield* Effect.promise(async () => {
|
|
381
445
|
try {
|
|
382
|
-
|
|
383
|
-
reason = granted ? "granted" : "refused";
|
|
446
|
+
return (await pool.grant(authz.identity)) === true ? "granted" : "refused";
|
|
384
447
|
}
|
|
385
448
|
catch {
|
|
386
|
-
|
|
449
|
+
return "grant threw";
|
|
387
450
|
}
|
|
388
|
-
}
|
|
389
|
-
if (!pool ||
|
|
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
|
|
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
|
|
470
|
+
return cors(new Response(JSON.stringify({ error: msg(error) }), {
|
|
408
471
|
status: 403,
|
|
409
472
|
headers: { "Content-Type": "application/json" },
|
|
410
|
-
}))
|
|
473
|
+
}));
|
|
411
474
|
}
|
|
412
475
|
if (new URL(request.url).searchParams.has("toolkit")) {
|
|
413
|
-
return
|
|
476
|
+
return cors(toolkitRetired(opts.logger));
|
|
414
477
|
}
|
|
415
|
-
return
|
|
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 {
|
|
1
|
+
import { Effect, Result } from "effect";
|
|
2
|
+
import { closeScope } from "../runtime/connector-scope.js";
|
|
2
3
|
import { isSafeHttpUrl } from "../ui.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
23
|
+
if (connector.authScope === "personal" && !authz.principalKey) {
|
|
24
|
+
return yield* refuse("forbidden", 403);
|
|
63
25
|
}
|
|
64
|
-
|
|
65
|
-
|
|
26
|
+
const disconnecting = request.method === "DELETE";
|
|
27
|
+
if (!disconnecting && request.method !== "POST") {
|
|
28
|
+
return yield* refuse("method not allowed", 405);
|
|
66
29
|
}
|
|
67
|
-
|
|
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
|
|
73
|
+
return yield* refuse(result.message || "OAuth authorization could not start", 502);
|
|
81
74
|
}
|
|
82
75
|
if (result.state === "auth_required" && !authorizationUrl) {
|
|
83
|
-
return
|
|
84
|
-
|
|
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
|
-
|
|
96
|
+
// No signal: a disconnect that has started runs through to its invalidation.
|
|
97
|
+
return serveOperator(oauthManagementRequest(context, connectorId));
|
|
109
98
|
}
|
package/dist/routes/oauth.js
CHANGED
|
@@ -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
|
-
/**
|
|
12
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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("
|
|
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("
|
|
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("
|
|
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(
|
|
229
|
+
return html("connected", opts.branding, Boolean(opts.ui), id);
|
|
206
230
|
}
|
|
207
231
|
catch (err) {
|
|
208
|
-
|
|
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
|
}
|