@zackbart/connecta 0.24.4 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/AGENTS.md +43 -12
  2. package/CHANGELOG.md +524 -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/advertised-schema.js +53 -0
  9. package/dist/artifacts/connector.d.ts +55 -0
  10. package/dist/artifacts/connector.js +724 -0
  11. package/dist/artifacts/document.js +76 -0
  12. package/dist/artifacts/guide.js +140 -0
  13. package/dist/artifacts/html-scan.js +329 -0
  14. package/dist/artifacts/json.js +68 -0
  15. package/dist/artifacts/kv-store.d.ts +20 -0
  16. package/dist/artifacts/kv-store.js +140 -0
  17. package/dist/artifacts/markdown.js +449 -0
  18. package/dist/artifacts/module.d.ts +37 -0
  19. package/dist/artifacts/module.js +60 -0
  20. package/dist/artifacts/operations.d.ts +195 -0
  21. package/dist/artifacts/operations.js +944 -0
  22. package/dist/artifacts/refresh.d.ts +53 -0
  23. package/dist/artifacts/refresh.js +235 -0
  24. package/dist/artifacts/routes.js +194 -0
  25. package/dist/artifacts/types.d.ts +214 -0
  26. package/dist/artifacts/types.js +22 -0
  27. package/dist/artifacts/validate.d.ts +62 -0
  28. package/dist/artifacts/validate.js +652 -0
  29. package/dist/artifacts.d.ts +6 -0
  30. package/dist/artifacts.js +9 -0
  31. package/dist/auth/downstream-oauth.js +490 -106
  32. package/dist/call-admission.js +139 -113
  33. package/dist/catalog-service.js +152 -106
  34. package/dist/catalog.js +297 -38
  35. package/dist/concurrency.js +24 -23
  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/connector-scope.js +6 -37
  41. package/dist/connectors/api.d.ts +2 -2
  42. package/dist/connectors/remote-mcp.d.ts +6 -5
  43. package/dist/connectors/remote-mcp.js +330 -240
  44. package/dist/credential-contract.d.ts +9 -0
  45. package/dist/credential-rules.js +2 -2
  46. package/dist/credentials.d.ts +6 -1
  47. package/dist/credentials.js +45 -3
  48. package/dist/errors.d.ts +16 -1
  49. package/dist/errors.js +28 -0
  50. package/dist/execute.js +745 -282
  51. package/dist/executor-admission.d.ts +11 -1
  52. package/dist/executor-admission.js +145 -63
  53. package/dist/executors/quickjs-runtime.js +4 -2
  54. package/dist/executors/quickjs.js +255 -212
  55. package/dist/fix-prompt.js +37 -0
  56. package/dist/index.d.ts +72 -7
  57. package/dist/index.js +281 -26
  58. package/dist/invocation.js +371 -286
  59. package/dist/meta-tools.js +213 -94
  60. package/dist/module-contracts.d.ts +21 -1
  61. package/dist/oauth-callback-outcome.js +94 -0
  62. package/dist/oauth-sealing.js +29 -0
  63. package/dist/operator-ui/fix-prompts.js +87 -0
  64. package/dist/operator-ui/generated.js +2 -2
  65. package/dist/operator-ui/model.d.ts +103 -1
  66. package/dist/operator-ui/setup-commands.js +53 -0
  67. package/dist/operator-ui/view.js +128 -3
  68. package/dist/program-source.js +321 -0
  69. package/dist/providers/notion.js +2 -2
  70. package/dist/providers/revenuecat.js +32 -5
  71. package/dist/providers/stripe.d.ts +3 -2
  72. package/dist/providers/vercel.js +1 -1
  73. package/dist/registry.d.ts +43 -7
  74. package/dist/registry.js +434 -288
  75. package/dist/resumable.js +1291 -0
  76. package/dist/routes/activity.js +74 -154
  77. package/dist/routes/actor-labels.js +114 -0
  78. package/dist/routes/credentials.js +196 -163
  79. package/dist/routes/mcp.js +228 -112
  80. package/dist/routes/oauth-management.js +73 -71
  81. package/dist/routes/oauth.js +44 -16
  82. package/dist/routes/operator.js +83 -0
  83. package/dist/routes/shared.d.ts +25 -1
  84. package/dist/routes/shared.js +20 -2
  85. package/dist/routes/ui-data.js +211 -0
  86. package/dist/routes/ui.js +76 -35
  87. package/dist/run-journal.js +347 -0
  88. package/dist/runtime/admission.js +43 -0
  89. package/dist/runtime/call-admission.js +67 -0
  90. package/dist/runtime/connector-scope.js +72 -0
  91. package/dist/runtime/run.js +174 -0
  92. package/dist/runtime/services.js +130 -0
  93. package/dist/runtime/shared-read.js +106 -0
  94. package/dist/runtime/storage.js +66 -0
  95. package/dist/server.js +137 -89
  96. package/dist/skills.js +28 -9
  97. package/dist/storage/file.js +39 -2
  98. package/dist/storage/memory.js +28 -16
  99. package/dist/timeout.js +39 -43
  100. package/dist/tool-safety.d.ts +27 -1
  101. package/dist/tool-safety.js +31 -0
  102. package/dist/types.d.ts +45 -7
  103. package/dist/ui.d.ts +25 -4
  104. package/dist/ui.js +77 -167
  105. package/dist/url-safety.js +157 -0
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/documentation/architecture.md +367 -16
  109. package/documentation/auth.md +163 -2
  110. package/documentation/code-mode.md +274 -56
  111. package/documentation/meta-tools.md +200 -44
  112. package/ethos.md +46 -47
  113. package/examples/worker/AGENTS.md +6 -0
  114. package/examples/worker/README.md +224 -14
  115. package/examples/worker/src/d1-activity-row.ts +14 -2
  116. package/examples/worker/src/d1-activity.ts +4 -2
  117. package/examples/worker/src/d1-storage.ts +116 -0
  118. package/examples/worker/src/index.ts +42 -7
  119. package/examples/worker/src/r2-artifact-blobs.ts +29 -0
  120. package/examples/worker/wrangler.jsonc +21 -5
  121. package/package.json +16 -4
  122. package/templates/node/AGENTS.md +9 -2
  123. package/templates/node/README.md +31 -3
  124. package/templates/node/package.json +1 -1
  125. package/templates/node/src/index.ts +27 -0
  126. package/dist/auth/downstream-oauth.d.ts +0 -200
  127. package/dist/catalog-fingerprint.d.ts +0 -19
  128. package/dist/catalog-limits.d.ts +0 -9
  129. package/dist/catalog-service.d.ts +0 -263
  130. package/dist/catalog.d.ts +0 -87
  131. package/dist/concurrency.d.ts +0 -3
  132. package/dist/connectors/guarded-fetch.d.ts +0 -96
  133. package/dist/credential-rules.d.ts +0 -85
  134. package/dist/execute.d.ts +0 -154
  135. package/dist/executor-result.d.ts +0 -10
  136. package/dist/executors/quickjs-child.d.ts +0 -1
  137. package/dist/executors/quickjs-protocol.d.ts +0 -67
  138. package/dist/favicon.d.ts +0 -1
  139. package/dist/identity.d.ts +0 -4
  140. package/dist/invocation.d.ts +0 -74
  141. package/dist/mcp-result.d.ts +0 -8
  142. package/dist/meta-tools.d.ts +0 -129
  143. package/dist/operator-ui/generated.d.ts +0 -2
  144. package/dist/operator-ui/view.d.ts +0 -188
  145. package/dist/result-shapes.d.ts +0 -13
  146. package/dist/retired-toolkits.d.ts +0 -2
  147. package/dist/routes/activity.d.ts +0 -2
  148. package/dist/routes/credentials.d.ts +0 -2
  149. package/dist/routes/mcp.d.ts +0 -10
  150. package/dist/routes/oauth-management.d.ts +0 -2
  151. package/dist/routes/oauth.d.ts +0 -2
  152. package/dist/routes/ui.d.ts +0 -2
  153. package/dist/server.d.ts +0 -9
  154. package/dist/skills.d.ts +0 -44
  155. package/dist/timeout.d.ts +0 -21
@@ -1,5 +1,7 @@
1
+ import { Effect } from "effect";
1
2
  import { oauthValueStorageKey } from "../auth/downstream-oauth.js";
2
3
  import { resolveBranding } from "../branding.js";
4
+ import { oauthCallbackOutcome, providerErrorReason, } from "../oauth-callback-outcome.js";
3
5
  import { authorizeUiIdentity, mayManageConnector, validateAuthPermissions, loggableValue, msg, } from "./shared.js";
4
6
  function escapeHtml(value) {
5
7
  return value
@@ -8,8 +10,14 @@ function escapeHtml(value) {
8
10
  .replaceAll(">", ">")
9
11
  .replaceAll('"', """);
10
12
  }
11
- /** `body` is escaped — callback params and error messages are attacker-influenced. */
12
- function html(body, status = 200, branding, hasUi = false) {
13
+ /**
14
+ * The callback's one page. It renders a reason from a closed set and that
15
+ * reason's fixed copy, so nothing the request carried — the provider's `error`
16
+ * parameter, an exchange failure's message — can reach the body. Escaping
17
+ * stays anyway: branding and the configured connector id are still strings.
18
+ */
19
+ function html(reason, branding, hasUi = false, connectorId) {
20
+ const outcome = oauthCallbackOutcome(reason, connectorId);
13
21
  const brand = resolveBranding(branding);
14
22
  const title = brand.pageTitle;
15
23
  const owner = brand.ownerName
@@ -52,7 +60,10 @@ function html(body, status = 200, branding, hasUi = false) {
52
60
  a { color: inherit; text-decoration: underline; text-decoration-thickness: 1.5px;
53
61
  text-underline-offset: .22em; }
54
62
  a:hover { text-decoration-color: transparent; }
55
- a:focus-visible { outline: 1px solid #000; outline-offset: 2px; }
63
+ a:focus-visible, summary:focus-visible { outline: 1px solid #000; outline-offset: 2px; }
64
+ summary { cursor: pointer; }
65
+ pre { font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace; margin: .75rem 0 0;
66
+ padding: .75rem; white-space: pre-wrap; border: 1px solid #000; user-select: all; }
56
67
  @media (max-width: 36.99rem) {
57
68
  .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
58
69
  .product { grid-column: 2; }
@@ -66,15 +77,16 @@ function html(body, status = 200, branding, hasUi = false) {
66
77
  ${owner}
67
78
  ${product}
68
79
  </header>
69
- <main class="shell grid">
80
+ <main class="shell grid" data-oauth-callback="${outcome.reason}">
70
81
  <h1>Connection status</h1>
71
82
  <div class="copy">
72
- <p>${escapeHtml(body)}</p>
83
+ <p>${escapeHtml(outcome.message)}</p>
84
+ ${outcome.fixPrompt ? `<details><summary>Fix prompt for a coding agent</summary><pre>${escapeHtml(outcome.fixPrompt)}</pre></details>` : ""}
73
85
  ${hasUi ? `<p><a href="/">Return to ${escapeHtml(brand.productName)}</a></p>` : ""}
74
86
  </div>
75
87
  </main>
76
88
  </body>
77
- </html>`, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
89
+ </html>`, { status: outcome.status, headers: { "Content-Type": "text/html; charset=utf-8" } });
78
90
  }
79
91
  /**
80
92
  * Pay the storage read a real downstream-OAuth refusal pays, on the refusal
@@ -107,16 +119,29 @@ async function equalizeRefusalCost(context) {
107
119
  // Deliberately ignored — see above.
108
120
  }
109
121
  }
110
- export async function routeOAuthCallback(context) {
122
+ /**
123
+ * The callback route as a step of the request's fiber, and the one step that
124
+ * the request's signal does not interrupt.
125
+ *
126
+ * An authorization code is single-use. Once a browser has delivered one, the
127
+ * handoff it consumes, the exchange, and the catalog invalidation after it are
128
+ * one commitment: abandoned halfway, a connector could hold fresh tokens
129
+ * behind a catalog still cached as unauthorized, or a consumed handoff with no
130
+ * exchange behind it. A caller that hangs up mid-exchange loses only the page.
131
+ */
132
+ export function routeOAuthCallback(context) {
133
+ if (!context.path.startsWith("/oauth/callback/"))
134
+ return Effect.succeed(null);
135
+ return Effect.uninterruptible(Effect.promise(() => finishOAuthCallback(context)));
136
+ }
137
+ async function finishOAuthCallback(context) {
111
138
  const { path, url, baseUrl, opts } = context;
112
- if (!path.startsWith("/oauth/callback/"))
113
- return null;
114
139
  const error = url.searchParams.get("error");
115
140
  if (error)
116
- return html(`Authorization denied: ${error}`, 400, opts.branding, Boolean(opts.ui));
141
+ return html(providerErrorReason(error), opts.branding, Boolean(opts.ui));
117
142
  const code = url.searchParams.get("code");
118
143
  if (!code)
119
- return html("Missing authorization code.", 400, opts.branding, Boolean(opts.ui));
144
+ return html("invalid_callback", opts.branding, Boolean(opts.ui));
120
145
  const id = path.slice("/oauth/callback/".length);
121
146
  const state = url.searchParams.get("state");
122
147
  const callbackTarget = await opts.registry.oauthCallbackView(id, state);
@@ -130,8 +155,7 @@ export async function routeOAuthCallback(context) {
130
155
  const connectorContext = callbackRegistry
131
156
  ? callbackRegistry.contextFor(id, baseUrl)
132
157
  : opts.registry.contextFor(id, baseUrl);
133
- const refused = () => html("Authorization could not be completed. Re-run authorization from " +
134
- "connecta and try again.", 400, opts.branding, Boolean(opts.ui));
158
+ const refused = () => html("invalid_callback", opts.branding, Boolean(opts.ui));
135
159
  if (!connector || !connector.finishAuth) {
136
160
  await equalizeRefusalCost(connectorContext);
137
161
  return refused();
@@ -196,15 +220,19 @@ export async function routeOAuthCallback(context) {
196
220
  opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
197
221
  `${loggableValue(id)} with 500: its principal handoff could not be ` +
198
222
  `consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`);
199
- return html("Authorization could not be completed.", 500, opts.branding, Boolean(opts.ui));
223
+ return html("handoff_failed", opts.branding, Boolean(opts.ui), id);
200
224
  }
201
225
  }
202
226
  try {
203
227
  await connector.finishAuth(code, connectorContext, url.searchParams);
204
228
  await callbackRegistry.invalidateStored(id);
205
- return html(`Connected "${id}". You can close this window.`, 200, opts.branding, Boolean(opts.ui));
229
+ return html("connected", opts.branding, Boolean(opts.ui), id);
206
230
  }
207
231
  catch (err) {
208
- return html(`Authorization failed: ${msg(err)}`, 500, opts.branding, Boolean(opts.ui));
232
+ // The page names the reason and nothing else; what the exchange threw can
233
+ // quote a token endpoint's body, so it goes only to the operator log.
234
+ opts.logger.warn(`[connecta] OAuth callback for connector ${loggableValue(id)} failed ` +
235
+ `with 500: the authorization code exchange threw ${loggableValue(msg(err))}.`);
236
+ return html("exchange_failed", opts.branding, Boolean(opts.ui), id);
209
237
  }
210
238
  }
@@ -0,0 +1,83 @@
1
+ // The Effect plumbing the operator and activity data routes share.
2
+ //
3
+ // Each JSON route under /ui/* is one Effect program, run at its Promise edge
4
+ // by serveOperator. A route that settles its answer early — a refusal, or an
5
+ // auth provider's own challenge — fails with an Answer carrying that exact
6
+ // Response, and the edge serves it. That is the whole error model: every
7
+ // status, body, and header is still built by privateJson or by the provider,
8
+ // so the wire format cannot drift from what these routes have always sent.
9
+ // Anything else that goes wrong is a defect and rejects the edge, as a throw
10
+ // from the async handlers did.
11
+ //
12
+ // Effect's HttpApi was measured for this and not used (P1-S18). Declaring
13
+ // these seven endpoints added about 101 KB gzip to ./ui and 131 KB to
14
+ // ./activity, most of it Schema, and matching the wire format would have
15
+ // meant opting out of nearly everything it does: unowned paths fall through
16
+ // to the next module rather than 404, a wrong method is a JSON 405, the
17
+ // Content-Type and size checks run before a body is read, and a credential
18
+ // field error names the field in words the page already shows. Plain routing
19
+ // over one error channel keeps the behavior and costs nothing, because ./ui
20
+ // already carries Effect through its deadlines.
21
+ //
22
+ // Imported by the /ui and /activity graphs only; never from the root entry.
23
+ import { Effect } from "effect";
24
+ import { runEdge } from "../runtime/run.js";
25
+ import { authorize, authorizeUiIdentity, msg, privateJson, validateAuthPermissions, } from "./shared.js";
26
+ /** A response the route has already decided on. */
27
+ export class Answer {
28
+ response;
29
+ _tag = "Answer";
30
+ constructor(response) {
31
+ this.response = response;
32
+ }
33
+ }
34
+ /** End the route with a private JSON error, as every route here does. */
35
+ export function refuse(error, status) {
36
+ return Effect.fail(new Answer(privateJson({ error }, { status })));
37
+ }
38
+ function admitted(result) {
39
+ return Effect.flatMap(Effect.promise(() => result), (authz) => authz.ok ? Effect.succeed(authz) : Effect.fail(new Answer(authz.response)));
40
+ }
41
+ /** Any configured caller. The gate's refusal, challenge headers and all, is the answer. */
42
+ export function authorized({ request, baseUrl, opts, runtimeContext }, partitionIdentity = true) {
43
+ return Effect.suspend(() => admitted(authorize(request, baseUrl, opts.auth, runtimeContext, opts.identity, partitionIdentity)));
44
+ }
45
+ /** A signed-in person, for a route that changes how a connector authenticates. */
46
+ export function authorizedPerson({ request, baseUrl, opts, runtimeContext }, purpose) {
47
+ return Effect.suspend(() => admitted(authorizeUiIdentity(request, baseUrl, opts.auth, purpose, runtimeContext, opts.identity)));
48
+ }
49
+ /** The partition and tool grants an identity carries into a registry view. */
50
+ export function scopeFor(authz, connectorIds = authz.connectorIds) {
51
+ return {
52
+ connectorIds,
53
+ ...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
54
+ ...(authz.guardedToolAccess ? { guardedToolAccess: authz.guardedToolAccess } : {}),
55
+ ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
56
+ ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
57
+ };
58
+ }
59
+ /**
60
+ * The registry as this identity may see it. A configured permission naming a
61
+ * connector that does not exist refuses the whole view, management rights
62
+ * included, rather than quietly granting what remains.
63
+ */
64
+ export function visibleRegistry({ opts }, authz) {
65
+ return Effect.try({
66
+ try: () => {
67
+ validateAuthPermissions(authz, opts.registry);
68
+ return opts.registry.scoped(scopeFor(authz));
69
+ },
70
+ catch: (error) => new Answer(privateJson({ error: msg(error) }, { status: 403 })),
71
+ });
72
+ }
73
+ /**
74
+ * Run one route program to its Response.
75
+ *
76
+ * A read passes the request's signal, so a caller who leaves stops whatever
77
+ * the read was waiting on. A mutation passes none: once a vault write or an
78
+ * OAuth disconnect has started, the cache invalidation that follows it must
79
+ * run too, whether or not anyone is still waiting for the answer.
80
+ */
81
+ export function serveOperator(program, signal) {
82
+ return runEdge(Effect.catch(program, (answer) => Effect.succeed(answer.response)), signal ? { signal } : undefined);
83
+ }
@@ -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;
@@ -37,6 +39,17 @@ export interface ServerOptions {
37
39
  maxHostCalls?: number | undefined;
38
40
  /** Deadline per execute_code host call. Default 15_000. */
39
41
  hostCallTimeoutMs?: number | undefined;
42
+ /** Hard ceiling on one execute_code run, outside the sandbox. Default 120_000. */
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;
40
53
  /** Required sandbox backing the execute_code meta-tool. */
41
54
  executor: Executor;
42
55
  /** Sanitized identity of the configured sandbox, when it has one. */
@@ -48,6 +61,8 @@ export interface ServerOptions {
48
61
  /** Optional browser routes, with no implementation import in core. */
49
62
  ui?: OperatorSurface | undefined;
50
63
  activityModule?: ActivityModule | undefined;
64
+ /** Optional team pages; their routes mount only beside `ui`. */
65
+ artifactsModule?: ArtifactsModule | undefined;
51
66
  /** Optional browser UI and OAuth result-page labels. */
52
67
  branding?: ConnectaBranding | undefined;
53
68
  }
@@ -84,6 +99,8 @@ export declare function authorize(request: Request, baseUrl: string, auth: Inbou
84
99
  connectorIds: "all" | readonly string[];
85
100
  /** Per-connector tool allowlist for connectors granted by address only. */
86
101
  toolAccess?: ToolAccess;
102
+ /** Granted addresses that still require the catalog's read-only hint. */
103
+ guardedToolAccess?: ToolAccess;
87
104
  operator: boolean;
88
105
  credentialAdministration: ConnectorPermission;
89
106
  personalConnection: ConnectorPermission;
@@ -106,6 +123,13 @@ type AuthorizedIdentity = Extract<Awaited<ReturnType<typeof authorize>>, {
106
123
  }>;
107
124
  /** Unknown configured ids refuse the complete view, including management rights. */
108
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;
109
133
  export declare function mayManageConnector(authz: AuthorizedIdentity, connector: {
110
134
  id: string;
111
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;
@@ -0,0 +1,211 @@
1
+ // The Effect core of the operator payload: `buildUiData` in src/ui.ts is its
2
+ // Promise face, and the `/ui/connectors/<id>` route yields it directly.
3
+ //
4
+ // Every visible connector is one slot of a bounded fan-out: its probe runs
5
+ // under a deadline, any failure becomes that connector's error row rather
6
+ // than the payload's, and its connector scope closes as the slot ends,
7
+ // however it ends.
8
+ import { Effect } from "effect";
9
+ import { resolveDiscoveryConcurrency } from "../concurrency.js";
10
+ import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentialShape, } from "../credential-rules.js";
11
+ import { closeScope } from "../runtime/connector-scope.js";
12
+ import { withDeadlineEffect } from "../runtime/run.js";
13
+ import { isApprovalExempt, NO_EXEMPTIONS, } from "../tool-safety.js";
14
+ import { uiProblemFor, uiToolSafety } from "../ui.js";
15
+ import { CONNECTA_VERSION } from "../version.js";
16
+ const DETAIL_TIMEOUT_MS = 30_000;
17
+ const DETAIL_TIMED_OUT = "Connection details timed out. Retry this connection.";
18
+ function attempt(operation) {
19
+ return Effect.tryPromise({ try: operation, catch: (error) => error });
20
+ }
21
+ export function uiData(registry, baseUrl, options) {
22
+ return Effect.suspend(() => {
23
+ const requestScope = {};
24
+ const vault = options.credentialVault;
25
+ const owner = options.personalCredentialOwner;
26
+ /**
27
+ * A connector's status message stops here. It can quote a downstream error
28
+ * body — and a downstream error body can quote the secret it just rejected —
29
+ * so the payload carries only the classified `problem` and the raw text goes
30
+ * to the deployment's log, where an operator debugging the failure already
31
+ * looks. An `ok` status's message is informational and simply dropped.
32
+ */
33
+ const logStatus = (id, state, message) => {
34
+ if (!message || state === "ok")
35
+ return;
36
+ const logger = registry.contextFor(id, baseUrl, requestScope).logger;
37
+ const line = `[connecta] connector "${id}" operator status ${state}: ${message}`;
38
+ if (state === "auth_required")
39
+ logger.info(line);
40
+ else
41
+ logger.warn(line);
42
+ };
43
+ // Status first, then the catalog only when status is ok. A failed status
44
+ // read is an error status, not a failed row: the row still reports the
45
+ // connector's credential.
46
+ const observe = (c, drift, signal) => {
47
+ if (drift) {
48
+ return Effect.succeed({
49
+ status: { state: "auth_required", message: drift },
50
+ tools: [],
51
+ catalogFailed: false,
52
+ });
53
+ }
54
+ return Effect.gen(function* () {
55
+ const status = yield* attempt(() => registry.statusFor(c.id, baseUrl, requestScope, { signal }));
56
+ if (status.state !== "ok" || signal.aborted) {
57
+ return { status, tools: [], catalogFailed: false };
58
+ }
59
+ return yield* attempt(async () => (await registry.getTools(c.id, baseUrl, requestScope, { signal })).map((t) => ({
60
+ name: t.name,
61
+ address: `${c.id}.${t.name}`,
62
+ ...(t.description ? { description: t.description } : {}),
63
+ safety: uiToolSafety(t, isApprovalExempt(options.approval ?? NO_EXEMPTIONS, c, t.name, t)),
64
+ }))).pipe(Effect.map((tools) => ({ status, tools, catalogFailed: false })),
65
+ // The registry owns the failed catalog observation; the page only
66
+ // needs to know there was one.
67
+ Effect.catch(() => Effect.succeed({ status, tools: [], catalogFailed: !signal.aborted })));
68
+ }).pipe(Effect.catch((error) => Effect.succeed({
69
+ status: {
70
+ state: "error",
71
+ message: error instanceof Error
72
+ ? error.message
73
+ : "Connection details unavailable",
74
+ },
75
+ tools: [],
76
+ catalogFailed: false,
77
+ })));
78
+ };
79
+ // The credential card, for someone who may manage this connector's auth.
80
+ // Never fails: a vault that cannot be read is the card's own error.
81
+ const credentialFor = (c) => {
82
+ const mayManageAuth = options.mayManage?.(c.id) ??
83
+ (c.authScope === "personal" ? Boolean(owner) : options.oauthManagement);
84
+ const declared = c.credential;
85
+ if (!declared || !vault || !mayManageAuth)
86
+ return Effect.succeed(undefined);
87
+ // One rule, shared with the test route: only the hook matching the
88
+ // declared credential shape can run, so the button is offered only
89
+ // where a click can succeed (src/credentials.ts).
90
+ const testRule = credentialTestRule(c);
91
+ const credentialFields = (metadata) => declared.fields?.map((field) => {
92
+ const fieldMetadata = metadata?.fields?.[field.name];
93
+ return {
94
+ name: field.name,
95
+ label: field.label,
96
+ ...(field.description ? { description: field.description } : {}),
97
+ ...(field.placeholder ? { placeholder: field.placeholder } : {}),
98
+ inputType: field.inputType ?? "password",
99
+ configured: Boolean(fieldMetadata),
100
+ ...(fieldMetadata
101
+ ? {
102
+ lastFour: fieldMetadata.lastFour,
103
+ updatedAt: fieldMetadata.updatedAt,
104
+ }
105
+ : {}),
106
+ };
107
+ });
108
+ const card = {
109
+ label: declared.label,
110
+ ...(declared.description ? { description: declared.description } : {}),
111
+ ...(declared.placeholder ? { placeholder: declared.placeholder } : {}),
112
+ };
113
+ return attempt(async () => {
114
+ const metadata = await vault.metadata(c.id, c.authScope === "personal" ? owner : undefined);
115
+ const fields = credentialFields(metadata);
116
+ const shape = storedCredentialShape(declared, metadata?.fields ?? null);
117
+ return {
118
+ ...card,
119
+ ...(fields?.length ? { fields } : {}),
120
+ configured: shape.state === "valid",
121
+ removable: Boolean(metadata),
122
+ ...(metadata
123
+ ? { lastFour: metadata.lastFour, updatedAt: metadata.updatedAt }
124
+ : {}),
125
+ testable: testRule.mode !== null && shape.state !== "mismatch",
126
+ ...(shape.state === "mismatch"
127
+ ? { error: shape.message, problem: "credential_mismatch" }
128
+ : {}),
129
+ // A dropped field leaves its secret in the vault, and the field
130
+ // list below only renders fields the connector still declares —
131
+ // so without this line there is nowhere an operator could see it.
132
+ ...(shape.state === "valid" && shape.undeclared.length
133
+ ? { notice: describeUndeclaredCredentialFields(shape.undeclared) }
134
+ : {}),
135
+ };
136
+ }).pipe(Effect.catch(() => {
137
+ const fields = credentialFields();
138
+ return Effect.succeed({
139
+ ...card,
140
+ ...(fields?.length ? { fields } : {}),
141
+ configured: false,
142
+ removable: true,
143
+ testable: testRule.mode !== null,
144
+ error: "Stored credential could not be read.",
145
+ problem: "credential_unreadable",
146
+ });
147
+ }));
148
+ };
149
+ const detail = (c, signal) => Effect.gen(function* () {
150
+ const drift = yield* attempt(() => registry.credentialDriftFor(c.id));
151
+ // One deadline covers the whole row. The status and catalog reads once
152
+ // had a second one of the same length inside it, which could only fire
153
+ // after this one had already ended the row.
154
+ const { status, tools, catalogFailed } = yield* observe(c, drift, signal);
155
+ const credential = yield* credentialFor(c);
156
+ logStatus(c.id, status.state, status.message);
157
+ const problem = uiProblemFor(c, status.state, {
158
+ credentialDrift: Boolean(drift),
159
+ catalogFailed,
160
+ });
161
+ return {
162
+ id: c.id,
163
+ authScope: c.authScope ?? "shared",
164
+ ...(c.title ? { title: c.title } : {}),
165
+ ...(c.description !== undefined ? { description: c.description } : {}),
166
+ status: status.state,
167
+ ...(problem ? { problem } : {}),
168
+ toolCount: tools.length,
169
+ tools,
170
+ // Counts only, and only when a refresh in this runtime produced them.
171
+ // `Registry.statusFor` already rebuilt the report through
172
+ // `boundedCatalogDrift`, so what lands here cannot carry a name or a
173
+ // schema even if the plugin seam returned one.
174
+ ...(status.catalogDrift ? { catalogDrift: status.catalogDrift } : {}),
175
+ ...(status.catalogAccess ? { catalogAccess: status.catalogAccess } : {}),
176
+ oauth: Boolean(c.startAuth && c.disconnectAuth),
177
+ ...(credential ? { credential } : {}),
178
+ };
179
+ });
180
+ const unavailable = (c, error) => Effect.sync(() => {
181
+ logStatus(c.id, "failed", error instanceof Error ? error.message : String(error));
182
+ return {
183
+ id: c.id,
184
+ ...(c.title ? { title: c.title } : {}),
185
+ authScope: c.authScope ?? "shared",
186
+ status: "error",
187
+ problem: "connector_unavailable",
188
+ oauth: Boolean(c.startAuth && c.disconnectAuth),
189
+ toolCount: 0,
190
+ tools: [],
191
+ };
192
+ });
193
+ const row = (c) => withDeadlineEffect((signal) => detail(c, signal), {
194
+ timeoutMs: options.timeoutMs ?? DETAIL_TIMEOUT_MS,
195
+ ...(options.signal ? { signal: options.signal } : {}),
196
+ timeoutError: new Error(DETAIL_TIMED_OUT),
197
+ }).pipe(
198
+ // A hook that throws where it should reject fails its own row too.
199
+ Effect.catchDefect((defect) => Effect.fail(defect)), Effect.catch((error) => unavailable(c, error)), Effect.ensuring(Effect.suspend(() => closeScope(c, registry.contextFor(c.id, baseUrl, requestScope), options.defer))));
200
+ return Effect.forEach(registry.listConnectors(), row, {
201
+ concurrency: resolveDiscoveryConcurrency(options.discoveryConcurrency),
202
+ }).pipe(Effect.map((connectors) => ({
203
+ serverInfo: options.serverInfo,
204
+ connectaVersion: CONNECTA_VERSION,
205
+ connectors,
206
+ activityEnabled: options.activityEnabled,
207
+ credentialManagement: options.credentialManagement,
208
+ oauthManagement: options.oauthManagement || Boolean(owner),
209
+ })));
210
+ });
211
+ }