@zackbart/connecta 0.25.0 → 0.26.1

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 (100) hide show
  1. package/AGENTS.md +6 -5
  2. package/CHANGELOG.md +387 -0
  3. package/README.md +32 -15
  4. package/SECURITY.md +6 -5
  5. package/bin/connecta.mjs +5 -1
  6. package/dist/activity.d.ts +10 -3
  7. package/dist/activity.js +10 -1
  8. package/dist/artifacts/connector.d.ts +55 -0
  9. package/dist/artifacts/connector.js +724 -0
  10. package/dist/artifacts/document.js +80 -0
  11. package/dist/artifacts/guide.js +140 -0
  12. package/dist/artifacts/html-scan.js +329 -0
  13. package/dist/artifacts/json.js +68 -0
  14. package/dist/artifacts/kv-store.d.ts +20 -0
  15. package/dist/artifacts/kv-store.js +140 -0
  16. package/dist/artifacts/markdown.js +478 -0
  17. package/dist/artifacts/module.d.ts +37 -0
  18. package/dist/artifacts/module.js +60 -0
  19. package/dist/artifacts/operations.d.ts +195 -0
  20. package/dist/artifacts/operations.js +944 -0
  21. package/dist/artifacts/refresh.d.ts +53 -0
  22. package/dist/artifacts/refresh.js +235 -0
  23. package/dist/artifacts/routes.js +217 -0
  24. package/dist/artifacts/types.d.ts +214 -0
  25. package/dist/artifacts/types.js +22 -0
  26. package/dist/artifacts/validate.d.ts +62 -0
  27. package/dist/artifacts/validate.js +653 -0
  28. package/dist/artifacts.d.ts +6 -0
  29. package/dist/artifacts.js +9 -0
  30. package/dist/auth/downstream-oauth.js +397 -48
  31. package/dist/auth/oauth-start-reset.js +20 -0
  32. package/dist/branding.d.ts +87 -0
  33. package/dist/branding.js +170 -1
  34. package/dist/catalog-service.js +39 -29
  35. package/dist/catalog.js +42 -8
  36. package/dist/connector-access.d.ts +18 -4
  37. package/dist/connector-access.js +65 -20
  38. package/dist/connector-caller.d.ts +8 -0
  39. package/dist/connector-caller.js +19 -0
  40. package/dist/connectors/remote-mcp.js +48 -9
  41. package/dist/errors.d.ts +16 -1
  42. package/dist/errors.js +28 -0
  43. package/dist/execute.js +431 -75
  44. package/dist/executor-admission.d.ts +11 -1
  45. package/dist/executor-admission.js +79 -14
  46. package/dist/executors/quickjs.js +4 -2
  47. package/dist/index.d.ts +62 -6
  48. package/dist/index.js +230 -6
  49. package/dist/invocation.js +56 -10
  50. package/dist/meta-tools.js +7 -2
  51. package/dist/module-contracts.d.ts +21 -1
  52. package/dist/oauth-callback-outcome.js +43 -18
  53. package/dist/operator-ui/generated.js +2 -2
  54. package/dist/operator-ui/model.d.ts +62 -5
  55. package/dist/operator-ui/view.js +372 -28
  56. package/dist/page-styles.js +7 -0
  57. package/dist/program-source.js +116 -0
  58. package/dist/registry.d.ts +42 -6
  59. package/dist/registry.js +164 -60
  60. package/dist/resumable.js +1291 -0
  61. package/dist/routes/activity.js +6 -69
  62. package/dist/routes/actor-labels.js +114 -0
  63. package/dist/routes/credentials.js +27 -5
  64. package/dist/routes/mcp.js +120 -59
  65. package/dist/routes/oauth-management.js +87 -20
  66. package/dist/routes/oauth.js +54 -87
  67. package/dist/routes/operator.js +1 -0
  68. package/dist/routes/shared.d.ts +23 -1
  69. package/dist/routes/shared.js +20 -2
  70. package/dist/routes/ui-data.js +2 -1
  71. package/dist/routes/ui.js +19 -4
  72. package/dist/run-journal.js +347 -0
  73. package/dist/runtime/shared-read.js +106 -0
  74. package/dist/server.js +30 -2
  75. package/dist/skills.js +30 -11
  76. package/dist/timeout.js +26 -0
  77. package/dist/tool-safety.d.ts +35 -0
  78. package/dist/tool-safety.js +31 -0
  79. package/dist/types.d.ts +23 -0
  80. package/dist/ui.d.ts +17 -5
  81. package/dist/ui.js +51 -68
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/documentation/architecture.md +214 -27
  85. package/documentation/auth.md +186 -2
  86. package/documentation/code-mode.md +236 -63
  87. package/documentation/meta-tools.md +55 -29
  88. package/ethos.md +12 -10
  89. package/examples/worker/AGENTS.md +6 -0
  90. package/examples/worker/README.md +174 -10
  91. package/examples/worker/src/d1-activity-row.ts +14 -2
  92. package/examples/worker/src/d1-activity.ts +4 -2
  93. package/examples/worker/src/index.ts +36 -1
  94. package/examples/worker/src/r2-artifact-blobs.ts +29 -0
  95. package/examples/worker/wrangler.jsonc +5 -1
  96. package/package.json +6 -2
  97. package/templates/node/AGENTS.md +9 -2
  98. package/templates/node/README.md +31 -3
  99. package/templates/node/package.json +1 -1
  100. package/templates/node/src/index.ts +27 -0
@@ -1,10 +1,31 @@
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
- const { request, baseUrl, defer } = context;
28
+ const { request, baseUrl, defer, opts } = context;
8
29
  return Effect.gen(function* () {
9
30
  if (!isSameOrigin(request, baseUrl)) {
10
31
  return yield* refuse("same-origin request required", 403);
@@ -27,35 +48,81 @@ 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
+ }));
104
+ // Every failure below answers in the route's own fixed words. A hook's
105
+ // rejection and a status message can quote a token endpoint's error body
106
+ // or a provider's refusal, either of which can quote the secret it was
107
+ // sent, so that text goes to the deployment's log and nowhere else.
108
+ const failed = (detail, fixed, status) => {
109
+ opts.logger.warn(`[connecta] connector "${connectorId}" OAuth ${disconnecting ? "disconnect" : mode} failed: ${detail}`);
110
+ return refuse(fixed, status);
111
+ };
112
+ const couldNot = disconnecting
113
+ ? "OAuth disconnect failed"
114
+ : "OAuth authorization could not start";
53
115
  // A Result, not a caught value, decides whether it failed: a hook that
54
116
  // 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);
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
+ }
121
+ return yield* failed(msg(operation.failure), couldNot, 400);
122
+ }
123
+ if (Result.isFailure(invalidated)) {
124
+ return yield* failed(`stored-state invalidation: ${msg(invalidated.failure)}`, couldNot, 400);
125
+ }
59
126
  const result = operation.success;
60
127
  if (!result) {
61
128
  return new Response(null, {
@@ -70,16 +137,16 @@ function oauthManagementRequest(context, connectorId) {
70
137
  ? result.authorizationUrl
71
138
  : undefined;
72
139
  if (result.state === "error") {
73
- return yield* refuse(result.message || "OAuth authorization could not start", 502);
140
+ return yield* failed(result.message || "the connector reported an error state", couldNot, 502);
74
141
  }
75
142
  if (result.state === "auth_required" && !authorizationUrl) {
76
- return yield* refuse(result.message ||
77
- "OAuth authorization requires consent but no safe URL is available", 502);
143
+ return yield* failed(result.message || "consent is required but no safe authorization URL was returned", "OAuth authorization requires consent but no safe URL is available", 502);
78
144
  }
145
+ // No message: a status message is the same text the Connections page
146
+ // stopped shipping, and the state and the link are the whole answer.
79
147
  return privateJson({
80
148
  state: result.state,
81
- ...(result.message ? { message: result.message } : {}),
82
- ...(authorizationUrl ? { authorizationUrl } : {}),
149
+ ...(authorizationUrl ? { authorizationUrl, reused } : {}),
83
150
  });
84
151
  }).pipe(Effect.scoped);
85
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
  }
@@ -51,6 +51,7 @@ export function scopeFor(authz, connectorIds = authz.connectorIds) {
51
51
  return {
52
52
  connectorIds,
53
53
  ...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
54
+ ...(authz.guardedToolAccess ? { guardedToolAccess: authz.guardedToolAccess } : {}),
54
55
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
55
56
  ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
56
57
  };
@@ -1,5 +1,5 @@
1
1
  import type { Implementation } from "@modelcontextprotocol/server";
2
- import type { ActivityModule, OperatorSurface } from "../module-contracts.js";
2
+ import type { 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";
@@ -7,6 +7,7 @@ import type { AdmissionController } from "../executor-admission.js";
7
7
  import type { Registry, ToolAccess } from "../registry.js";
8
8
  import type { ResolvedPool } from "../connector-access.js";
9
9
  import type { AuthenticatedIdentity, ConnectaBranding, Executor, InboundAuth, InboundAuthRuntimeContext, Logger } from "../types.js";
10
+ import type { ApprovalPolicy } from "../tool-safety.js";
10
11
  import type { ConnectorPermission, ConnectaIdentityConfig } from "../index.js";
11
12
  export { msg } from "../errors.js";
12
13
  export interface ServerOptions {
@@ -16,6 +17,7 @@ export interface ServerOptions {
16
17
  /** Validated named pools served at `/mcp/<name>`; empty when none declared. */
17
18
  pools?: ReadonlyMap<string, ResolvedPool> | undefined;
18
19
  publicUrl?: string | undefined;
20
+ artifactOrigin?: string | undefined;
19
21
  allowedOrigins?: readonly string[] | "*" | undefined;
20
22
  serverInfo: Implementation;
21
23
  logger: Logger;
@@ -39,6 +41,15 @@ export interface ServerOptions {
39
41
  hostCallTimeoutMs?: number | undefined;
40
42
  /** Hard ceiling on one execute_code run, outside the sandbox. Default 120_000. */
41
43
  watchdogMs?: number | undefined;
44
+ /** Resumable writes' settings; absent when they are off. */
45
+ resumable?: {
46
+ maxWrites: number;
47
+ ttlSeconds: number;
48
+ } | undefined;
49
+ /** Config approval exemptions (`execute.approval`), resolved at construction. */
50
+ approval?: ApprovalPolicy | undefined;
51
+ /** Writes one program may send (`execute.maxWrites`), exempt ones included. */
52
+ maxWrites?: number | undefined;
42
53
  /** Required sandbox backing the execute_code meta-tool. */
43
54
  executor: Executor;
44
55
  /** Sanitized identity of the configured sandbox, when it has one. */
@@ -50,6 +61,8 @@ export interface ServerOptions {
50
61
  /** Optional browser routes, with no implementation import in core. */
51
62
  ui?: OperatorSurface | undefined;
52
63
  activityModule?: ActivityModule | undefined;
64
+ /** Optional team pages; their routes mount only beside `ui`. */
65
+ artifactsModule?: ArtifactsModule | undefined;
53
66
  /** Optional browser UI and OAuth result-page labels. */
54
67
  branding?: ConnectaBranding | undefined;
55
68
  }
@@ -86,6 +99,8 @@ export declare function authorize(request: Request, baseUrl: string, auth: Inbou
86
99
  connectorIds: "all" | readonly string[];
87
100
  /** Per-connector tool allowlist for connectors granted by address only. */
88
101
  toolAccess?: ToolAccess;
102
+ /** Granted addresses that still require the catalog's read-only hint. */
103
+ guardedToolAccess?: ToolAccess;
89
104
  operator: boolean;
90
105
  credentialAdministration: ConnectorPermission;
91
106
  personalConnection: ConnectorPermission;
@@ -108,6 +123,13 @@ type AuthorizedIdentity = Extract<Awaited<ReturnType<typeof authorize>>, {
108
123
  }>;
109
124
  /** Unknown configured ids refuse the complete view, including management rights. */
110
125
  export declare function validateAuthPermissions(authz: AuthorizedIdentity, registry: Registry): void;
126
+ /**
127
+ * Whether this identity may open artifact pages: exactly whoever may read the
128
+ * `artifacts` connector — it is visible to them and `get_artifact` is among
129
+ * its tools they were granted. Viewing grants nothing a program could not
130
+ * already read (ethos, "Human routes manage auth, never capability").
131
+ */
132
+ export declare function mayViewArtifacts(authz: AuthorizedIdentity, registry: Registry): boolean;
111
133
  export declare function mayManageConnector(authz: AuthorizedIdentity, connector: {
112
134
  id: string;
113
135
  authScope?: "shared" | "personal";
@@ -1,5 +1,6 @@
1
1
  import { parseConnectorAccess } from "../connector-access.js";
2
2
  import { identityStorageKey, validIdentityReference } from "../identity.js";
3
+ import { isExplicitlyReadOnly } from "../tool-safety.js";
3
4
  export { msg } from "../errors.js";
4
5
  export function privateJson(body, init = {}) {
5
6
  const headers = new Headers(init.headers);
@@ -36,7 +37,7 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
36
37
  const identity = { actor, interactive: false };
37
38
  let access;
38
39
  try {
39
- access = parseConnectorAccess(identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all");
40
+ access = parseConnectorAccess(identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all", { allowReadOnly: true });
40
41
  }
41
42
  catch {
42
43
  return {
@@ -83,7 +84,7 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
83
84
  ? await identityConfig.activityAccess(principal)
84
85
  : false;
85
86
  }
86
- access = parseConnectorAccess(identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all");
87
+ access = parseConnectorAccess(identityConfig?.connectorAccess ? await identityConfig.connectorAccess(identity) : "all", { allowReadOnly: true });
87
88
  if (interactive) {
88
89
  credentialAdministration = identityConfig?.credentialAdministration ? await identityConfig.credentialAdministration(identity) : "none";
89
90
  personalConnection = principal && identityConfig?.personalConnection ? await identityConfig.personalConnection(identity) : "none";
@@ -194,6 +195,23 @@ export function validateAuthPermissions(authz, registry) {
194
195
  }
195
196
  }
196
197
  }
198
+ /**
199
+ * Whether this identity may open artifact pages: exactly whoever may read the
200
+ * `artifacts` connector — it is visible to them and `get_artifact` is among
201
+ * its tools they were granted. Viewing grants nothing a program could not
202
+ * already read (ethos, "Human routes manage auth, never capability").
203
+ */
204
+ export function mayViewArtifacts(authz, registry) {
205
+ if (authz.connectorIds !== "all" && !authz.connectorIds.includes("artifacts")) {
206
+ return false;
207
+ }
208
+ const tools = authz.toolAccess?.get("artifacts");
209
+ if (tools && !tools.has("get_artifact"))
210
+ return false;
211
+ if (!authz.guardedToolAccess?.get("artifacts")?.has("get_artifact"))
212
+ return true;
213
+ return registry.getConnector("artifacts")?.staticTools?.some((tool) => tool.name === "get_artifact" && isExplicitlyReadOnly(tool)) === true;
214
+ }
197
215
  export function mayManageConnector(authz, connector) {
198
216
  if (!authz.identity.interactive)
199
217
  return false;
@@ -10,6 +10,7 @@ import { resolveDiscoveryConcurrency } from "../concurrency.js";
10
10
  import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentialShape, } from "../credential-rules.js";
11
11
  import { closeScope } from "../runtime/connector-scope.js";
12
12
  import { withDeadlineEffect } from "../runtime/run.js";
13
+ import { isApprovalExempt, NO_EXEMPTIONS, } from "../tool-safety.js";
13
14
  import { uiProblemFor, uiToolSafety } from "../ui.js";
14
15
  import { CONNECTA_VERSION } from "../version.js";
15
16
  const DETAIL_TIMEOUT_MS = 30_000;
@@ -59,7 +60,7 @@ export function uiData(registry, baseUrl, options) {
59
60
  name: t.name,
60
61
  address: `${c.id}.${t.name}`,
61
62
  ...(t.description ? { description: t.description } : {}),
62
- safety: uiToolSafety(t),
63
+ safety: uiToolSafety(t, isApprovalExempt(options.approval ?? NO_EXEMPTIONS, c, t.name, t)),
63
64
  }))).pipe(Effect.map((tools) => ({ status, tools, catalogFailed: false })),
64
65
  // The registry owns the failed catalog observation; the page only
65
66
  // needs to know there was one.
package/dist/routes/ui.js CHANGED
@@ -3,7 +3,7 @@ import { CONNECTA_VERSION } from "../version.js";
3
3
  import { CONNECTA_FAVICON_ICO } from "../favicon.js";
4
4
  import { CONNECTA_FAVICON_SVG, operatorPageForPath, renderUiHtml, } from "../ui.js";
5
5
  import { authorized, refuse, scopeFor, serveOperator, visibleRegistry, } from "./operator.js";
6
- import { mayManageConnector, privateJson, } from "./shared.js";
6
+ import { mayManageConnector, mayViewArtifacts, privateJson, } from "./shared.js";
7
7
  import { uiData } from "./ui-data.js";
8
8
  /**
9
9
  * Headers that make an operator-supplied favicon body inert on this origin.
@@ -71,7 +71,10 @@ export async function routeUi(context) {
71
71
  });
72
72
  }
73
73
  const operatorPage = operatorPageForPath(path);
74
- if (operatorPage && (operatorPage !== "activity" || opts.activity?.list)) {
74
+ const artifactPage = operatorPage === "artifacts" || operatorPage === "artifact";
75
+ if (operatorPage &&
76
+ (operatorPage !== "activity" || opts.activity?.list) &&
77
+ (!artifactPage || opts.artifactsModule)) {
75
78
  if (request.method !== "GET" && request.method !== "HEAD") {
76
79
  return privateJson({ error: "method not allowed" }, { status: 405 });
77
80
  }
@@ -89,14 +92,24 @@ export async function routeUi(context) {
89
92
  // style/font/network needs and the page's inline <style> stay unrestricted
90
93
  // — only script execution, the XSS sink, is gated.
91
94
  const nonce = uiScriptNonce();
95
+ // An artifact shell frames the sandboxed page from its own origin and
96
+ // nothing else; Connections stays on the deployment's public origin.
97
+ const homeUrl = opts.publicUrl ? new URL("/", opts.publicUrl).toString() : "/";
98
+ // The artifact host serves no favicon, so a shell there takes its icons
99
+ // from the public origin, as the 404 does; on the main host they stay
100
+ // root-relative.
101
+ const onArtifactHost = Boolean(opts.artifactOrigin) &&
102
+ url.origin === new URL(opts.artifactOrigin).origin;
103
+ const iconOrigin = onArtifactHost ? opts.publicUrl : undefined;
92
104
  return new Response(request.method === "HEAD"
93
105
  ? null
94
- : renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage), {
106
+ : renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage, { homeUrl, iconOrigin }), {
95
107
  status: 200,
96
108
  headers: {
97
109
  "Content-Type": "text/html; charset=utf-8",
98
110
  "Content-Security-Policy": `script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
99
- "object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
111
+ "object-src 'none'; base-uri 'none'; frame-ancestors 'none'" +
112
+ (artifactPage ? "; frame-src 'self'" : ""),
100
113
  "X-Content-Type-Options": "nosniff",
101
114
  },
102
115
  });
@@ -146,6 +159,7 @@ function connectorDetail(context, id) {
146
159
  mayManage,
147
160
  timeoutMs: opts.probeTimeoutMs ?? 30_000,
148
161
  signal: request.signal,
162
+ approval: opts.approval,
149
163
  });
150
164
  return privateJson({ ...data.connectors[0], permissions: permissions(connector) });
151
165
  });
@@ -173,6 +187,7 @@ function summary(context) {
173
187
  serverInfo: opts.serverInfo,
174
188
  connectaVersion: CONNECTA_VERSION,
175
189
  activityEnabled,
190
+ ...(opts.artifactsModule && mayViewArtifacts(authz, opts.registry) ? { artifactsEnabled: true } : {}),
176
191
  credentialManagement,
177
192
  oauthManagement: visible.some(c => mayManage(c.id)),
178
193
  connectors: visible.map(c => ({ id: c.id, ...(c.title ? { title: c.title } : {}), ...(c.description ? { description: c.description } : {}), authScope: c.authScope ?? "shared", status: "loading", toolCount: 0, tools: [], oauth: Boolean(c.startAuth && c.disconnectAuth), permissions: permissions(c) })),