@zackbart/connecta 0.21.2 → 0.22.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 (63) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +7 -0
  3. package/dist/access-tokens.d.ts +2 -2
  4. package/dist/access-tokens.js +14 -2
  5. package/dist/connectors/api.d.ts +2 -0
  6. package/dist/connectors/api.js +1 -0
  7. package/dist/connectors/remote-mcp.d.ts +2 -0
  8. package/dist/connectors/remote-mcp.js +6 -0
  9. package/dist/credentials.d.ts +6 -6
  10. package/dist/credentials.js +25 -21
  11. package/dist/identity.d.ts +4 -0
  12. package/dist/identity.js +17 -0
  13. package/dist/index.d.ts +16 -2
  14. package/dist/index.js +6 -1
  15. package/dist/meta-tools.js +7 -2
  16. package/dist/operator-ui/generated.js +1 -1
  17. package/dist/operator-ui/model.d.ts +4 -2
  18. package/dist/operator-ui/view.js +1 -1
  19. package/dist/providers/cloudflare.d.ts +2 -0
  20. package/dist/providers/cloudflare.js +1 -0
  21. package/dist/providers/linear.d.ts +2 -0
  22. package/dist/providers/linear.js +1 -0
  23. package/dist/providers/mixpanel.d.ts +2 -0
  24. package/dist/providers/mixpanel.js +1 -0
  25. package/dist/providers/notion.d.ts +2 -0
  26. package/dist/providers/notion.js +1 -0
  27. package/dist/providers/revenuecat.d.ts +2 -0
  28. package/dist/providers/revenuecat.js +1 -0
  29. package/dist/providers/stripe.d.ts +2 -0
  30. package/dist/providers/stripe.js +1 -0
  31. package/dist/registry.d.ts +25 -0
  32. package/dist/registry.js +200 -4
  33. package/dist/routes/access-tokens.js +2 -2
  34. package/dist/routes/activity.js +4 -1
  35. package/dist/routes/credentials.js +31 -12
  36. package/dist/routes/mcp.js +17 -2
  37. package/dist/routes/oauth.js +55 -11
  38. package/dist/routes/shared.d.ts +20 -4
  39. package/dist/routes/shared.js +92 -24
  40. package/dist/routes/ui.js +32 -13
  41. package/dist/types.d.ts +28 -2
  42. package/dist/ui.d.ts +3 -3
  43. package/dist/ui.js +18 -5
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/documentation/architecture.md +14 -8
  47. package/documentation/auth.md +89 -9
  48. package/documentation/code-mode.md +2 -2
  49. package/documentation/connectors.md +13 -0
  50. package/documentation/meta-tools.md +4 -3
  51. package/documentation/operations.md +3 -1
  52. package/documentation/operator-ui.md +13 -4
  53. package/documentation/request-admission.md +2 -1
  54. package/documentation/storage-and-credentials.md +42 -4
  55. package/documentation/upgrading.md +35 -4
  56. package/ethos.md +8 -8
  57. package/examples/worker/AGENTS.md +44 -0
  58. package/examples/worker/README.md +63 -14
  59. package/examples/worker/src/index.ts +26 -22
  60. package/package.json +1 -1
  61. package/templates/node/README.md +7 -0
  62. package/templates/node/package.json +1 -1
  63. package/templates/node/src/index.ts +13 -4
@@ -5,6 +5,8 @@ export declare const REVENUECAT_MCP_ENDPOINT = "https://mcp.revenuecat.ai/mcp";
5
5
  export interface RevenueCatOptions {
6
6
  /** Display name; scope defaults are in `documentation/revenuecat.md`. */
7
7
  title?: string;
8
+ /** Downstream auth ownership. Defaults to one shared deployment grant. */
9
+ authScope?: "shared" | "personal";
8
10
  /**
9
11
  * Which project this connector is for and what decisions it answers. With
10
12
  * headers auth this is the only place the project a key reaches is named,
@@ -207,6 +207,7 @@ export function revenuecat(id, options) {
207
207
  const scoped = auth.type !== "oauth";
208
208
  const connector = remoteMcp(id, {
209
209
  url: REVENUECAT_MCP_ENDPOINT,
210
+ ...(options.authScope ? { authScope: options.authScope } : {}),
210
211
  // The scope shape rides the title because browse-time discovery renders
211
212
  // the title and the guide summary and nothing else, and reaching one
212
213
  // project versus every project the account has is the fact an agent must
@@ -7,6 +7,8 @@ export declare const STRIPE_MCP_ENDPOINT = "https://mcp.stripe.com/";
7
7
  interface StripeCommonOptions {
8
8
  /** Human-readable display name; defaults to "Stripe" for OAuth. */
9
9
  title?: string;
10
+ /** Downstream auth ownership. Defaults to one shared deployment grant. */
11
+ authScope?: "shared" | "personal";
10
12
  /** Which business purpose and Stripe context this connector is for. */
11
13
  purpose: string;
12
14
  /** Connector-specific conventions appended to the maintained provider guide. */
@@ -192,6 +192,7 @@ export function stripe(id, options) {
192
192
  const copy = mode === undefined ? undefined : MODE_COPY[mode];
193
193
  const connector = remoteMcp(id, {
194
194
  url: STRIPE_MCP_ENDPOINT,
195
+ ...(options.authScope ? { authScope: options.authScope } : {}),
195
196
  title: options.title ?? copy?.title ?? "Stripe",
196
197
  description: mode === undefined
197
198
  ? `Stripe payments (live and sandbox accounts) — ${purpose}`
@@ -47,6 +47,10 @@ export interface RegistryOptions {
47
47
  storage: KVStorage;
48
48
  logger: Logger;
49
49
  credentialVault?: CredentialVault | undefined;
50
+ /** Internal owner partition used by a personal registry. */
51
+ credentialOwner?: string | undefined;
52
+ /** Internal child registries skip deployment-wide construction warnings. */
53
+ constructionChecks?: boolean | undefined;
50
54
  toolCacheTtlSeconds?: number | undefined;
51
55
  persistToolCatalog?: boolean | undefined;
52
56
  toolCatalogStaleSeconds?: number | undefined;
@@ -105,6 +109,13 @@ export interface RegistryView {
105
109
  observeOutputShape(connectorId: string, definition: ToolDef, value: unknown): void;
106
110
  statusFor(id: string, baseUrl: string, requestScope?: object, callOptions?: ConnectorOperationOptions): Promise<ConnectorStatus>;
107
111
  invalidateStored(id: string): Promise<void>;
112
+ /** Bind returned OAuth state to this view's personal storage partition. */
113
+ bindOAuthHandoff(id: string, authorizationUrl: string): Promise<void>;
114
+ }
115
+ export interface RegistryScope {
116
+ connectorIds: "all" | readonly string[];
117
+ subjectKey?: string;
118
+ principalKey?: string;
108
119
  }
109
120
  /**
110
121
  * Holds the connector set, resolves addresses, and caches per-connector tool
@@ -136,7 +147,20 @@ export declare class Registry implements RegistryView {
136
147
  private readonly persistToolCatalog;
137
148
  /** Result-size guard cap threaded to the meta-tools. */
138
149
  readonly maxResultBytes: number;
150
+ private readonly configuredConnectors;
151
+ private readonly personalRegistries;
139
152
  constructor(connectors: Connector[], opts: RegistryOptions);
153
+ personalRegistry(principalKey: string): Registry;
154
+ /** Build the only connector view an authenticated request receives. */
155
+ scoped(scope: RegistryScope): RegistryView;
156
+ scopedStorage(subjectKey: string): KVStorage;
157
+ private oauthHandoffKey;
158
+ storeOAuthHandoff(connectorId: string, state: string, principalKey: string): Promise<void>;
159
+ oauthCallbackView(connectorId: string, state: string | null): Promise<{
160
+ registry: RegistryView;
161
+ principalKey?: string;
162
+ } | null>;
163
+ clearOAuthHandoff(connectorId: string, state: string | null): Promise<void>;
140
164
  /**
141
165
  * Warn once per unusable result cap, at construction time — the same
142
166
  * "runs fine but is surely unintended" channel as the insecure-config
@@ -193,6 +217,7 @@ export declare class Registry implements RegistryView {
193
217
  * separate from any connector's `conn:<id>:` namespace. Backs get_result.
194
218
  */
195
219
  resultsStorage(): KVStorage;
220
+ bindOAuthHandoff(): Promise<void>;
196
221
  observedOutputSchema(connectorId: string, definition: ToolDef): ToolDef["outputSchema"] | undefined;
197
222
  observeOutputShape(connectorId: string, definition: ToolDef, value: unknown): void;
198
223
  /** Resolve "<connectorId>.<toolName>" → connector + tool name. */
package/dist/registry.js CHANGED
@@ -73,8 +73,21 @@ function namespaced(storage, prefix) {
73
73
  get: (k) => storage.get(prefix + k),
74
74
  set: (k, v, o) => storage.set(prefix + k, v, o),
75
75
  delete: (k) => storage.delete(prefix + k),
76
+ ...(storage.list
77
+ ? {
78
+ list: async (keyPrefix) => (await storage.list(prefix + keyPrefix)).map((key) => key.slice(prefix.length)),
79
+ }
80
+ : {}),
76
81
  };
77
82
  }
83
+ const MAX_PERSONAL_REGISTRIES = 1_024;
84
+ const OAUTH_HANDOFF_TTL_SECONDS = 15 * 60;
85
+ async function sha256Hex(value) {
86
+ const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
87
+ return [...bytes]
88
+ .map((byte) => byte.toString(16).padStart(2, "0"))
89
+ .join("");
90
+ }
78
91
  /**
79
92
  * Holds the connector set, resolves addresses, and caches per-connector tool
80
93
  * lists in memory with a TTL. Connector failures are isolated: a broken
@@ -105,8 +118,11 @@ export class Registry {
105
118
  persistToolCatalog;
106
119
  /** Result-size guard cap threaded to the meta-tools. */
107
120
  maxResultBytes;
121
+ configuredConnectors;
122
+ personalRegistries = new Map();
108
123
  constructor(connectors, opts) {
109
124
  this.opts = opts;
125
+ this.configuredConnectors = [...connectors];
110
126
  this.observedOutputSchemas = new ObservedOutputSchemas();
111
127
  this.ttlMs =
112
128
  (opts.toolCacheTtlSeconds ?? DEFAULT_TTL_SECONDS) * 1000;
@@ -121,6 +137,11 @@ export class Registry {
121
137
  if (this.connectors.has(c.id)) {
122
138
  throw new Error(`Duplicate connector id "${c.id}"`);
123
139
  }
140
+ if (c.authScope !== undefined &&
141
+ c.authScope !== "shared" &&
142
+ c.authScope !== "personal") {
143
+ throw new Error(`Invalid authScope on connector "${c.id}": expected "shared" or "personal"`);
144
+ }
124
145
  const configuredGuideSummary = typeof c.usageGuide === "object"
125
146
  ? normalizeGuideSummary(c.usageGuide.summary ?? "")
126
147
  : undefined;
@@ -136,8 +157,82 @@ export class Registry {
136
157
  this.callAdmission.set(c.id, new ConnectorCallAdmissionController(c.id, c.callAdmission));
137
158
  }
138
159
  }
139
- this.checkConventions(opts.logger);
140
- this.checkResultCaps(opts.logger, opts.maxResultBytes);
160
+ if (opts.constructionChecks !== false) {
161
+ this.checkConventions(opts.logger);
162
+ this.checkResultCaps(opts.logger, opts.maxResultBytes);
163
+ }
164
+ }
165
+ personalRegistry(principalKey) {
166
+ const existing = this.personalRegistries.get(principalKey);
167
+ if (existing) {
168
+ this.personalRegistries.delete(principalKey);
169
+ this.personalRegistries.set(principalKey, existing);
170
+ return existing;
171
+ }
172
+ const registry = new Registry(this.configuredConnectors.filter((connector) => connector.authScope === "personal"), {
173
+ ...this.opts,
174
+ storage: namespaced(this.opts.storage, `principal:${principalKey}:`),
175
+ credentialOwner: principalKey,
176
+ constructionChecks: false,
177
+ });
178
+ this.personalRegistries.set(principalKey, registry);
179
+ const oldest = this.personalRegistries.keys().next().value;
180
+ if (this.personalRegistries.size > MAX_PERSONAL_REGISTRIES &&
181
+ typeof oldest === "string") {
182
+ this.personalRegistries.delete(oldest);
183
+ }
184
+ return registry;
185
+ }
186
+ /** Build the only connector view an authenticated request receives. */
187
+ scoped(scope) {
188
+ const requested = scope.connectorIds === "all"
189
+ ? new Set(this.connectors.keys())
190
+ : new Set(scope.connectorIds);
191
+ for (const id of requested) {
192
+ if (!this.connectors.has(id)) {
193
+ throw new Error(`Identity access resolver returned unknown connector "${id}"`);
194
+ }
195
+ }
196
+ return new ScopedRegistryView(this, requested, scope);
197
+ }
198
+ scopedStorage(subjectKey) {
199
+ return namespaced(this.opts.storage, `subject:${subjectKey}:`);
200
+ }
201
+ oauthHandoffKey(connectorId, stateHash) {
202
+ return `oauth-handoff:v1:${connectorId}:${stateHash}`;
203
+ }
204
+ async storeOAuthHandoff(connectorId, state, principalKey) {
205
+ const key = this.oauthHandoffKey(connectorId, await sha256Hex(state));
206
+ const existing = await this.opts.storage.get(key);
207
+ if (existing && existing !== principalKey) {
208
+ throw new Error(`Connector "${connectorId}" reused one OAuth state across principals`);
209
+ }
210
+ await this.opts.storage.set(key, principalKey, { ttlSeconds: OAUTH_HANDOFF_TTL_SECONDS });
211
+ }
212
+ async oauthCallbackView(connectorId, state) {
213
+ const connector = this.connectors.get(connectorId);
214
+ if (!connector)
215
+ return null;
216
+ if (connector.authScope !== "personal")
217
+ return { registry: this };
218
+ if (!state)
219
+ return null;
220
+ const principalKey = await this.opts.storage.get(this.oauthHandoffKey(connectorId, await sha256Hex(state)));
221
+ if (!principalKey)
222
+ return null;
223
+ return {
224
+ registry: this.scoped({
225
+ connectorIds: [connectorId],
226
+ subjectKey: principalKey,
227
+ principalKey,
228
+ }),
229
+ principalKey,
230
+ };
231
+ }
232
+ async clearOAuthHandoff(connectorId, state) {
233
+ if (!state)
234
+ return;
235
+ await this.opts.storage.delete(this.oauthHandoffKey(connectorId, await sha256Hex(state)));
141
236
  }
142
237
  /**
143
238
  * Warn once per unusable result cap, at construction time — the same
@@ -202,7 +297,7 @@ export class Registry {
202
297
  if (this.opts.credentialVault && credentialConfig) {
203
298
  const vault = this.opts.credentialVault;
204
299
  const readValues = async () => {
205
- const values = await vault.getAll(id);
300
+ const values = await vault.getAll(id, this.opts.credentialOwner);
206
301
  const shape = storedCredentialShape(credentialConfig, values);
207
302
  if (shape.state === "mismatch") {
208
303
  throw new ConnectorCallError("auth_required", shape.message);
@@ -310,6 +405,9 @@ export class Registry {
310
405
  resultsStorage() {
311
406
  return namespaced(this.opts.storage, "results:");
312
407
  }
408
+ async bindOAuthHandoff() {
409
+ // Shared OAuth already resolves in deployment-wide connector storage.
410
+ }
313
411
  observedOutputSchema(connectorId, definition) {
314
412
  return this.observedOutputSchemas.get(connectorId, definition);
315
413
  }
@@ -858,7 +956,7 @@ export class Registry {
858
956
  if (!credential || !vault)
859
957
  return undefined;
860
958
  try {
861
- const values = await vault.getAll(id);
959
+ const values = await vault.getAll(id, this.opts.credentialOwner);
862
960
  const shape = storedCredentialShape(credential, values);
863
961
  return shape.state === "mismatch" ? shape.message : undefined;
864
962
  }
@@ -943,3 +1041,101 @@ export class Registry {
943
1041
  await this.deleteStoredCatalog(id);
944
1042
  }
945
1043
  }
1044
+ class ScopedRegistryView {
1045
+ root;
1046
+ allowed;
1047
+ scope;
1048
+ maxResultBytes;
1049
+ personal;
1050
+ constructor(root, allowed, scope) {
1051
+ this.root = root;
1052
+ this.allowed = allowed;
1053
+ this.scope = scope;
1054
+ this.maxResultBytes = root.maxResultBytes;
1055
+ this.personal = scope.principalKey
1056
+ ? root.personalRegistry(scope.principalKey)
1057
+ : undefined;
1058
+ }
1059
+ registryFor(id) {
1060
+ if (!this.allowed.has(id))
1061
+ return undefined;
1062
+ const connector = this.root.getConnector(id);
1063
+ if (!connector)
1064
+ return undefined;
1065
+ return connector.authScope === "personal" ? this.personal : this.root;
1066
+ }
1067
+ listConnectors() {
1068
+ return this.root.listConnectors().filter((connector) => this.registryFor(connector.id) !== undefined);
1069
+ }
1070
+ getConnector(id) {
1071
+ return this.registryFor(id)?.getConnector(id);
1072
+ }
1073
+ resolveAddress(address) {
1074
+ const parsed = splitAddress(address);
1075
+ if (!parsed)
1076
+ return null;
1077
+ const connector = this.getConnector(parsed.connectorId);
1078
+ return connector ? { connector, toolName: parsed.toolName } : null;
1079
+ }
1080
+ getTools(...args) {
1081
+ const registry = this.registryFor(args[0]);
1082
+ if (!registry) {
1083
+ return Promise.reject(new Error(`Unknown connector "${args[0]}"`));
1084
+ }
1085
+ return registry.getTools(...args);
1086
+ }
1087
+ contextFor(...args) {
1088
+ const registry = this.registryFor(args[0]);
1089
+ if (!registry)
1090
+ throw new Error(`Unknown connector "${args[0]}"`);
1091
+ return registry.contextFor(...args);
1092
+ }
1093
+ admitCall(...args) {
1094
+ if (!this.registryFor(args[0])) {
1095
+ return Promise.reject(new Error(`Unknown connector "${args[0]}"`));
1096
+ }
1097
+ return this.root.admitCall(...args);
1098
+ }
1099
+ resultsStorage() {
1100
+ return this.scope.subjectKey
1101
+ ? this.root.scopedStorage(this.scope.subjectKey)
1102
+ : this.root.resultsStorage();
1103
+ }
1104
+ credentialDriftFor(id) {
1105
+ const registry = this.registryFor(id);
1106
+ return registry
1107
+ ? registry.credentialDriftFor(id)
1108
+ : Promise.resolve(undefined);
1109
+ }
1110
+ observedOutputSchema(connectorId, definition) {
1111
+ return this.registryFor(connectorId)?.observedOutputSchema(connectorId, definition);
1112
+ }
1113
+ observeOutputShape(connectorId, definition, value) {
1114
+ this.registryFor(connectorId)?.observeOutputShape(connectorId, definition, value);
1115
+ }
1116
+ statusFor(...args) {
1117
+ const registry = this.registryFor(args[0]);
1118
+ return registry
1119
+ ? registry.statusFor(...args)
1120
+ : Promise.resolve({ state: "error", message: "Unknown connector" });
1121
+ }
1122
+ invalidateStored(id) {
1123
+ const registry = this.registryFor(id);
1124
+ return registry ? registry.invalidateStored(id) : Promise.resolve();
1125
+ }
1126
+ async bindOAuthHandoff(id, authorizationUrl) {
1127
+ const connector = this.getConnector(id);
1128
+ if (connector?.authScope !== "personal" || !this.scope.principalKey)
1129
+ return;
1130
+ let state = null;
1131
+ try {
1132
+ state = new URL(authorizationUrl).searchParams.get("state");
1133
+ }
1134
+ catch {
1135
+ return;
1136
+ }
1137
+ if (state) {
1138
+ await this.root.storeOAuthHandoff(id, state, this.scope.principalKey);
1139
+ }
1140
+ }
1141
+ }
@@ -46,7 +46,7 @@ export async function routeAccessTokens(context) {
46
46
  if (mutating && !isSameOrigin(request, baseUrl)) {
47
47
  return privateJson({ error: "same-origin request required" }, { status: 403 });
48
48
  }
49
- const admin = await authorizeUiAdmin(request, baseUrl, opts.auth, "access token management", context.runtimeContext);
49
+ const admin = await authorizeUiAdmin(request, baseUrl, opts.auth, "access token management", context.runtimeContext, opts.identity);
50
50
  if (!admin.ok)
51
51
  return admin.response;
52
52
  const id = match[1];
@@ -58,7 +58,7 @@ export async function routeAccessTokens(context) {
58
58
  const input = await readName(request);
59
59
  if (!input.ok)
60
60
  return input.response;
61
- return privateJson(await opts.accessTokens.create(input.name, admin.userId), { status: 201 });
61
+ return privateJson(await opts.accessTokens.create(input.name, admin.principal ?? admin.userId), { status: 201 });
62
62
  }
63
63
  if (id && request.method === "PUT") {
64
64
  const input = await readName(request);
@@ -134,9 +134,12 @@ export async function routeActivity(context) {
134
134
  if (request.method !== "GET") {
135
135
  return privateJson({ error: "method not allowed" }, { status: 405 });
136
136
  }
137
- const authz = await authorize(request, baseUrl, opts.auth, runtimeContext);
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) {
141
+ return privateJson({ error: "operator access required" }, { status: 403 });
142
+ }
140
143
  if (opts.activityReadGate &&
141
144
  !(await opts.activityReadGate(authz.actor))) {
142
145
  return privateJson({ error: "forbidden" }, { status: 403 });
@@ -1,5 +1,5 @@
1
1
  import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape, } from "../credentials.js";
2
- import { authorizeUiAdmin, isSameOrigin, msg, privateJson, } from "./shared.js";
2
+ import { authorizeUiIdentity, isSameOrigin, msg, privateJson, } from "./shared.js";
3
3
  async function readCredentialInput(request, config) {
4
4
  if (!request.headers
5
5
  .get("content-type")
@@ -73,13 +73,32 @@ async function handleCredentialRequest(context, connectorId, action) {
73
73
  if (!isSameOrigin(request, baseUrl)) {
74
74
  return privateJson({ error: "same-origin request required" }, { status: 403 });
75
75
  }
76
- const admin = await authorizeUiAdmin(request, baseUrl, opts.auth, "credential management", context.runtimeContext);
77
- if (!admin.ok)
78
- return admin.response;
79
- const connector = opts.registry.getConnector(connectorId);
76
+ const authz = await authorizeUiIdentity(request, baseUrl, opts.auth, "credential management", context.runtimeContext, opts.identity);
77
+ if (!authz.ok)
78
+ return authz.response;
79
+ let registry;
80
+ try {
81
+ registry = opts.registry.scoped({
82
+ connectorIds: authz.connectorIds,
83
+ ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
84
+ ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
85
+ });
86
+ }
87
+ catch (error) {
88
+ return privateJson({ error: msg(error) }, { status: 403 });
89
+ }
90
+ const connector = registry.getConnector(connectorId);
80
91
  if (!connector?.credential) {
81
92
  return privateJson({ error: "unknown credential slot" }, { status: 404 });
82
93
  }
94
+ const personal = connector.authScope === "personal";
95
+ if (personal && !authz.principalKey) {
96
+ return privateJson({ error: "forbidden" }, { status: 403 });
97
+ }
98
+ const owner = personal ? authz.principalKey : undefined;
99
+ const updatedBy = authz.identity.principal
100
+ ? `${authz.identity.principal.namespace}:${authz.identity.principal.id}`
101
+ : authz.actor.id ?? authz.actor.kind;
83
102
  if (action === "test") {
84
103
  if (request.method !== "POST") {
85
104
  return privateJson({ error: "method not allowed" }, { status: 405 });
@@ -97,7 +116,7 @@ async function handleCredentialRequest(context, connectorId, action) {
97
116
  }, { status: 400 });
98
117
  }
99
118
  try {
100
- const values = await opts.credentialVault.getAll(connectorId);
119
+ const values = await opts.credentialVault.getAll(connectorId, owner);
101
120
  const shape = storedCredentialShape(connector.credential, values);
102
121
  if (shape.state === "missing") {
103
122
  return privateJson({
@@ -110,7 +129,7 @@ async function handleCredentialRequest(context, connectorId, action) {
110
129
  return privateJson({ error: shape.message }, { status: 409 });
111
130
  }
112
131
  const storedValues = values;
113
- const ctx = opts.registry.contextFor(connectorId, baseUrl);
132
+ const ctx = registry.contextFor(connectorId, baseUrl);
114
133
  const result = rule.mode === "multiple"
115
134
  ? await connector.testCredentials(storedValues, ctx)
116
135
  : await connector.testCredential(
@@ -131,9 +150,9 @@ async function handleCredentialRequest(context, connectorId, action) {
131
150
  return input.response;
132
151
  try {
133
152
  const metadata = input.input.kind === "single"
134
- ? await opts.credentialVault.set(connectorId, input.input.value, admin.userId)
135
- : await opts.credentialVault.setAll(connectorId, input.input.values, admin.userId);
136
- await opts.registry.invalidateStored(connectorId);
153
+ ? await opts.credentialVault.set(connectorId, input.input.value, updatedBy, owner)
154
+ : await opts.credentialVault.setAll(connectorId, input.input.values, updatedBy, owner);
155
+ await registry.invalidateStored(connectorId);
137
156
  return privateJson({ credential: metadata });
138
157
  }
139
158
  catch (err) {
@@ -141,8 +160,8 @@ async function handleCredentialRequest(context, connectorId, action) {
141
160
  }
142
161
  }
143
162
  if (request.method === "DELETE") {
144
- await opts.credentialVault.delete(connectorId);
145
- await opts.registry.invalidateStored(connectorId);
163
+ await opts.credentialVault.delete(connectorId, owner);
164
+ await registry.invalidateStored(connectorId);
146
165
  return new Response(null, {
147
166
  status: 204,
148
167
  headers: {
@@ -4,6 +4,7 @@ import { registerExecuteTool } from "../execute.js";
4
4
  import { ExecutorAdmissionError, } from "../executor-admission.js";
5
5
  import { registerMetaTools } from "../meta-tools.js";
6
6
  import { instructionsFor } from "../skills.js";
7
+ import { msg } from "../errors.js";
7
8
  import { authorize, } from "./shared.js";
8
9
  export const MCP_CORS_HEADERS = {
9
10
  "Access-Control-Allow-Origin": "*",
@@ -329,14 +330,28 @@ export function createMcpRoute(opts) {
329
330
  throw error;
330
331
  }
331
332
  try {
332
- const authz = await authorize(request, baseUrl, opts.auth, runtimeContext);
333
+ const authz = await authorize(request, baseUrl, opts.auth, runtimeContext, opts.identity);
333
334
  if (!authz.ok) {
334
335
  return releaseAdmissionWithResponse(withMcpCors(authz.response), admission, request.signal);
335
336
  }
337
+ let scopedRegistry;
338
+ try {
339
+ scopedRegistry = opts.registry.scoped({
340
+ connectorIds: authz.connectorIds,
341
+ ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
342
+ ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
343
+ });
344
+ }
345
+ catch (error) {
346
+ return releaseAdmissionWithResponse(withMcpCors(new Response(JSON.stringify({ error: msg(error) }), {
347
+ status: 403,
348
+ headers: { "Content-Type": "application/json" },
349
+ })), admission, request.signal);
350
+ }
336
351
  if (new URL(request.url).searchParams.has("toolkit")) {
337
352
  return releaseAdmissionWithResponse(withMcpCors(toolkitRetired(opts.logger)), admission, request.signal);
338
353
  }
339
- return releaseAdmissionWithResponse(withMcpCors(await serveMcp(request, opts, baseUrl, authz.actor, opts.registry, runtimeContext)), admission, request.signal);
354
+ return releaseAdmissionWithResponse(withMcpCors(await serveMcp(request, opts, baseUrl, authz.actor, scopedRegistry, runtimeContext)), admission, request.signal);
340
355
  }
341
356
  catch (error) {
342
357
  admission.release();
@@ -1,24 +1,38 @@
1
1
  import { oauthValueStorageKey } from "../auth/downstream-oauth.js";
2
2
  import { closeConnectorScope } from "../connector-scope.js";
3
3
  import { isSafeHttpUrl, resolveBranding } from "../ui.js";
4
- import { authorizeUiAdmin, isSameOrigin, loggableValue, msg, privateJson, } from "./shared.js";
4
+ import { authorizeUiIdentity, isSameOrigin, loggableValue, msg, privateJson, } from "./shared.js";
5
5
  async function handleOAuthManagementRequest(context, connectorId) {
6
6
  const { request, baseUrl, opts, defer } = context;
7
7
  if (!isSameOrigin(request, baseUrl)) {
8
8
  return privateJson({ error: "same-origin request required" }, { status: 403 });
9
9
  }
10
- const admin = await authorizeUiAdmin(request, baseUrl, opts.auth, "OAuth management", context.runtimeContext);
11
- if (!admin.ok)
12
- return admin.response;
13
- const connector = opts.registry.getConnector(connectorId);
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);
14
25
  if (!connector?.disconnectAuth || !connector.startAuth) {
15
26
  return privateJson({ error: "unknown OAuth connector" }, { status: 404 });
16
27
  }
28
+ if (connector.authScope === "personal" && !authz.principalKey) {
29
+ return privateJson({ error: "forbidden" }, { status: 403 });
30
+ }
17
31
  if (request.method !== "DELETE" && request.method !== "POST") {
18
32
  return privateJson({ error: "method not allowed" }, { status: 405 });
19
33
  }
20
34
  const requestScope = {};
21
- const ctx = opts.registry.contextFor(connectorId, baseUrl, requestScope);
35
+ const ctx = registry.contextFor(connectorId, baseUrl, requestScope);
22
36
  try {
23
37
  let result;
24
38
  let operationError;
@@ -28,6 +42,9 @@ async function handleOAuthManagementRequest(context, connectorId) {
28
42
  }
29
43
  else {
30
44
  result = await connector.startAuth(ctx, { force: true });
45
+ if (result.authorizationUrl) {
46
+ await registry.bindOAuthHandoff(connectorId, result.authorizationUrl);
47
+ }
31
48
  }
32
49
  }
33
50
  catch (error) {
@@ -36,7 +53,7 @@ async function handleOAuthManagementRequest(context, connectorId) {
36
53
  // The old grant and its cached catalog are invalid after either operation,
37
54
  // including a partially failed physical cleanup whose epoch fence succeeded.
38
55
  try {
39
- await opts.registry.invalidateStored(connectorId);
56
+ await registry.invalidateStored(connectorId);
40
57
  }
41
58
  catch (error) {
42
59
  operationError ??= error;
@@ -204,19 +221,36 @@ export async function routeOAuthCallback(context) {
204
221
  if (!code)
205
222
  return html("Missing authorization code.", 400, opts.branding);
206
223
  const id = path.slice("/oauth/callback/".length);
207
- const connector = opts.registry.getConnector(id);
224
+ const state = url.searchParams.get("state");
225
+ const callbackTarget = await opts.registry.oauthCallbackView(id, state);
226
+ const callbackRegistry = callbackTarget?.registry;
227
+ const connector = callbackRegistry?.getConnector(id);
208
228
  // Safe to build before we know the id names anything: `contextFor` is a pure
209
229
  // constructor — a namespaced storage view over `conn:<id>:` and, only for a
210
230
  // connector that declares one, a lazy credential accessor. It neither throws
211
231
  // nor touches storage for an unknown id, which is what lets the refusals
212
232
  // below borrow it to equalize their cost.
213
- const connectorContext = opts.registry.contextFor(id, baseUrl);
233
+ const connectorContext = callbackRegistry
234
+ ? callbackRegistry.contextFor(id, baseUrl)
235
+ : opts.registry.contextFor(id, baseUrl);
214
236
  const refused = () => html("Authorization could not be completed. Re-run authorization from " +
215
237
  "connecta and try again.", 400, opts.branding);
216
238
  if (!connector || !connector.finishAuth) {
217
239
  await equalizeRefusalCost(connectorContext);
218
240
  return refused();
219
241
  }
242
+ 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.");
251
+ return refused();
252
+ }
253
+ }
220
254
  // CSRF / login-fixation guard: this route is intentionally public, so verify
221
255
  // the `state` matches the flow connecta started BEFORE exchanging the code.
222
256
  if (!connector.verifyState) {
@@ -228,7 +262,6 @@ export async function routeOAuthCallback(context) {
228
262
  "trying again.");
229
263
  return refused();
230
264
  }
231
- const state = url.searchParams.get("state");
232
265
  let stateMatches;
233
266
  try {
234
267
  stateMatches = await connector.verifyState(state, connectorContext);
@@ -251,9 +284,20 @@ export async function routeOAuthCallback(context) {
251
284
  "connecta and try again.");
252
285
  return refused();
253
286
  }
287
+ if (connector.authScope === "personal") {
288
+ try {
289
+ await opts.registry.clearOAuthHandoff(id, state);
290
+ }
291
+ catch (err) {
292
+ opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
293
+ `${loggableValue(id)} with 500: its principal handoff could not be ` +
294
+ `consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`);
295
+ return html("Authorization could not be completed.", 500, opts.branding);
296
+ }
297
+ }
254
298
  try {
255
299
  await connector.finishAuth(code, connectorContext, url.searchParams);
256
- await opts.registry.invalidateStored(id);
300
+ await callbackRegistry.invalidateStored(id);
257
301
  return html(`Connected "${id}". You can close this window.`, 200, opts.branding);
258
302
  }
259
303
  catch (err) {