@zackbart/connecta 0.26.0 → 0.26.2

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 (47) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/README.md +11 -0
  3. package/dist/access-tokens.d.ts +38 -0
  4. package/dist/access-tokens.js +294 -0
  5. package/dist/artifacts/document.js +6 -2
  6. package/dist/artifacts/markdown.js +43 -14
  7. package/dist/artifacts/routes.js +27 -4
  8. package/dist/artifacts/validate.js +1 -0
  9. package/dist/auth/downstream-oauth.js +397 -48
  10. package/dist/auth/oauth-start-reset.js +20 -0
  11. package/dist/branding.d.ts +87 -0
  12. package/dist/branding.js +170 -1
  13. package/dist/connectors/remote-mcp.js +32 -7
  14. package/dist/execute.js +7 -8
  15. package/dist/identity.js +2 -0
  16. package/dist/index.d.ts +6 -2
  17. package/dist/index.js +13 -2
  18. package/dist/module-contracts.d.ts +5 -0
  19. package/dist/oauth-callback-outcome.js +43 -18
  20. package/dist/operator-ui/generated.js +2 -2
  21. package/dist/operator-ui/model.d.ts +1 -0
  22. package/dist/operator-ui/view.js +304 -33
  23. package/dist/page-styles.js +7 -0
  24. package/dist/registry.js +16 -13
  25. package/dist/routes/access-tokens.js +110 -0
  26. package/dist/routes/mcp.js +5 -1
  27. package/dist/routes/oauth-management.js +66 -12
  28. package/dist/routes/oauth.js +54 -87
  29. package/dist/routes/shared.d.ts +3 -1
  30. package/dist/routes/shared.js +6 -1
  31. package/dist/routes/ui.js +9 -1
  32. package/dist/server.js +8 -5
  33. package/dist/skills.js +6 -6
  34. package/dist/types.d.ts +5 -0
  35. package/dist/ui.d.ts +6 -1
  36. package/dist/ui.js +27 -64
  37. package/dist/version.d.ts +1 -1
  38. package/dist/version.js +1 -1
  39. package/documentation/architecture.md +19 -9
  40. package/documentation/auth.md +166 -2
  41. package/documentation/code-mode.md +15 -15
  42. package/documentation/meta-tools.md +12 -5
  43. package/ethos.md +1 -1
  44. package/package.json +5 -1
  45. package/templates/node/README.md +11 -0
  46. package/templates/node/package.json +1 -1
  47. package/templates/node/src/index.ts +5 -1
@@ -0,0 +1,110 @@
1
+ import { authorizeUiIdentity, isSameOrigin, privateJson, } from "./shared.js";
2
+ async function readName(request) {
3
+ if (!request.headers
4
+ .get("content-type")
5
+ ?.toLowerCase()
6
+ .startsWith("application/json")) {
7
+ return {
8
+ ok: false,
9
+ response: privateJson({ error: "Content-Type must be application/json" }, { status: 415 }),
10
+ };
11
+ }
12
+ const reader = request.body?.getReader();
13
+ const chunks = [];
14
+ let size = 0;
15
+ if (reader) {
16
+ try {
17
+ while (true) {
18
+ const { done, value } = await reader.read();
19
+ if (done)
20
+ break;
21
+ size += value.byteLength;
22
+ if (size > 1_000) {
23
+ reader.cancel().catch(() => { });
24
+ return { ok: false, response: privateJson({ error: "request body is too large" }, { status: 413 }) };
25
+ }
26
+ chunks.push(value);
27
+ }
28
+ }
29
+ finally {
30
+ reader.releaseLock();
31
+ }
32
+ }
33
+ const bytes = new Uint8Array(size);
34
+ let offset = 0;
35
+ for (const chunk of chunks) {
36
+ bytes.set(chunk, offset);
37
+ offset += chunk.byteLength;
38
+ }
39
+ const raw = new TextDecoder().decode(bytes);
40
+ if (raw.length > 1_000) {
41
+ return {
42
+ ok: false,
43
+ response: privateJson({ error: "request body is too large" }, { status: 413 }),
44
+ };
45
+ }
46
+ try {
47
+ const body = JSON.parse(raw);
48
+ return { ok: true, name: body.name };
49
+ }
50
+ catch {
51
+ return {
52
+ ok: false,
53
+ response: privateJson({ error: "invalid JSON body" }, { status: 400 }),
54
+ };
55
+ }
56
+ }
57
+ /**
58
+ * Interactive-operator lifecycle for deployment access tokens. The token itself
59
+ * is deliberately never an administrator credential and cannot reach here.
60
+ */
61
+ export async function routeAccessTokens(context, manager) {
62
+ const match = /^\/ui\/access-tokens(?:\/([0-9a-f-]{36}))?$/.exec(context.path);
63
+ if (!match)
64
+ return null;
65
+ const { request, baseUrl, opts } = context;
66
+ if (request.method === "OPTIONS") {
67
+ return privateJson({ error: "CORS is not allowed" }, { status: 403 });
68
+ }
69
+ const mutating = request.method !== "GET";
70
+ if (mutating && !isSameOrigin(request, baseUrl)) {
71
+ return privateJson({ error: "same-origin request required" }, { status: 403 });
72
+ }
73
+ const admin = await authorizeUiIdentity(request, baseUrl, opts.auth, "access token management", context.runtimeContext, opts.identity);
74
+ if (!admin.ok)
75
+ return admin.response;
76
+ if (!admin.accessTokenManagement || !admin.identity.principal) {
77
+ return privateJson({ error: "access token management permission required" }, { status: 403 });
78
+ }
79
+ const id = match[1];
80
+ try {
81
+ if (!id && request.method === "GET") {
82
+ return privateJson({ accessTokens: await manager.list() });
83
+ }
84
+ if (!id && request.method === "POST") {
85
+ const input = await readName(request);
86
+ if (!input.ok)
87
+ return input.response;
88
+ return privateJson(await manager.create(input.name, admin.identity.principal), { status: 201 });
89
+ }
90
+ if (id && request.method === "PUT") {
91
+ const input = await readName(request);
92
+ if (!input.ok)
93
+ return input.response;
94
+ const accessToken = await manager.rename(id, input.name);
95
+ return accessToken
96
+ ? privateJson({ accessToken })
97
+ : privateJson({ error: "unknown access token" }, { status: 404 });
98
+ }
99
+ if (id && request.method === "DELETE") {
100
+ const accessToken = await manager.revoke(id, `${admin.identity.principal.namespace}:${admin.identity.principal.id}`);
101
+ return accessToken
102
+ ? privateJson({ accessToken })
103
+ : privateJson({ error: "unknown access token" }, { status: 404 });
104
+ }
105
+ return privateJson({ error: "method not allowed" }, { status: 405 });
106
+ }
107
+ catch {
108
+ return privateJson({ error: "Access token operation failed; check the name, capacity, and storage" }, { status: 400 });
109
+ }
110
+ }
@@ -1,5 +1,6 @@
1
1
  import { createMcpHandler, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server";
2
2
  import { Duration, Effect, Exit, Option, Result, Scope } from "effect";
3
+ import { notFoundResponse } from "../branding.js";
3
4
  import { registerExecuteTool } from "../execute.js";
4
5
  import { ExecutorAdmissionError, } from "../executor-admission.js";
5
6
  import { registerMetaTools } from "../meta-tools.js";
@@ -495,7 +496,10 @@ export function createMcpRoute(opts) {
495
496
  if (!pool || reason !== "granted") {
496
497
  opts.logger.warn(`[connecta] refused /mcp/${poolName} with 404: pool ${reason}` +
497
498
  (authz.actor.id ? ` for ${loggableValue(authz.actor.id)}` : ""));
498
- return cors(new Response("Not Found", { status: 404 }));
499
+ // The server's own 404, so a browser sees the page every unserved
500
+ // path shows and an MCP client (which never asks for text/html)
501
+ // still reads a plain "Not Found".
502
+ return cors(notFoundResponse(request, opts));
499
503
  }
500
504
  access = intersectAccess(authz, pool.access);
501
505
  }
@@ -1,8 +1,29 @@
1
1
  import { Effect, Result } from "effect";
2
+ import { drainOAuthStartResets } from "../auth/oauth-start-reset.js";
2
3
  import { closeScope } from "../runtime/connector-scope.js";
4
+ import { withDeadlineEffect } from "../runtime/run.js";
3
5
  import { isSafeHttpUrl } from "../ui.js";
4
6
  import { authorizedPerson, refuse, serveOperator, visibleRegistry, } from "./operator.js";
5
7
  import { mayManageConnector, isSameOrigin, msg, privateJson, } from "./shared.js";
8
+ /** Bounds the start and its handoff; invalidation and scope close finish after it. */
9
+ const OAUTH_START_TIMEOUT_MS = 30_000;
10
+ /**
11
+ * How a POST starts authorization, from its `mode` query parameter:
12
+ * `restart` (the default) resets the connector to a fresh epoch — wiping the
13
+ * grant, the client registration, and discovery — before starting over;
14
+ * `continue` hands back a recent pending authorization URL when one exists
15
+ * and otherwise starts a flow in the current epoch, keeping the stored
16
+ * registration. `null` is a malformed request.
17
+ */
18
+ function startMode(url) {
19
+ const modes = url.searchParams.getAll("mode");
20
+ if (modes.length === 0)
21
+ return "restart";
22
+ const [mode] = modes;
23
+ return modes.length === 1 && (mode === "continue" || mode === "restart")
24
+ ? mode
25
+ : null;
26
+ }
6
27
  function oauthManagementRequest(context, connectorId) {
7
28
  const { request, baseUrl, defer, opts } = context;
8
29
  return Effect.gen(function* () {
@@ -27,35 +48,65 @@ function oauthManagementRequest(context, connectorId) {
27
48
  if (!disconnecting && request.method !== "POST") {
28
49
  return yield* refuse("method not allowed", 405);
29
50
  }
30
- const ctx = registry.contextFor(connectorId, baseUrl, {});
51
+ const mode = disconnecting ? undefined : startMode(context.url);
52
+ if (mode === null) {
53
+ return yield* refuse('mode must be "continue" or "restart"', 400);
54
+ }
55
+ let ctx;
31
56
  // 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({
57
+ yield* Effect.addFinalizer(() => ctx ? closeScope(connector, ctx, defer) : Effect.void);
58
+ const timeoutError = new Error("OAuth authorization start timed out");
59
+ const operation = yield* Effect.result(withDeadlineEffect((signal) => Effect.tryPromise({
34
60
  try: async () => {
61
+ ctx = registry.contextFor(connectorId, baseUrl, {}, { signal });
35
62
  if (disconnecting) {
36
63
  await disconnectAuth(ctx);
37
64
  return undefined;
38
65
  }
39
- const started = await startAuth(ctx, { force: true });
66
+ const started = await startAuth(ctx, { force: mode === "restart" });
67
+ if (signal.aborted)
68
+ throw signal.reason;
40
69
  if (started.authorizationUrl) {
41
70
  await registry.bindOAuthHandoff(connectorId, started.authorizationUrl);
42
71
  }
43
72
  return started;
44
73
  },
45
74
  catch: (error) => error,
75
+ }), {
76
+ // A disconnect deliberately ignores both browser cancellation and
77
+ // this start deadline so its reset reaches invalidation.
78
+ ...(disconnecting ? {} : { timeoutMs: OAUTH_START_TIMEOUT_MS }),
79
+ ...(disconnecting ? {} : { signal: request.signal }),
80
+ timeoutError,
46
81
  }));
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
- }));
82
+ const opened = ctx;
83
+ if (Result.isFailure(operation) && opened) {
84
+ // Storage has no abort contract. If a reset's generation write is still
85
+ // pending, answering now would let it publish over a newer flow.
86
+ yield* Effect.promise(() => drainOAuthStartResets(opened.requestScope ?? opened));
87
+ }
88
+ // A disconnect or a restart invalidates the old grant and its cached
89
+ // catalog, even when a partially failed physical cleanup left its epoch
90
+ // fence standing. So does a continued start that had to begin a new flow:
91
+ // the grant it found was missing or refused. A continued start that
92
+ // reused a pending URL or found the connection healthy changed nothing,
93
+ // so the catalog stays. (A continue does reset a disconnected connector,
94
+ // but the disconnect already invalidated that catalog.)
95
+ const started = Result.isSuccess(operation) ? operation.success : undefined;
96
+ const reused = mode === "continue" && started?.authorizationReused === true;
97
+ const unchanged = mode === "continue" && (reused || started?.state === "ok");
98
+ const invalidated = unchanged
99
+ ? Result.succeed(undefined)
100
+ : yield* Effect.result(Effect.tryPromise({
101
+ try: () => registry.invalidateStored(connectorId),
102
+ catch: (error) => error,
103
+ }));
53
104
  // Every failure below answers in the route's own fixed words. A hook's
54
105
  // rejection and a status message can quote a token endpoint's error body
55
106
  // or a provider's refusal, either of which can quote the secret it was
56
107
  // sent, so that text goes to the deployment's log and nowhere else.
57
108
  const failed = (detail, fixed, status) => {
58
- opts.logger.warn(`[connecta] connector "${connectorId}" OAuth ${disconnecting ? "disconnect" : "restart"} failed: ${detail}`);
109
+ opts.logger.warn(`[connecta] connector "${connectorId}" OAuth ${disconnecting ? "disconnect" : mode} failed: ${detail}`);
59
110
  return refuse(fixed, status);
60
111
  };
61
112
  const couldNot = disconnecting
@@ -64,6 +115,9 @@ function oauthManagementRequest(context, connectorId) {
64
115
  // A Result, not a caught value, decides whether it failed: a hook that
65
116
  // rejects with no reason at all still failed.
66
117
  if (Result.isFailure(operation)) {
118
+ if (operation.failure === timeoutError) {
119
+ return yield* failed(`start exceeded ${OAUTH_START_TIMEOUT_MS} ms`, "OAuth authorization start timed out", 504);
120
+ }
67
121
  return yield* failed(msg(operation.failure), couldNot, 400);
68
122
  }
69
123
  if (Result.isFailure(invalidated)) {
@@ -92,7 +146,7 @@ function oauthManagementRequest(context, connectorId) {
92
146
  // stopped shipping, and the state and the link are the whole answer.
93
147
  return privateJson({
94
148
  state: result.state,
95
- ...(authorizationUrl ? { authorizationUrl } : {}),
149
+ ...(authorizationUrl ? { authorizationUrl, reused } : {}),
96
150
  });
97
151
  }).pipe(Effect.scoped);
98
152
  }
@@ -1,92 +1,59 @@
1
1
  import { Effect } from "effect";
2
2
  import { oauthValueStorageKey } from "../auth/downstream-oauth.js";
3
- import { resolveBranding } from "../branding.js";
3
+ import { escapeHtml, renderPage, resolveBranding, STATUS_ICONS } from "../branding.js";
4
4
  import { oauthCallbackOutcome, providerErrorReason, } from "../oauth-callback-outcome.js";
5
5
  import { authorizeUiIdentity, mayManageConnector, validateAuthPermissions, loggableValue, msg, } from "./shared.js";
6
- function escapeHtml(value) {
7
- return value
8
- .replaceAll("&", "&")
9
- .replaceAll("<", "&lt;")
10
- .replaceAll(">", "&gt;")
11
- .replaceAll('"', "&quot;");
12
- }
6
+ const TONE_MARKS = {
7
+ ok: { className: " ok", icon: STATUS_ICONS.ok, label: "Connected" },
8
+ declined: { className: "", icon: STATUS_ICONS.declined, label: "Not connected" },
9
+ problem: { className: " danger", icon: STATUS_ICONS.problem, label: "Not connected" },
10
+ };
13
11
  /**
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.
12
+ * The callback's one page, in the shared layout. It renders a reason from a
13
+ * closed set and that reason's fixed copy, so nothing the request carried —
14
+ * the provider's `error` parameter, an exchange failure's message — can reach
15
+ * the body. The connector's title appears only when `connector` is passed,
16
+ * which the route does only after the state check, and `oauthCallbackOutcome`
17
+ * gates it by reason again; every refusal before that point is the same bytes
18
+ * for every connector id and path. Escaping stays anyway: branding and a
19
+ * configured title are still strings.
20
+ *
21
+ * The fix prompt is for whoever runs the deployment, and the person reading
22
+ * this page is often a teammate finishing personal auth, so it sits folded
23
+ * under a quiet disclosure rather than in the message.
18
24
  */
19
- function html(reason, branding, hasUi = false, connectorId) {
20
- const outcome = oauthCallbackOutcome(reason, connectorId);
21
- const brand = resolveBranding(branding);
22
- const title = brand.pageTitle;
23
- const owner = brand.ownerName
24
- ? brand.ownerUrl
25
- ? `<a class="brand" href="${escapeHtml(brand.ownerUrl)}">${escapeHtml(brand.ownerName)}</a>`
26
- : `<span class="brand">${escapeHtml(brand.ownerName)}</span>`
27
- : brand.productUrl
28
- ? `<a class="brand" href="${escapeHtml(brand.productUrl)}">${escapeHtml(brand.productName)}</a>`
29
- : `<span class="brand">${escapeHtml(brand.productName)}</span>`;
30
- const product = brand.ownerName
31
- ? brand.productUrl
32
- ? `<a class="product" href="${escapeHtml(brand.productUrl)}">${escapeHtml(brand.productName)}</a>`
33
- : `<span class="product">${escapeHtml(brand.productName)}</span>`
25
+ function html(reason, opts, connector) {
26
+ const brand = resolveBranding(opts.branding);
27
+ const outcome = oauthCallbackOutcome(reason, connector, brand.productName);
28
+ const mark = TONE_MARKS[outcome.tone];
29
+ const uiMounted = Boolean(opts.ui);
30
+ const home = uiMounted
31
+ ? `<div class="status-actions"><a class="btn${outcome.tone === "ok" ? "" : " primary"}" href="/">Return to ${escapeHtml(brand.productName)}</a></div>`
32
+ : "";
33
+ const details = outcome.fixPrompt
34
+ ? `<details class="status-details">
35
+ <summary>Details for the operator</summary>
36
+ <p>If this keeps happening, send this to whoever runs ${escapeHtml(brand.productName)}. It is written for a coding agent working on the deployment and carries nothing from the provider's response.</p>
37
+ <pre>${escapeHtml(outcome.fixPrompt)}</pre>
38
+ </details>`
34
39
  : "";
35
- return new Response(`<!doctype html>
36
- <html lang="en">
37
- <head>
38
- <meta charset="utf-8">
39
- <meta name="viewport" content="width=device-width,initial-scale=1">
40
- <meta name="theme-color" content="${escapeHtml(brand.themeColor)}">
41
- <link rel="icon" href="${escapeHtml(brand.faviconHref)}" type="image/svg+xml">
42
- <link rel="shortcut icon" href="/favicon.ico">
43
- <title>${escapeHtml(title)}</title>
44
- <style>
45
- * { border-radius: 0; box-sizing: border-box; }
46
- html { color: #000; background: #fff; font: 16px/1.5 "Helvetica Neue",
47
- Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; }
48
- body { margin: 0; min-height: 100vh; }
49
- ::selection { color: #fff; background: #000; }
50
- .shell { margin: 0 auto; max-width: 70rem; padding: 1rem; }
51
- .grid { display: grid; gap: 1rem 1.5rem;
52
- grid-template-columns: repeat(3, minmax(0, 1fr)); }
53
- .brand { font-weight: 500; grid-column: 1; text-decoration: none; }
54
- .product { grid-column: 2 / -1; }
55
- main { margin-top: 5rem; }
56
- h1, p { font: inherit; margin: 0; }
57
- h1 { grid-column: 1; }
58
- .copy { grid-column: 2 / -1; max-width: 34em; }
59
- .copy > * + * { margin-top: 1.5rem; }
60
- a { color: inherit; text-decoration: underline; text-decoration-thickness: 1.5px;
61
- text-underline-offset: .22em; }
62
- a:hover { text-decoration-color: transparent; }
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; }
67
- @media (max-width: 36.99rem) {
68
- .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
69
- .product { grid-column: 2; }
70
- main { margin-top: 3rem; }
71
- h1, .copy { grid-column: 1 / -1; }
72
- }
73
- </style>
74
- </head>
75
- <body>
76
- <header class="shell grid">
77
- ${owner}
78
- ${product}
79
- </header>
80
- <main class="shell grid" data-oauth-callback="${outcome.reason}">
81
- <h1>Connection status</h1>
82
- <div class="copy">
83
- <p>${escapeHtml(outcome.message)}</p>
84
- ${outcome.fixPrompt ? `<details><summary>Fix prompt for a coding agent</summary><pre>${escapeHtml(outcome.fixPrompt)}</pre></details>` : ""}
85
- ${hasUi ? `<p><a href="/">Return to ${escapeHtml(brand.productName)}</a></p>` : ""}
40
+ const body = `<main class="page shell" data-oauth-callback="${outcome.reason}">
41
+ <section class="status-page">
42
+ <div class="status-head">
43
+ <span class="status-mark${mark.className}" aria-hidden="true">${mark.icon}</span>
44
+ <p class="status-label${mark.className}">${mark.label}</p>
86
45
  </div>
87
- </main>
88
- </body>
89
- </html>`, { status: outcome.status, headers: { "Content-Type": "text/html; charset=utf-8" } });
46
+ <h1>${escapeHtml(outcome.heading)}</h1>
47
+ <p class="status-copy">${escapeHtml(outcome.message)}</p>
48
+ ${home}
49
+ ${details}
50
+ </section>
51
+ </main>`;
52
+ return new Response(renderPage(opts.branding, {
53
+ title: `${outcome.heading} — ${brand.pageTitle}`,
54
+ uiMounted,
55
+ body,
56
+ }), { status: outcome.status, headers: { "Content-Type": "text/html; charset=utf-8" } });
90
57
  }
91
58
  /**
92
59
  * Pay the storage read a real downstream-OAuth refusal pays, on the refusal
@@ -138,10 +105,10 @@ async function finishOAuthCallback(context) {
138
105
  const { path, url, baseUrl, opts } = context;
139
106
  const error = url.searchParams.get("error");
140
107
  if (error)
141
- return html(providerErrorReason(error), opts.branding, Boolean(opts.ui));
108
+ return html(providerErrorReason(error), opts);
142
109
  const code = url.searchParams.get("code");
143
110
  if (!code)
144
- return html("invalid_callback", opts.branding, Boolean(opts.ui));
111
+ return html("invalid_callback", opts);
145
112
  const id = path.slice("/oauth/callback/".length);
146
113
  const state = url.searchParams.get("state");
147
114
  const callbackTarget = await opts.registry.oauthCallbackView(id, state);
@@ -155,7 +122,7 @@ async function finishOAuthCallback(context) {
155
122
  const connectorContext = callbackRegistry
156
123
  ? callbackRegistry.contextFor(id, baseUrl)
157
124
  : opts.registry.contextFor(id, baseUrl);
158
- const refused = () => html("invalid_callback", opts.branding, Boolean(opts.ui));
125
+ const refused = () => html("invalid_callback", opts);
159
126
  if (!connector || !connector.finishAuth) {
160
127
  await equalizeRefusalCost(connectorContext);
161
128
  return refused();
@@ -220,19 +187,19 @@ async function finishOAuthCallback(context) {
220
187
  opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
221
188
  `${loggableValue(id)} with 500: its principal handoff could not be ` +
222
189
  `consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`);
223
- return html("handoff_failed", opts.branding, Boolean(opts.ui), id);
190
+ return html("handoff_failed", opts, connector);
224
191
  }
225
192
  }
226
193
  try {
227
194
  await connector.finishAuth(code, connectorContext, url.searchParams);
228
195
  await callbackRegistry.invalidateStored(id);
229
- return html("connected", opts.branding, Boolean(opts.ui), id);
196
+ return html("connected", opts, connector);
230
197
  }
231
198
  catch (err) {
232
199
  // The page names the reason and nothing else; what the exchange threw can
233
200
  // quote a token endpoint's body, so it goes only to the operator log.
234
201
  opts.logger.warn(`[connecta] OAuth callback for connector ${loggableValue(id)} failed ` +
235
202
  `with 500: the authorization code exchange threw ${loggableValue(msg(err))}.`);
236
- return html("exchange_failed", opts.branding, Boolean(opts.ui), id);
203
+ return html("exchange_failed", opts, connector);
237
204
  }
238
205
  }
@@ -1,5 +1,5 @@
1
1
  import type { Implementation } from "@modelcontextprotocol/server";
2
- import type { ActivityModule, ArtifactsModule, OperatorSurface } from "../module-contracts.js";
2
+ import type { AccessTokensModule, ActivityModule, ArtifactsModule, OperatorSurface } from "../module-contracts.js";
3
3
  import type { ActivityActor, ActivityReadGate, ActivityStore } from "../activity.js";
4
4
  import type { CredentialVault } from "../credential-contract.js";
5
5
  import type { DeferredWork } from "../connector-scope.js";
@@ -63,6 +63,7 @@ export interface ServerOptions {
63
63
  activityModule?: ActivityModule | undefined;
64
64
  /** Optional team pages; their routes mount only beside `ui`. */
65
65
  artifactsModule?: ArtifactsModule | undefined;
66
+ accessTokens?: AccessTokensModule | undefined;
66
67
  /** Optional browser UI and OAuth result-page labels. */
67
68
  branding?: ConnectaBranding | undefined;
68
69
  }
@@ -102,6 +103,7 @@ export declare function authorize(request: Request, baseUrl: string, auth: Inbou
102
103
  /** Granted addresses that still require the catalog's read-only hint. */
103
104
  guardedToolAccess?: ToolAccess;
104
105
  operator: boolean;
106
+ accessTokenManagement: boolean;
105
107
  credentialAdministration: ConnectorPermission;
106
108
  personalConnection: ConnectorPermission;
107
109
  /** Backward-compatible name used by operator views. */
@@ -45,7 +45,7 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
45
45
  response: privateJson({ error: "identity access resolution failed" }, { status: 403 }),
46
46
  };
47
47
  }
48
- return { ok: true, actor, identity, ...access, operator: false, credentialAdministration: "none", personalConnection: "none" };
48
+ return { ok: true, actor, identity, ...access, operator: false, accessTokenManagement: false, credentialAdministration: "none", personalConnection: "none" };
49
49
  }
50
50
  let lastResponse = null;
51
51
  for (const provider of auth) {
@@ -75,6 +75,7 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
75
75
  interactive,
76
76
  };
77
77
  let operator = interactive;
78
+ let accessTokenManagement = false;
78
79
  let credentialAdministration = "none";
79
80
  let personalConnection = "none";
80
81
  let access;
@@ -86,6 +87,9 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
86
87
  }
87
88
  access = parseConnectorAccess(identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all", { allowReadOnly: true });
88
89
  if (interactive) {
90
+ accessTokenManagement = identityConfig?.accessTokenManagement ? await identityConfig.accessTokenManagement(identity) : false;
91
+ if (typeof accessTokenManagement !== "boolean")
92
+ throw new Error("invalid token management permission");
89
93
  credentialAdministration = identityConfig?.credentialAdministration ? await identityConfig.credentialAdministration(identity) : "none";
90
94
  personalConnection = principal && identityConfig?.personalConnection ? await identityConfig.personalConnection(identity) : "none";
91
95
  }
@@ -113,6 +117,7 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
113
117
  ? { principalKey: await identityStorageKey(principal) }
114
118
  : {}),
115
119
  ...access,
120
+ accessTokenManagement,
116
121
  credentialAdministration,
117
122
  personalConnection,
118
123
  operator,
package/dist/routes/ui.js CHANGED
@@ -74,6 +74,7 @@ export async function routeUi(context) {
74
74
  const artifactPage = operatorPage === "artifacts" || operatorPage === "artifact";
75
75
  if (operatorPage &&
76
76
  (operatorPage !== "activity" || opts.activity?.list) &&
77
+ (operatorPage !== "tokens" || opts.accessTokens) &&
77
78
  (!artifactPage || opts.artifactsModule)) {
78
79
  if (request.method !== "GET" && request.method !== "HEAD") {
79
80
  return privateJson({ error: "method not allowed" }, { status: 405 });
@@ -95,9 +96,15 @@ export async function routeUi(context) {
95
96
  // An artifact shell frames the sandboxed page from its own origin and
96
97
  // nothing else; Connections stays on the deployment's public origin.
97
98
  const homeUrl = opts.publicUrl ? new URL("/", opts.publicUrl).toString() : "/";
99
+ // The artifact host serves no favicon, so a shell there takes its icons
100
+ // from the public origin, as the 404 does; on the main host they stay
101
+ // root-relative.
102
+ const onArtifactHost = Boolean(opts.artifactOrigin) &&
103
+ url.origin === new URL(opts.artifactOrigin).origin;
104
+ const iconOrigin = onArtifactHost ? opts.publicUrl : undefined;
98
105
  return new Response(request.method === "HEAD"
99
106
  ? null
100
- : renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage, { homeUrl }), {
107
+ : renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage, { homeUrl, iconOrigin }), {
101
108
  status: 200,
102
109
  headers: {
103
110
  "Content-Type": "text/html; charset=utf-8",
@@ -181,6 +188,7 @@ function summary(context) {
181
188
  serverInfo: opts.serverInfo,
182
189
  connectaVersion: CONNECTA_VERSION,
183
190
  activityEnabled,
191
+ ...(opts.accessTokens ? { accessTokenManagement: authz.accessTokenManagement && authz.identity.principal ? "available" : "requires_operator" } : {}),
184
192
  ...(opts.artifactsModule && mayViewArtifacts(authz, opts.registry) ? { artifactsEnabled: true } : {}),
185
193
  credentialManagement,
186
194
  oauthManagement: visible.some(c => mayManage(c.id)),
package/dist/server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect";
2
+ import { notFoundResponse } from "./branding.js";
2
3
  import { aggregateCallAdmissionSnapshots } from "./call-admission.js";
3
4
  import { isAdmittingExecutor } from "./executor-admission.js";
4
5
  import { createMcpRoute, MCP_CORS_HEADERS } from "./routes/mcp.js";
@@ -76,6 +77,7 @@ export function createFetchHandler(opts) {
76
77
  "/health",
77
78
  ...(opts.ui?.reservedPaths ?? []),
78
79
  ...(opts.ui && opts.activity?.list ? ["/activity"] : []),
80
+ ...(opts.ui && opts.accessTokens ? ["/tokens"] : []),
79
81
  ...(opts.ui && opts.artifactsModule ? ["/artifacts", "/artifacts/*"] : []),
80
82
  ],
81
83
  },
@@ -107,7 +109,7 @@ export function createFetchHandler(opts) {
107
109
  const response = yield* metadata(request, baseUrl);
108
110
  if (response)
109
111
  return response;
110
- return new Response("Not Found", { status: 404 });
112
+ return notFoundResponse(request, opts);
111
113
  }
112
114
  if (path === "/health")
113
115
  return yield* Effect.promise(health);
@@ -117,7 +119,7 @@ export function createFetchHandler(opts) {
117
119
  const mcp = yield* routeMcp.handle(context);
118
120
  if (mcp)
119
121
  return mcp;
120
- return new Response("Not Found", { status: 404 });
122
+ return notFoundResponse(request, opts);
121
123
  });
122
124
  const serve = (request, runtimeContext) => Effect.suspend(() => {
123
125
  const url = new URL(request.url);
@@ -127,15 +129,16 @@ export function createFetchHandler(opts) {
127
129
  ? runtimeContext.waitUntil.bind(runtimeContext)
128
130
  : undefined;
129
131
  // The artifact hostname has no MCP, health, OAuth, or operator routes.
130
- // Dispatch before MCP origin checks so even a hostile /mcp is a plain 404.
132
+ // Dispatch before MCP origin checks so even a hostile /mcp gets the same
133
+ // 404 as any other unserved path.
131
134
  if (opts.artifactOrigin && url.origin === new URL(opts.artifactOrigin).origin) {
132
135
  if (!isArtifactPath(path) || !opts.ui || !opts.artifactsModule) {
133
- return Effect.succeed(withSecurityHeaders(new Response("Not Found", { status: 404 }), url, path));
136
+ return Effect.succeed(withSecurityHeaders(notFoundResponse(request, opts), url, path));
134
137
  }
135
138
  const context = {
136
139
  request, url, path, baseUrl: opts.artifactOrigin, opts, defer, runtimeContext,
137
140
  };
138
- return Effect.map(Effect.promise(() => opts.ui.handle(context)), response => withSecurityHeaders(response ?? new Response("Not Found", { status: 404 }), url, path));
141
+ return Effect.map(Effect.promise(() => opts.ui.handle(context)), response => withSecurityHeaders(response ?? notFoundResponse(request, opts), url, path));
139
142
  }
140
143
  if (opts.artifactOrigin && isArtifactPath(path) && opts.ui && opts.artifactsModule) {
141
144
  const target = new URL(opts.artifactOrigin);