@zackbart/connecta 0.22.3 → 0.24.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 (94) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +19 -11
  4. package/dist/activity-friction.d.ts +3 -0
  5. package/dist/activity-friction.js +19 -0
  6. package/dist/activity.d.ts +11 -2
  7. package/dist/activity.js +15 -19
  8. package/dist/auth/downstream-oauth.d.ts +2 -1
  9. package/dist/auth/downstream-oauth.js +10 -1
  10. package/dist/branding.d.ts +67 -0
  11. package/dist/branding.js +176 -0
  12. package/dist/catalog-service.d.ts +1 -6
  13. package/dist/catalog-service.js +3 -53
  14. package/dist/connectors/remote-mcp.js +3 -5
  15. package/dist/credential-contract.d.ts +24 -0
  16. package/dist/credential-contract.js +1 -0
  17. package/dist/credential-rules.d.ts +85 -0
  18. package/dist/credential-rules.js +107 -0
  19. package/dist/credentials.d.ts +4 -100
  20. package/dist/credentials.js +3 -107
  21. package/dist/errors.d.ts +1 -1
  22. package/dist/execute.d.ts +5 -52
  23. package/dist/execute.js +40 -347
  24. package/dist/executors/quickjs-protocol.d.ts +0 -7
  25. package/dist/executors/quickjs-protocol.js +2 -10
  26. package/dist/executors/quickjs.js +1 -1
  27. package/dist/index.d.ts +23 -56
  28. package/dist/index.js +30 -58
  29. package/dist/invocation.d.ts +0 -33
  30. package/dist/invocation.js +56 -124
  31. package/dist/meta-tools.d.ts +7 -6
  32. package/dist/meta-tools.js +14 -21
  33. package/dist/module-contracts.d.ts +19 -0
  34. package/dist/module-contracts.js +1 -0
  35. package/dist/operator-ui/generated.js +2 -2
  36. package/dist/operator-ui/model.d.ts +6 -3
  37. package/dist/operator-ui/view.d.ts +2 -18
  38. package/dist/operator-ui/view.js +3 -20
  39. package/dist/registry.d.ts +4 -1
  40. package/dist/registry.js +8 -6
  41. package/dist/routes/activity.js +1 -1
  42. package/dist/routes/credentials.js +5 -2
  43. package/dist/routes/mcp.js +7 -51
  44. package/dist/routes/oauth-management.d.ts +2 -0
  45. package/dist/routes/oauth-management.js +108 -0
  46. package/dist/routes/oauth.d.ts +0 -1
  47. package/dist/routes/oauth.js +21 -121
  48. package/dist/routes/shared.d.ts +19 -17
  49. package/dist/routes/shared.js +48 -44
  50. package/dist/routes/ui.js +36 -33
  51. package/dist/server.d.ts +1 -2
  52. package/dist/server.js +7 -45
  53. package/dist/skills.d.ts +1 -1
  54. package/dist/skills.js +55 -19
  55. package/dist/types.d.ts +3 -15
  56. package/dist/ui.d.ts +15 -70
  57. package/dist/ui.js +176 -317
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/documentation/architecture.md +31 -26
  61. package/documentation/auth.md +65 -111
  62. package/documentation/call-admission.md +10 -11
  63. package/documentation/cloudflare.md +1 -1
  64. package/documentation/code-mode.md +65 -260
  65. package/documentation/connectors.md +8 -6
  66. package/documentation/linear.md +1 -1
  67. package/documentation/meta-tools.md +37 -18
  68. package/documentation/mixpanel.md +1 -1
  69. package/documentation/notion.md +2 -2
  70. package/documentation/operations.md +19 -23
  71. package/documentation/operator-ui.md +82 -104
  72. package/documentation/optional-modules-upgrade.md +243 -0
  73. package/documentation/provider-conventions.md +7 -5
  74. package/documentation/revenuecat.md +1 -1
  75. package/documentation/storage-and-credentials.md +59 -40
  76. package/documentation/stripe.md +1 -1
  77. package/documentation/upgrading.md +83 -8
  78. package/ethos.md +36 -43
  79. package/examples/worker/AGENTS.md +3 -1
  80. package/examples/worker/README.md +68 -84
  81. package/examples/worker/src/d1-activity.ts +1 -1
  82. package/examples/worker/src/index.ts +11 -6
  83. package/package.json +18 -2
  84. package/templates/node/AGENTS.md +8 -6
  85. package/templates/node/README.md +56 -67
  86. package/templates/node/package.json +1 -1
  87. package/templates/node/src/file-activity.ts +1 -1
  88. package/templates/node/src/index.ts +11 -12
  89. package/dist/access-tokens.d.ts +0 -31
  90. package/dist/access-tokens.js +0 -236
  91. package/dist/apps-shell.d.ts +0 -37
  92. package/dist/apps-shell.js +0 -174
  93. package/dist/routes/access-tokens.d.ts +0 -6
  94. package/dist/routes/access-tokens.js +0 -83
@@ -1,13 +1,9 @@
1
1
  export const OPERATOR_PAGES = [
2
2
  "connections",
3
- "credentials",
4
- "tokens",
5
3
  "activity",
6
4
  ];
7
5
  export const PAGE_META = {
8
6
  connections: { path: "/", label: "Connections" },
9
- credentials: { path: "/credentials", label: "Credentials" },
10
- tokens: { path: "/tokens", label: "Access tokens" },
11
7
  activity: { path: "/activity", label: "Activity" },
12
8
  };
13
9
  export function pageForPath(path) {
@@ -45,12 +41,6 @@ function identityScopedState() {
45
41
  credentialNotice: null,
46
42
  credentialEditing: null,
47
43
  credentialBusy: null,
48
- tokenPhase: "idle",
49
- tokenNotice: null,
50
- tokens: [],
51
- createdToken: null,
52
- tokenRenaming: null,
53
- tokenBusy: false,
54
44
  activityPhase: "idle",
55
45
  activityNotice: null,
56
46
  activityEvents: [],
@@ -82,9 +72,6 @@ export function withPage(state, page) {
82
72
  return {
83
73
  ...state,
84
74
  page,
85
- createdToken: null,
86
- tokenRenaming: null,
87
- tokenNotice: null,
88
75
  credentialEditing: null,
89
76
  credentialNotice: null,
90
77
  };
@@ -94,17 +81,13 @@ export function credentialUnavailableCopy(capability) {
94
81
  return "No connectors declare operator-managed credential slots. Connector credentials remain configuration-as-code until a slot is declared.";
95
82
  }
96
83
  if (capability === "vault_not_configured") {
97
- return "Credential storage is not configured. Set credentials.encryptionKey before managing connector credentials here.";
84
+ return "Credential storage is not configured. Configure a vault before managing connector credentials here.";
98
85
  }
99
86
  return "Credential management requires an interactive user. Bearer-authenticated sessions can inspect connections but cannot manage stored credentials.";
100
87
  }
101
- export function accessTokenUnavailableCopy(capability) {
102
- if (capability === "not_configured") {
103
- return "Access tokens are not configured for this deployment. Add accessTokens to the deployment configuration to enable them.";
104
- }
105
- return "Access token management requires an eligible interactive operator. A Bearer token can connect to MCP, but it cannot create or revoke other tokens.";
106
- }
107
88
  export function connectorStatusLabel(status) {
89
+ if (status === "loading")
90
+ return "Loading details";
108
91
  if (status === "ok")
109
92
  return "Connected";
110
93
  if (status === "auth_required")
@@ -1,7 +1,7 @@
1
+ import type { CredentialVault } from "./credential-contract.js";
1
2
  import type { CatalogDriftReport, Connector, ConnectorContext, ConnectorStatus, KVStorage, Logger, ToolDef } from "./types.js";
2
3
  import { type DeferredWork } from "./connector-scope.js";
3
4
  import { type CatalogDriftActivityContext } from "./activity.js";
4
- import { type CredentialVault } from "./credentials.js";
5
5
  import { type CallAdmissionPermit, type ConnectorCallAdmissionSnapshot } from "./call-admission.js";
6
6
  /**
7
7
  * Split `"<connectorId>.<toolName>"` on the first dot. Connector ids contain
@@ -47,6 +47,7 @@ export interface RegistryOptions {
47
47
  storage: KVStorage;
48
48
  logger: Logger;
49
49
  credentialVault?: CredentialVault | undefined;
50
+ credentialUi?: boolean | undefined;
50
51
  /** Internal owner partition used by a personal registry. */
51
52
  credentialOwner?: string | undefined;
52
53
  /** Internal child registries skip deployment-wide construction warnings. */
@@ -84,6 +85,7 @@ export interface CatalogReadOptions {
84
85
  * or its construction-only methods.
85
86
  */
86
87
  export interface RegistryView {
88
+ credentialUiAvailable(): boolean;
87
89
  /** Deployment-wide result-size cap threaded to the meta-tools. */
88
90
  readonly maxResultBytes: number;
89
91
  listConnectors(): Connector[];
@@ -217,6 +219,7 @@ export declare class Registry implements RegistryView {
217
219
  * separate from any connector's `conn:<id>:` namespace. Backs get_result.
218
220
  */
219
221
  resultsStorage(): KVStorage;
222
+ credentialUiAvailable(): boolean;
220
223
  bindOAuthHandoff(): Promise<void>;
221
224
  observedOutputSchema(connectorId: string, definition: ToolDef): ToolDef["outputSchema"] | undefined;
222
225
  observeOutputShape(connectorId: string, definition: ToolDef, value: unknown): void;
package/dist/registry.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { closeConnectorScope, } from "./connector-scope.js";
2
- import { recordCatalogDriftActivity, } from "./activity.js";
3
- import { storedCredentialShape, } from "./credentials.js";
2
+ import { storedCredentialShape, } from "./credential-rules.js";
4
3
  import { ConnectorCallError, msg } from "./errors.js";
5
4
  import { ConnectorCallAdmissionController, } from "./call-admission.js";
6
5
  import { boundedCatalogDrift } from "./catalog-drift.js";
@@ -131,6 +130,10 @@ export class Registry {
131
130
  this.persistToolCatalog = opts.persistToolCatalog ?? true;
132
131
  this.maxResultBytes = resolveMaxResultBytes(opts.maxResultBytes, DEFAULT_MAX_RESULT_BYTES);
133
132
  for (const c of connectors) {
133
+ if ("handleRequest" in c) {
134
+ throw new Error(`Connector "${c.id}" declares removed handleRequest. ` +
135
+ "Move custom HTTP routes into the deployment's fetch handler.");
136
+ }
134
137
  if (!ID_RE.test(c.id)) {
135
138
  throw new Error(`Invalid connector id "${c.id}": must match ${ID_RE.source}`);
136
139
  }
@@ -389,7 +392,7 @@ export class Registry {
389
392
  this.reportedDrift.set(connector.id, counts);
390
393
  if (previous === undefined && clean)
391
394
  return;
392
- recordCatalogDriftActivity(this.opts.catalogDriftActivity
395
+ this.opts.catalogDriftActivity?.recordDrift?.(this.opts.catalogDriftActivity
393
396
  ? { ...this.opts.catalogDriftActivity, logger: this.opts.logger }
394
397
  : undefined, { connectorId: connector.id, ...counts });
395
398
  }
@@ -405,6 +408,7 @@ export class Registry {
405
408
  resultsStorage() {
406
409
  return namespaced(this.opts.storage, "results:");
407
410
  }
411
+ credentialUiAvailable() { return Boolean(this.opts.credentialUi); }
408
412
  async bindOAuthHandoff() {
409
413
  // Shared OAuth already resolves in deployment-wide connector storage.
410
414
  }
@@ -986,9 +990,6 @@ export class Registry {
986
990
  // observation when this runtime has not made one.
987
991
  const boundedStatus = {
988
992
  state: status.state,
989
- ...(status.authorizationUrl !== undefined
990
- ? { authorizationUrl: status.authorizationUrl }
991
- : {}),
992
993
  ...(status.message !== undefined ? { message: status.message } : {}),
993
994
  };
994
995
  return {
@@ -1123,6 +1124,7 @@ class ScopedRegistryView {
1123
1124
  const registry = this.registryFor(id);
1124
1125
  return registry ? registry.invalidateStored(id) : Promise.resolve();
1125
1126
  }
1127
+ credentialUiAvailable() { return this.root.credentialUiAvailable(); }
1126
1128
  async bindOAuthHandoff(id, authorizationUrl) {
1127
1129
  const connector = this.getConnector(id);
1128
1130
  if (connector?.authScope !== "personal" || !this.scope.principalKey)
@@ -137,7 +137,7 @@ export async function routeActivity(context) {
137
137
  const authz = await authorize(request, baseUrl, opts.auth, runtimeContext, opts.identity, false);
138
138
  if (!authz.ok)
139
139
  return authz.response;
140
- if (opts.identity?.operatorAccess && !authz.operator) {
140
+ if (!authz.operator) {
141
141
  return privateJson({ error: "operator access required" }, { status: 403 });
142
142
  }
143
143
  if (opts.activityReadGate &&
@@ -1,5 +1,5 @@
1
- import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape, } from "../credentials.js";
2
- import { authorizeUiIdentity, isSameOrigin, msg, privateJson, } from "./shared.js";
1
+ import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape, } from "../credential-rules.js";
2
+ import { authorizeUiIdentity, mayManageConnector, validateAuthPermissions, isSameOrigin, msg, privateJson, } from "./shared.js";
3
3
  async function readCredentialInput(request, config) {
4
4
  if (!request.headers
5
5
  .get("content-type")
@@ -78,6 +78,7 @@ async function handleCredentialRequest(context, connectorId, action) {
78
78
  return authz.response;
79
79
  let registry;
80
80
  try {
81
+ validateAuthPermissions(authz, opts.registry);
81
82
  registry = opts.registry.scoped({
82
83
  connectorIds: authz.connectorIds,
83
84
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
@@ -91,6 +92,8 @@ async function handleCredentialRequest(context, connectorId, action) {
91
92
  if (!connector?.credential) {
92
93
  return privateJson({ error: "unknown credential slot" }, { status: 404 });
93
94
  }
95
+ if (!mayManageConnector(authz, connector))
96
+ return privateJson({ error: "credential management is not permitted" }, { status: 403 });
94
97
  const personal = connector.authScope === "personal";
95
98
  if (personal && !authz.principalKey) {
96
99
  return privateJson({ error: "forbidden" }, { status: 403 });
@@ -1,11 +1,10 @@
1
1
  import { createMcpHandler, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server";
2
- import { MCP_APPS_EXTENSION, PROGRAM_UI_MIME_TYPE, PROGRAM_UI_RESOURCE_URI, PROGRAM_UI_SHELL_HTML, } from "../apps-shell.js";
3
2
  import { registerExecuteTool } from "../execute.js";
4
3
  import { ExecutorAdmissionError, } from "../executor-admission.js";
5
4
  import { registerMetaTools } from "../meta-tools.js";
6
5
  import { instructionsFor } from "../skills.js";
7
6
  import { msg } from "../errors.js";
8
- import { authorize, } from "./shared.js";
7
+ import { authorize, mayManageConnector, validateAuthPermissions, } from "./shared.js";
9
8
  export const MCP_CORS_HEADERS = {
10
9
  "Access-Control-Allow-Origin": "*",
11
10
  "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
@@ -149,56 +148,10 @@ function toolkitRetired(logger) {
149
148
  },
150
149
  });
151
150
  }
152
- /**
153
- * U5: one static template, served by a handler that answers exactly one URI
154
- * and fails on every other. Registering it is also what declares the
155
- * `resources` capability — which is why `resources/list` has to answer, and
156
- * why it answers with nothing. That is the Apps spec's permitted omission of
157
- * UI-only resources from listing, taken exactly: the capability stays honest
158
- * because the method answers, and nothing downstream is ever listed or
159
- * aggregated. Widening this handler to proxy downstream templates is a
160
- * decision (see the design record), not a diff.
161
- */
162
- function registerProgramUiResource(server) {
163
- server.registerResource("connecta-program-ui", PROGRAM_UI_RESOURCE_URI, {
164
- title: "connecta program view",
165
- description: "The MCP Apps shell that renders HTML an execute_code program handed connecta.ui.",
166
- mimeType: PROGRAM_UI_MIME_TYPE,
167
- }, (uri) => ({
168
- contents: [
169
- {
170
- uri: uri.href,
171
- mimeType: PROGRAM_UI_MIME_TYPE,
172
- text: PROGRAM_UI_SHELL_HTML,
173
- },
174
- ],
175
- }));
176
- // The SDK's generated listing would advertise the template it just
177
- // registered. Replace it rather than accept that: the URI reaches the host
178
- // through tool metadata, so the listing has nothing to carry.
179
- server.server.setRequestHandler("resources/list", () => ({ resources: [] }));
180
- }
181
- async function serveMcp(request, opts, baseUrl, actor, registry, runtimeContext) {
151
+ async function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtimeContext) {
182
152
  const createServer = () => {
183
153
  const server = new McpServer(opts.serverInfo, {
184
154
  instructions: instructionsFor(),
185
- // U11: the Apps extension must be explicitly negotiated, and a
186
- // conforming client acts on an extension only when both sides declare
187
- // it — without this line no host reads execute_code's _meta.ui, no host
188
- // fetches the shell, and the whole design is inert. This is the one
189
- // extension connecta advertises; the versioned extensions framework
190
- // stays declined as a general surface (https://github.com/zackbart/connecta/blob/main/records/mcp-2026-07-28.md).
191
- capabilities: {
192
- extensions: {
193
- [MCP_APPS_EXTENSION]: { mimeTypes: [PROGRAM_UI_MIME_TYPE] },
194
- },
195
- // Registering the shell below declares `resources` on its own, but it
196
- // would default `listChanged` to true. Connecta serves one build-time
197
- // template and never sends a list_changed notification, so say so:
198
- // a client that subscribes on the strength of that flag would wait
199
- // forever for an event this server has no way to produce.
200
- resources: { listChanged: false },
201
- },
202
155
  cacheHints: {
203
156
  "tools/list": {
204
157
  ttlMs: 3_600_000,
@@ -206,10 +159,10 @@ async function serveMcp(request, opts, baseUrl, actor, registry, runtimeContext)
206
159
  },
207
160
  },
208
161
  });
209
- registerProgramUiResource(server);
210
162
  const activity = opts.activity
211
163
  ? {
212
164
  sink: opts.activity,
165
+ recordTool: opts.activityModule?.recordTool,
213
166
  actor,
214
167
  requestId: crypto.randomUUID(),
215
168
  serverInfo: opts.serverInfo,
@@ -224,6 +177,8 @@ async function serveMcp(request, opts, baseUrl, actor, registry, runtimeContext)
224
177
  : undefined;
225
178
  registerMetaTools(server, registry, {
226
179
  baseUrl,
180
+ canManageAuth,
181
+ credentialHandoffUrl: opts.ui?.credentialHandoffUrl(baseUrl),
227
182
  ...(activity ? { activity } : {}),
228
183
  ...(opts.defaultToolTimeoutMs !== undefined
229
184
  ? { defaultToolTimeoutMs: opts.defaultToolTimeoutMs }
@@ -336,6 +291,7 @@ export function createMcpRoute(opts) {
336
291
  }
337
292
  let scopedRegistry;
338
293
  try {
294
+ validateAuthPermissions(authz, opts.registry);
339
295
  scopedRegistry = opts.registry.scoped({
340
296
  connectorIds: authz.connectorIds,
341
297
  ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
@@ -351,7 +307,7 @@ export function createMcpRoute(opts) {
351
307
  if (new URL(request.url).searchParams.has("toolkit")) {
352
308
  return releaseAdmissionWithResponse(withMcpCors(toolkitRetired(opts.logger)), admission, request.signal);
353
309
  }
354
- return releaseAdmissionWithResponse(withMcpCors(await serveMcp(request, opts, baseUrl, authz.actor, scopedRegistry, runtimeContext)), admission, request.signal);
310
+ return releaseAdmissionWithResponse(withMcpCors(await serveMcp(request, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); }, runtimeContext)), admission, request.signal);
355
311
  }
356
312
  catch (error) {
357
313
  admission.release();
@@ -0,0 +1,2 @@
1
+ import { type RouteContext } from "./shared.js";
2
+ export declare function routeOAuthManagement(context: RouteContext): Promise<Response | null>;
@@ -0,0 +1,108 @@
1
+ import { closeConnectorScope } from "../connector-scope.js";
2
+ import { isSafeHttpUrl } from "../ui.js";
3
+ import { authorizeUiIdentity, mayManageConnector, validateAuthPermissions, isSameOrigin, msg, privateJson, } from "./shared.js";
4
+ async function handleOAuthManagementRequest(context, connectorId) {
5
+ const { request, baseUrl, opts, defer } = context;
6
+ if (!isSameOrigin(request, baseUrl)) {
7
+ return privateJson({ error: "same-origin request required" }, { status: 403 });
8
+ }
9
+ const authz = await authorizeUiIdentity(request, baseUrl, opts.auth, "OAuth management", context.runtimeContext, opts.identity);
10
+ if (!authz.ok)
11
+ return authz.response;
12
+ let registry;
13
+ try {
14
+ validateAuthPermissions(authz, opts.registry);
15
+ registry = opts.registry.scoped({
16
+ connectorIds: authz.connectorIds,
17
+ ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
18
+ ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
19
+ });
20
+ }
21
+ catch (error) {
22
+ return privateJson({ error: msg(error) }, { status: 403 });
23
+ }
24
+ const connector = registry.getConnector(connectorId);
25
+ if (!connector?.disconnectAuth || !connector.startAuth) {
26
+ return privateJson({ error: "unknown OAuth connector" }, { status: 404 });
27
+ }
28
+ if (!mayManageConnector(authz, connector))
29
+ return privateJson({ error: "credential management is not permitted" }, { status: 403 });
30
+ if (connector.authScope === "personal" && !authz.principalKey) {
31
+ return privateJson({ error: "forbidden" }, { status: 403 });
32
+ }
33
+ if (request.method !== "DELETE" && request.method !== "POST") {
34
+ return privateJson({ error: "method not allowed" }, { status: 405 });
35
+ }
36
+ const requestScope = {};
37
+ const ctx = registry.contextFor(connectorId, baseUrl, requestScope);
38
+ try {
39
+ let result;
40
+ let operationError;
41
+ try {
42
+ if (request.method === "DELETE") {
43
+ await connector.disconnectAuth(ctx);
44
+ }
45
+ else {
46
+ result = await connector.startAuth(ctx, { force: true });
47
+ if (result.authorizationUrl) {
48
+ await registry.bindOAuthHandoff(connectorId, result.authorizationUrl);
49
+ }
50
+ }
51
+ }
52
+ catch (error) {
53
+ operationError = error;
54
+ }
55
+ // The old grant and its cached catalog are invalid after either operation,
56
+ // including a partially failed physical cleanup whose epoch fence succeeded.
57
+ try {
58
+ await registry.invalidateStored(connectorId);
59
+ }
60
+ catch (error) {
61
+ operationError ??= error;
62
+ }
63
+ if (operationError) {
64
+ return privateJson({ error: msg(operationError) }, { status: 400 });
65
+ }
66
+ if (request.method === "DELETE") {
67
+ return new Response(null, {
68
+ status: 204,
69
+ headers: {
70
+ "Cache-Control": "no-store",
71
+ "Referrer-Policy": "no-referrer",
72
+ },
73
+ });
74
+ }
75
+ const authorizationUrl = isSafeHttpUrl(result.authorizationUrl)
76
+ ? result.authorizationUrl
77
+ : undefined;
78
+ if (result.state === "error") {
79
+ return privateJson({ error: result.message || "OAuth authorization could not start" }, { status: 502 });
80
+ }
81
+ if (result.state === "auth_required" && !authorizationUrl) {
82
+ return privateJson({
83
+ error: result.message ||
84
+ "OAuth authorization requires consent but no safe URL is available",
85
+ }, { status: 502 });
86
+ }
87
+ return privateJson({
88
+ state: result.state,
89
+ ...(result.message ? { message: result.message } : {}),
90
+ ...(authorizationUrl ? { authorizationUrl } : {}),
91
+ });
92
+ }
93
+ finally {
94
+ await closeConnectorScope(connector, ctx, defer);
95
+ }
96
+ }
97
+ export async function routeOAuthManagement(context) {
98
+ const match = /^\/ui\/oauth\/([a-z0-9_-]+)$/.exec(context.path);
99
+ if (!match)
100
+ return null;
101
+ const connectorId = match[1];
102
+ if (!connectorId)
103
+ return null;
104
+ if (context.request.method === "OPTIONS") {
105
+ return privateJson({ error: "method not allowed" }, { status: 405 });
106
+ }
107
+ return handleOAuthManagementRequest(context, connectorId);
108
+ }
@@ -1,3 +1,2 @@
1
1
  import { type RouteContext } from "./shared.js";
2
- export declare function routeOAuthManagement(context: RouteContext): Promise<Response | null>;
3
2
  export declare function routeOAuthCallback(context: RouteContext): Promise<Response | null>;
@@ -1,109 +1,6 @@
1
1
  import { oauthValueStorageKey } from "../auth/downstream-oauth.js";
2
- import { closeConnectorScope } from "../connector-scope.js";
3
- import { isSafeHttpUrl, resolveBranding } from "../ui.js";
4
- import { authorizeUiIdentity, isSameOrigin, loggableValue, msg, privateJson, } from "./shared.js";
5
- async function handleOAuthManagementRequest(context, connectorId) {
6
- const { request, baseUrl, opts, defer } = context;
7
- if (!isSameOrigin(request, baseUrl)) {
8
- return privateJson({ error: "same-origin request required" }, { status: 403 });
9
- }
10
- const authz = await authorizeUiIdentity(request, baseUrl, opts.auth, "OAuth management", context.runtimeContext, opts.identity);
11
- if (!authz.ok)
12
- return authz.response;
13
- let registry;
14
- try {
15
- registry = opts.registry.scoped({
16
- connectorIds: authz.connectorIds,
17
- ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
18
- ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
19
- });
20
- }
21
- catch (error) {
22
- return privateJson({ error: msg(error) }, { status: 403 });
23
- }
24
- const connector = registry.getConnector(connectorId);
25
- if (!connector?.disconnectAuth || !connector.startAuth) {
26
- return privateJson({ error: "unknown OAuth connector" }, { status: 404 });
27
- }
28
- if (connector.authScope === "personal" && !authz.principalKey) {
29
- return privateJson({ error: "forbidden" }, { status: 403 });
30
- }
31
- if (request.method !== "DELETE" && request.method !== "POST") {
32
- return privateJson({ error: "method not allowed" }, { status: 405 });
33
- }
34
- const requestScope = {};
35
- const ctx = registry.contextFor(connectorId, baseUrl, requestScope);
36
- try {
37
- let result;
38
- let operationError;
39
- try {
40
- if (request.method === "DELETE") {
41
- await connector.disconnectAuth(ctx);
42
- }
43
- else {
44
- result = await connector.startAuth(ctx, { force: true });
45
- if (result.authorizationUrl) {
46
- await registry.bindOAuthHandoff(connectorId, result.authorizationUrl);
47
- }
48
- }
49
- }
50
- catch (error) {
51
- operationError = error;
52
- }
53
- // The old grant and its cached catalog are invalid after either operation,
54
- // including a partially failed physical cleanup whose epoch fence succeeded.
55
- try {
56
- await registry.invalidateStored(connectorId);
57
- }
58
- catch (error) {
59
- operationError ??= error;
60
- }
61
- if (operationError) {
62
- return privateJson({ error: msg(operationError) }, { status: 400 });
63
- }
64
- if (request.method === "DELETE") {
65
- return new Response(null, {
66
- status: 204,
67
- headers: {
68
- "Cache-Control": "no-store",
69
- "Referrer-Policy": "no-referrer",
70
- },
71
- });
72
- }
73
- const authorizationUrl = isSafeHttpUrl(result.authorizationUrl)
74
- ? result.authorizationUrl
75
- : undefined;
76
- if (result.state === "error") {
77
- return privateJson({ error: result.message || "OAuth authorization could not start" }, { status: 502 });
78
- }
79
- if (result.state === "auth_required" && !authorizationUrl) {
80
- return privateJson({
81
- error: result.message ||
82
- "OAuth authorization requires consent but no safe URL is available",
83
- }, { status: 502 });
84
- }
85
- return privateJson({
86
- state: result.state,
87
- ...(result.message ? { message: result.message } : {}),
88
- ...(authorizationUrl ? { authorizationUrl } : {}),
89
- });
90
- }
91
- finally {
92
- await closeConnectorScope(connector, ctx, defer);
93
- }
94
- }
95
- export async function routeOAuthManagement(context) {
96
- const match = /^\/ui\/oauth\/([a-z0-9_-]+)$/.exec(context.path);
97
- if (!match)
98
- return null;
99
- const connectorId = match[1];
100
- if (!connectorId)
101
- return null;
102
- if (context.request.method === "OPTIONS") {
103
- return privateJson({ error: "method not allowed" }, { status: 405 });
104
- }
105
- return handleOAuthManagementRequest(context, connectorId);
106
- }
2
+ import { resolveBranding } from "../branding.js";
3
+ import { authorizeUiIdentity, mayManageConnector, validateAuthPermissions, loggableValue, msg, } from "./shared.js";
107
4
  function escapeHtml(value) {
108
5
  return value
109
6
  .replaceAll("&", "&amp;")
@@ -112,7 +9,7 @@ function escapeHtml(value) {
112
9
  .replaceAll('"', "&quot;");
113
10
  }
114
11
  /** `body` is escaped — callback params and error messages are attacker-influenced. */
115
- function html(body, status = 200, branding) {
12
+ function html(body, status = 200, branding, hasUi = false) {
116
13
  const brand = resolveBranding(branding);
117
14
  const title = brand.pageTitle;
118
15
  const owner = brand.ownerName
@@ -173,7 +70,7 @@ function html(body, status = 200, branding) {
173
70
  <h1>Connection status</h1>
174
71
  <div class="copy">
175
72
  <p>${escapeHtml(body)}</p>
176
- <p><a href="/">Return to ${escapeHtml(brand.productName)}</a></p>
73
+ ${hasUi ? `<p><a href="/">Return to ${escapeHtml(brand.productName)}</a></p>` : ""}
177
74
  </div>
178
75
  </main>
179
76
  </body>
@@ -216,10 +113,10 @@ export async function routeOAuthCallback(context) {
216
113
  return null;
217
114
  const error = url.searchParams.get("error");
218
115
  if (error)
219
- return html(`Authorization denied: ${error}`, 400, opts.branding);
116
+ return html(`Authorization denied: ${error}`, 400, opts.branding, Boolean(opts.ui));
220
117
  const code = url.searchParams.get("code");
221
118
  if (!code)
222
- return html("Missing authorization code.", 400, opts.branding);
119
+ return html("Missing authorization code.", 400, opts.branding, Boolean(opts.ui));
223
120
  const id = path.slice("/oauth/callback/".length);
224
121
  const state = url.searchParams.get("state");
225
122
  const callbackTarget = await opts.registry.oauthCallbackView(id, state);
@@ -234,22 +131,25 @@ export async function routeOAuthCallback(context) {
234
131
  ? callbackRegistry.contextFor(id, baseUrl)
235
132
  : opts.registry.contextFor(id, baseUrl);
236
133
  const refused = () => html("Authorization could not be completed. Re-run authorization from " +
237
- "connecta and try again.", 400, opts.branding);
134
+ "connecta and try again.", 400, opts.branding, Boolean(opts.ui));
238
135
  if (!connector || !connector.finishAuth) {
239
136
  await equalizeRefusalCost(connectorContext);
240
137
  return refused();
241
138
  }
242
139
  const expectedPrincipalKey = callbackTarget?.principalKey;
243
- if (expectedPrincipalKey) {
244
- const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.auth, "OAuth callback", context.runtimeContext, opts.identity);
245
- if (browserIdentity.ok &&
246
- browserIdentity.principalKey !== expectedPrincipalKey) {
247
- opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
248
- `${loggableValue(id)} with 400: the authenticated browser identity ` +
249
- "did not start this personal authorization flow. No authorization " +
250
- "code was exchanged.");
140
+ const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.auth, "OAuth callback", context.runtimeContext, opts.identity);
141
+ if (browserIdentity.ok) {
142
+ try {
143
+ validateAuthPermissions(browserIdentity, opts.registry);
144
+ }
145
+ catch {
251
146
  return refused();
252
147
  }
148
+ if (!mayManageConnector(browserIdentity, connector) || (expectedPrincipalKey && browserIdentity.principalKey !== expectedPrincipalKey))
149
+ return refused();
150
+ }
151
+ else if (browserIdentity.response.status === 403 && opts.auth.some(provider => provider.interactiveOperator)) {
152
+ return refused();
253
153
  }
254
154
  // CSRF / login-fixation guard: this route is intentionally public, so verify
255
155
  // the `state` matches the flow connecta started BEFORE exchanging the code.
@@ -292,15 +192,15 @@ export async function routeOAuthCallback(context) {
292
192
  opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
293
193
  `${loggableValue(id)} with 500: its principal handoff could not be ` +
294
194
  `consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`);
295
- return html("Authorization could not be completed.", 500, opts.branding);
195
+ return html("Authorization could not be completed.", 500, opts.branding, Boolean(opts.ui));
296
196
  }
297
197
  }
298
198
  try {
299
199
  await connector.finishAuth(code, connectorContext, url.searchParams);
300
200
  await callbackRegistry.invalidateStored(id);
301
- return html(`Connected "${id}". You can close this window.`, 200, opts.branding);
201
+ return html(`Connected "${id}". You can close this window.`, 200, opts.branding, Boolean(opts.ui));
302
202
  }
303
203
  catch (err) {
304
- return html(`Authorization failed: ${msg(err)}`, 500, opts.branding);
204
+ return html(`Authorization failed: ${msg(err)}`, 500, opts.branding, Boolean(opts.ui));
305
205
  }
306
206
  }