@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,11 +5,13 @@ import type { CredentialVault } from "../credentials.js";
5
5
  import type { DeferredWork } from "../connector-scope.js";
6
6
  import type { AdmissionController } from "../executor-admission.js";
7
7
  import type { Registry } from "../registry.js";
8
- import type { ConnectaBranding, Executor, InboundAuth, InboundAuthRuntimeContext, Logger } from "../types.js";
8
+ import type { AuthenticatedIdentity, ConnectaBranding, Executor, IdentityReference, InboundAuth, InboundAuthRuntimeContext, Logger } from "../types.js";
9
+ import type { ConnectaIdentityConfig } from "../index.js";
9
10
  export { msg } from "../errors.js";
10
11
  export interface ServerOptions {
11
12
  registry: Registry;
12
13
  auth: InboundAuth[];
14
+ identity?: ConnectaIdentityConfig | undefined;
13
15
  publicUrl?: string | undefined;
14
16
  serverInfo: Implementation;
15
17
  logger: Logger;
@@ -64,21 +66,35 @@ export declare function privateJson(body: unknown, init?: ResponseInit): Respons
64
66
  */
65
67
  export declare function loggableValue(requested: string): string;
66
68
  export declare function activityActorNamespace(provider: InboundAuth): string | undefined;
67
- export declare function authorize(request: Request, baseUrl: string, auth: InboundAuth[], runtimeContext?: RuntimeExecutionContext): Promise<{
69
+ export declare function authorize(request: Request, baseUrl: string, auth: InboundAuth[], runtimeContext?: RuntimeExecutionContext, identityConfig?: ConnectaIdentityConfig, partitionIdentity?: boolean): Promise<{
68
70
  ok: true;
69
71
  actor: ActivityActor;
70
- /** True only when the admitting provider can also authorize UI mutation. */
72
+ identity: AuthenticatedIdentity;
73
+ subjectKey?: string;
74
+ principalKey?: string;
75
+ connectorIds: "all" | readonly string[];
76
+ operator: boolean;
77
+ /** Backward-compatible name used by operator views. */
71
78
  uiAdminEligible?: boolean;
72
79
  } | {
73
80
  ok: false;
74
81
  response: Response;
75
82
  }>;
76
- export declare function authorizeUiAdmin(request: Request, baseUrl: string, auth: InboundAuth[], purpose?: string, runtimeContext?: RuntimeExecutionContext): Promise<{
83
+ export declare function authorizeUiAdmin(request: Request, baseUrl: string, auth: InboundAuth[], purpose?: string, runtimeContext?: RuntimeExecutionContext, identityConfig?: ConnectaIdentityConfig): Promise<{
77
84
  ok: true;
78
85
  userId: string;
86
+ principal?: IdentityReference;
87
+ principalKey?: string;
88
+ connectorIds: "all" | readonly string[];
79
89
  } | {
80
90
  ok: false;
81
91
  response: Response;
82
92
  }>;
93
+ export declare function authorizeUiIdentity(request: Request, baseUrl: string, auth: InboundAuth[], purpose: string, runtimeContext?: RuntimeExecutionContext, identityConfig?: ConnectaIdentityConfig): Promise<Extract<Awaited<ReturnType<typeof authorize>>, {
94
+ ok: true;
95
+ }> | {
96
+ ok: false;
97
+ response: Response;
98
+ }>;
83
99
  export declare function isSameOrigin(request: Request, baseUrl: string): boolean;
84
100
  export declare function withSecurityHeaders(response: Response, requestUrl: URL, path: string): Response;
@@ -1,3 +1,4 @@
1
+ import { identityStorageKey, validIdentityReference } from "../identity.js";
1
2
  import { operatorPageForPath } from "../ui.js";
2
3
  export { msg } from "../errors.js";
3
4
  export function privateJson(body, init = {}) {
@@ -29,9 +30,21 @@ export function activityActorNamespace(provider) {
29
30
  ? provider.activityActorNamespace
30
31
  : undefined;
31
32
  }
32
- export async function authorize(request, baseUrl, auth, runtimeContext) {
33
+ export async function authorize(request, baseUrl, auth, runtimeContext, identityConfig, partitionIdentity = true) {
33
34
  if (auth.length === 0) {
34
- return { ok: true, actor: { kind: "anonymous" } };
35
+ const actor = { kind: "anonymous" };
36
+ const identity = { actor, interactive: false };
37
+ let connectorIds = "all";
38
+ try {
39
+ connectorIds = await identityConfig?.connectorAccess?.(identity) ?? "all";
40
+ }
41
+ catch {
42
+ return {
43
+ ok: false,
44
+ response: privateJson({ error: "identity access resolution failed" }, { status: 403 }),
45
+ };
46
+ }
47
+ return { ok: true, actor, identity, connectorIds, operator: false };
35
48
  }
36
49
  let lastResponse = null;
37
50
  for (const provider of auth) {
@@ -39,18 +52,56 @@ export async function authorize(request, baseUrl, auth, runtimeContext) {
39
52
  if (result.ok) {
40
53
  const subjectId = result.subjectId ?? result.userId;
41
54
  const actorNamespace = activityActorNamespace(provider);
55
+ const subject = subjectId && actorNamespace
56
+ ? { namespace: actorNamespace, id: subjectId }
57
+ : undefined;
58
+ const derivedPrincipal = result.userId && actorNamespace
59
+ ? { namespace: actorNamespace, id: result.userId }
60
+ : undefined;
61
+ const principal = validIdentityReference(result.principal)
62
+ ? result.principal
63
+ : derivedPrincipal;
64
+ const interactive = Boolean(result.userId && provider.interactiveOperator);
65
+ const actor = {
66
+ kind: provider.kind,
67
+ ...(subjectId ? { id: subjectId } : {}),
68
+ ...(subject ? { namespace: subject.namespace } : {}),
69
+ };
70
+ const identity = {
71
+ actor,
72
+ ...(subject ? { subject } : {}),
73
+ ...(principal ? { principal } : {}),
74
+ interactive,
75
+ };
76
+ let operator = interactive;
77
+ let connectorIds = "all";
78
+ try {
79
+ if (identityConfig?.operatorAccess) {
80
+ operator = interactive && principal
81
+ ? await identityConfig.operatorAccess(principal)
82
+ : false;
83
+ }
84
+ connectorIds = await identityConfig?.connectorAccess?.(identity) ?? "all";
85
+ }
86
+ catch {
87
+ return {
88
+ ok: false,
89
+ response: privateJson({ error: "identity access resolution failed" }, { status: 403 }),
90
+ };
91
+ }
42
92
  return {
43
93
  ok: true,
44
- actor: {
45
- kind: provider.kind,
46
- ...(subjectId ? { id: subjectId } : {}),
47
- ...(subjectId && actorNamespace
48
- ? { namespace: actorNamespace }
49
- : {}),
50
- },
51
- ...(result.userId && provider.interactiveOperator
52
- ? { uiAdminEligible: true }
94
+ actor,
95
+ identity,
96
+ ...(subject && partitionIdentity
97
+ ? { subjectKey: await identityStorageKey(subject) }
98
+ : {}),
99
+ ...(principal && partitionIdentity
100
+ ? { principalKey: await identityStorageKey(principal) }
53
101
  : {}),
102
+ connectorIds,
103
+ operator,
104
+ ...(operator ? { uiAdminEligible: true } : {}),
54
105
  };
55
106
  }
56
107
  lastResponse = result.response;
@@ -67,7 +118,7 @@ export async function authorize(request, baseUrl, auth, runtimeContext) {
67
118
  }),
68
119
  };
69
120
  }
70
- export async function authorizeUiAdmin(request, baseUrl, auth, purpose = "credential management", runtimeContext) {
121
+ export async function authorizeUiAdmin(request, baseUrl, auth, purpose = "credential management", runtimeContext, identityConfig) {
71
122
  // Operator mutation is intentionally narrower than /mcp and /ui/data: only
72
123
  // an interactive provider may admit it. A static bearer token is useful
73
124
  // for headless tool calls but must not become a deployment-admin key.
@@ -76,30 +127,47 @@ export async function authorizeUiAdmin(request, baseUrl, auth, purpose = "creden
76
127
  // Stopping at the first would make admission depend on config order: a failed gate or
77
128
  // missing user may simply mean a later provider is the one meant to admit.
78
129
  // The last refusal is returned if none do.
130
+ const authz = await authorizeUiIdentity(request, baseUrl, auth, purpose, runtimeContext, identityConfig);
131
+ if (!authz.ok)
132
+ return authz;
133
+ if (!authz.operator || !authz.actor.id) {
134
+ return {
135
+ ok: false,
136
+ response: privateJson({ error: `${purpose} requires operator access` }, { status: 403 }),
137
+ };
138
+ }
139
+ return {
140
+ ok: true,
141
+ userId: authz.identity.principal?.id ?? authz.actor.id,
142
+ ...(authz.identity.principal
143
+ ? { principal: authz.identity.principal }
144
+ : {}),
145
+ ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
146
+ connectorIds: authz.connectorIds,
147
+ };
148
+ }
149
+ export async function authorizeUiIdentity(request, baseUrl, auth, purpose, runtimeContext, identityConfig) {
79
150
  const providers = auth.filter((candidate) => candidate.interactiveOperator);
80
151
  if (providers.length === 0) {
81
152
  return {
82
153
  ok: false,
83
- response: privateJson({ error: `${purpose} requires interactive operator authentication` }, { status: 403 }),
154
+ response: privateJson({ error: `${purpose} requires interactive user authentication` }, { status: 403 }),
84
155
  };
85
156
  }
86
- let lastResponse = null;
157
+ let lastResponse;
87
158
  for (const provider of providers) {
88
- const result = await provider.authorize(request, baseUrl, runtimeContext);
89
- if (!result.ok) {
90
- lastResponse = result.response;
159
+ const authz = await authorize(request, baseUrl, [provider], runtimeContext, identityConfig);
160
+ if (!authz.ok) {
161
+ lastResponse = authz.response;
91
162
  continue;
92
163
  }
93
- if (!result.userId) {
94
- lastResponse = privateJson({ error: "authenticated user required" }, { status: 403 });
95
- continue;
96
- }
97
- return { ok: true, userId: result.userId };
164
+ if (authz.identity.interactive)
165
+ return authz;
166
+ lastResponse = privateJson({ error: "authenticated user required" }, { status: 403 });
98
167
  }
99
168
  return {
100
169
  ok: false,
101
- response: lastResponse ??
102
- privateJson({ error: "forbidden" }, { status: 403 }),
170
+ response: lastResponse ?? privateJson({ error: `${purpose} requires interactive user authentication` }, { status: 403 }),
103
171
  };
104
172
  }
105
173
  export function isSameOrigin(request, baseUrl) {
package/dist/routes/ui.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { CONNECTA_FAVICON_ICO } from "../favicon.js";
2
2
  import { buildUiData, CONNECTA_FAVICON_SVG, credentialManagementCapability, operatorPageForPath, renderUiHtml, } from "../ui.js";
3
- import { authorize, privateJson, } from "./shared.js";
3
+ import { authorize, msg, privateJson, } from "./shared.js";
4
4
  /**
5
5
  * Headers that make an operator-supplied favicon body inert on this origin.
6
6
  * The SVG route is the sharp one: `image/svg+xml` is an *active* content type,
@@ -99,17 +99,35 @@ export async function routeUi(context) {
99
99
  }
100
100
  if (path !== "/ui/data")
101
101
  return null;
102
- const authz = await authorize(request, baseUrl, opts.auth, runtimeContext);
102
+ const authz = await authorize(request, baseUrl, opts.auth, runtimeContext, opts.identity);
103
103
  if (!authz.ok)
104
104
  return authz.response;
105
+ let registry;
106
+ try {
107
+ registry = opts.registry.scoped({
108
+ connectorIds: authz.connectorIds,
109
+ ...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
110
+ ...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
111
+ });
112
+ }
113
+ catch (error) {
114
+ return privateJson({ error: msg(error) }, { status: 403 });
115
+ }
105
116
  const eligibleOperator = authz.uiAdminEligible === true;
106
- const credentialManagement = credentialManagementCapability({
107
- eligibleOperator,
108
- hasCredentialSlots: opts.registry
109
- .listConnectors()
110
- .some((connector) => Boolean(connector.credential)),
111
- hasCredentialVault: Boolean(opts.credentialVault),
112
- });
117
+ const interactiveManager = authz.identity.interactive;
118
+ const personalManager = Boolean(interactiveManager && authz.principalKey);
119
+ const visibleConnectors = registry.listConnectors();
120
+ const hasManageableCredentialSlot = visibleConnectors.some((connector) => Boolean(connector.credential) &&
121
+ (connector.authScope !== "personal" || personalManager));
122
+ const credentialManagement = interactiveManager && hasManageableCredentialSlot
123
+ ? opts.credentialVault
124
+ ? "available"
125
+ : "vault_not_configured"
126
+ : credentialManagementCapability({
127
+ eligibleOperator: interactiveManager,
128
+ hasCredentialSlots: visibleConnectors.some((connector) => Boolean(connector.credential)),
129
+ hasCredentialVault: Boolean(opts.credentialVault),
130
+ });
113
131
  // As with connector credentials, a Bearer-authenticated observer learns
114
132
  // only that an interactive operator is required, not whether this deployment has opted into
115
133
  // token issuance. Configuration topology is operator data.
@@ -118,9 +136,10 @@ export async function routeUi(context) {
118
136
  : opts.accessTokens
119
137
  ? "available"
120
138
  : "not_configured";
121
- const data = await buildUiData(opts.registry, baseUrl, opts.serverInfo,
122
- // The static headless bearer may read connector health, but only a
123
- // Clerk-authenticated operator receives credential metadata.
124
- eligibleOperator ? opts.credentialVault : undefined, Boolean(opts.activity?.list), credentialManagement, defer, eligibleOperator, opts.discoveryConcurrency, accessTokenManagement);
139
+ const data = await buildUiData(registry, baseUrl, opts.serverInfo,
140
+ // A static headless bearer may read connector health, but only an
141
+ // interactive human receives credential metadata for visible connectors.
142
+ interactiveManager ? opts.credentialVault : undefined, Boolean(opts.activity?.list) &&
143
+ (!opts.identity?.operatorAccess || eligibleOperator), credentialManagement, defer, interactiveManager, opts.discoveryConcurrency, accessTokenManagement, personalManager ? authz.principalKey : undefined);
125
144
  return privateJson(data);
126
145
  }
package/dist/types.d.ts CHANGED
@@ -138,7 +138,7 @@ export interface ConnectorContext {
138
138
  /** Public base URL of this deployment (origin), used for OAuth callbacks. */
139
139
  baseUrl: string;
140
140
  /**
141
- * Read-only access to this connector's operator-managed credential. Present
141
+ * Read-only access to this connector's human-managed credential. Present
142
142
  * only when the connector declares `credential` and the deployment configures
143
143
  * `credentials.encryptionKey`.
144
144
  */
@@ -208,6 +208,12 @@ export interface ConnectorStatus {
208
208
  /** The whole plugin contract — the one open seam. */
209
209
  export interface Connector {
210
210
  id: string;
211
+ /**
212
+ * Who owns this connector's downstream authentication. `shared` keeps one
213
+ * deployment-wide grant. `personal` isolates storage and credentials by the
214
+ * authenticated human principal. Defaults to `shared`.
215
+ */
216
+ authScope?: "shared" | "personal";
211
217
  /** Human-readable display name; the stable `id` remains the tool-address prefix. */
212
218
  title?: string;
213
219
  /** How call_tool wraps results. "mcp" passes the content array through; anything else is JSON-wrapped. */
@@ -240,7 +246,7 @@ export interface Connector {
240
246
  * configuration; no runtime registration or shared mutable copy exists.
241
247
  */
242
248
  usageGuide?: string | ConnectorUsageGuide;
243
- /** Optional operator-managed credential slot rendered on /credentials. */
249
+ /** Optional human-managed credential slot rendered on /credentials. */
244
250
  credential?: ConnectorCredentialConfig;
245
251
  /** Optional server-side check used by /credentials' Test action. */
246
252
  testCredential?(value: string, ctx: ConnectorContext): Promise<CredentialTestResult>;
@@ -420,10 +426,30 @@ export type AuthResult = {
420
426
  ok: true;
421
427
  userId?: string;
422
428
  subjectId?: string;
429
+ /** Human owner represented by a non-interactive access credential. */
430
+ principal?: IdentityReference;
423
431
  } | {
424
432
  ok: false;
425
433
  response: Response;
426
434
  };
435
+ /** Stable identity inside one configured authentication directory. */
436
+ export interface IdentityReference {
437
+ namespace: string;
438
+ id: string;
439
+ }
440
+ /** Identity data passed to config-owned access resolvers. */
441
+ export interface AuthenticatedIdentity {
442
+ actor: {
443
+ kind: string;
444
+ id?: string;
445
+ namespace?: string;
446
+ };
447
+ /** Any stable admitted caller, including service identities and tokens. */
448
+ subject?: IdentityReference;
449
+ /** Human owner of personal connector authentication. */
450
+ principal?: IdentityReference;
451
+ interactive: boolean;
452
+ }
427
453
  /** Public browser-auth configuration exposed to connecta's status UI. */
428
454
  export type UiAuthConfig = {
429
455
  kind: "cloudflare-access";
package/dist/ui.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { CredentialVault } from "./credentials.js";
2
2
  import { type DeferredWork } from "./connector-scope.js";
3
3
  import { type CredentialManagementCapability, type AccessTokenManagementCapability, type UiData } from "./operator-ui/model.js";
4
- import type { Registry } from "./registry.js";
4
+ import type { RegistryView } from "./registry.js";
5
5
  import type { ConnectaBranding, UiAuthConfig } from "./types.js";
6
6
  export { filterUiConnectors, type AccessTokenManagementCapability, type CredentialManagementCapability, type UiConnector, type UiData, } from "./operator-ui/model.js";
7
7
  /** Connecta's default monochrome "C" mark. */
@@ -82,10 +82,10 @@ export declare function credentialManagementCapability(input: {
82
82
  * isolated: they surface status "error" with an empty tool list rather than
83
83
  * failing the whole payload.
84
84
  */
85
- export declare function buildUiData(registry: Registry, baseUrl: string, serverInfo: {
85
+ export declare function buildUiData(registry: RegistryView, baseUrl: string, serverInfo: {
86
86
  name: string;
87
87
  version: string;
88
- }, credentialVault?: CredentialVault, activityEnabled?: boolean, credentialManagement?: CredentialManagementCapability, defer?: DeferredWork, oauthManagement?: boolean, discoveryConcurrency?: number, accessTokenManagement?: AccessTokenManagementCapability): Promise<UiData>;
88
+ }, credentialVault?: CredentialVault, activityEnabled?: boolean, credentialManagement?: CredentialManagementCapability, defer?: DeferredWork, oauthManagement?: boolean, discoveryConcurrency?: number, accessTokenManagement?: AccessTokenManagementCapability, personalCredentialOwner?: string): Promise<UiData>;
89
89
  /**
90
90
  * Every operator page serves this same data-free shell. Connector, credential,
91
91
  * and activity data arrives only through the authenticated `/ui/*` APIs.
package/dist/ui.js CHANGED
@@ -223,7 +223,7 @@ export function credentialManagementCapability(input) {
223
223
  */
224
224
  export async function buildUiData(registry, baseUrl, serverInfo, credentialVault, activityEnabled = false, credentialManagement = credentialVault
225
225
  ? "available"
226
- : "requires_operator", defer, oauthManagement = false, discoveryConcurrency, accessTokenManagement = "not_configured") {
226
+ : "requires_operator", defer, oauthManagement = false, discoveryConcurrency, accessTokenManagement = "not_configured", personalCredentialOwner) {
227
227
  const requestScope = {};
228
228
  const connectorSet = registry.listConnectors();
229
229
  const concurrency = resolveDiscoveryConcurrency(discoveryConcurrency);
@@ -232,6 +232,9 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
232
232
  const status = drift
233
233
  ? { state: "auth_required", message: drift }
234
234
  : await registry.statusFor(c.id, baseUrl, requestScope);
235
+ if (status.authorizationUrl) {
236
+ await registry.bindOAuthHandoff(c.id, status.authorizationUrl);
237
+ }
235
238
  let tools = [];
236
239
  // `status()` on an unauthenticated remote connector starts OAuth and
237
240
  // stores its state + PKCE verifier. Probing listTools immediately
@@ -253,7 +256,10 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
253
256
  }
254
257
  }
255
258
  let credential;
256
- if (c.credential && credentialVault) {
259
+ const mayManageAuth = c.authScope === "personal"
260
+ ? Boolean(personalCredentialOwner)
261
+ : oauthManagement;
262
+ if (c.credential && credentialVault && mayManageAuth) {
257
263
  // One rule, shared with the test route: only the hook matching the
258
264
  // declared credential shape can run, so the button is offered only
259
265
  // where a click can succeed (src/credentials.ts).
@@ -289,7 +295,7 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
289
295
  : {}),
290
296
  };
291
297
  try {
292
- const metadata = await credentialVault.metadata(c.id);
298
+ const metadata = await credentialVault.metadata(c.id, c.authScope === "personal" ? personalCredentialOwner : undefined);
293
299
  const fields = credentialFields(metadata);
294
300
  const shape = storedCredentialShape(c.credential, metadata?.fields ?? null);
295
301
  credential = {
@@ -331,6 +337,7 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
331
337
  }
332
338
  return {
333
339
  id: c.id,
340
+ authScope: c.authScope ?? "shared",
334
341
  ...(c.title ? { title: c.title } : {}),
335
342
  ...(c.description !== undefined
336
343
  ? { description: c.description }
@@ -350,7 +357,13 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
350
357
  ...(status.catalogAccess
351
358
  ? { catalogAccess: status.catalogAccess }
352
359
  : {}),
353
- ...(c.disconnectAuth && c.startAuth ? { oauth: true } : {}),
360
+ ...(c.disconnectAuth &&
361
+ c.startAuth &&
362
+ (oauthManagement ||
363
+ c.authScope === "personal" ||
364
+ !personalCredentialOwner)
365
+ ? { oauth: true }
366
+ : {}),
354
367
  ...(credential ? { credential } : {}),
355
368
  };
356
369
  });
@@ -367,7 +380,7 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
367
380
  activityEnabled,
368
381
  credentialManagement,
369
382
  accessTokenManagement,
370
- oauthManagement,
383
+ oauthManagement: oauthManagement || Boolean(personalCredentialOwner),
371
384
  };
372
385
  }
373
386
  function escapeHtmlAttr(value) {
package/dist/version.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export declare const CONNECTA_VERSION = "0.21.2";
7
+ export declare const CONNECTA_VERSION = "0.22.0";
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.21.2";
7
+ export const CONNECTA_VERSION = "0.22.0";
@@ -70,7 +70,7 @@ read top to bottom.
70
70
  | 2 | `OPTIONS` | Each auth provider's `handleMetadata` gets a chance (CORS preflight for browser MCP clients); otherwise 204 with MCP CORS. |
71
71
  | 3 | `/.well-known/*` | Auth providers' `handleMetadata`, open. 404 when none handles it. |
72
72
  | 4 | `/health` | Open JSON: status, connector count, `serverInfo`, the configured executor's sanitized name when it has one, catalog-drift counts, admission snapshots, reserved route names, and `deployment` when `deploymentInfo` is set. Payload-free by construction, and it never joins the MCP queue. |
73
- | 5 | `/oauth/callback/<connectorId>` | Downstream-OAuth completion, open, `verifyState` before `finishAuth`. |
73
+ | 5 | `/oauth/callback/<connectorId>` | Downstream-OAuth completion, open, `verifyState` before `finishAuth`. Personal flows first resolve the short-lived state hash to the principal partition. |
74
74
  | 6 | `/favicon.*`, `/ui` → `/`, the operator shells, `/ui/data` | The operator surface ([operator UI](./operator-ui.md)). The shells are open and data-free; `/ui/data` behind them is gated. Built-ins are matched before connector routes, so a connector cannot shadow a page. |
75
75
  | 7 | `/ui/activity` | Gated, plus the optional `activity.readGate`. `GET` only; 404 with no `activity.store.list`. |
76
76
  | 8 | `/mcp` | **Admission before auth**, then the auth gate, then a fresh MCP server. |
@@ -93,11 +93,15 @@ any one file and a reordering reads like a harmless refactor.
93
93
  before interactive providers. First `ok` admits; if all fail, the last provider's challenge
94
94
  response is returned. No providers configured means open — development
95
95
  only, and it warns at construction.
96
- 3. **Refuse `?toolkit=`.** Toolkits were removed ([#178](https://github.com/zackbart/connecta/issues/178))
96
+ 3. **Derive the registry view.** Auth supplies a namespaced subject and, for a
97
+ human, a principal. `identity.connectorAccess` selects declared connector
98
+ ids. Personal connectors use the principal partition; result paging uses
99
+ the subject partition. No caller parameter selects either.
100
+ 4. **Refuse `?toolkit=`.** Caller-selected toolkits were removed ([#178](https://github.com/zackbart/connecta/issues/178))
97
101
  but the URLs naming them were handed out, so the parameter is a 404 rather
98
102
  than silently serving the full registry. Retiring a scoping boundary into
99
103
  fail-open is the one outcome worse than the 404.
100
- 4. **Serve.** A fresh `McpServer` per request, the seven meta-tools registered
104
+ 5. **Serve.** A fresh `McpServer` per request, the seven meta-tools registered
101
105
  against the registry, the Apps shell resource registered (and
102
106
  `resources/list` deliberately answering with nothing), and the response
103
107
  handed back.
@@ -109,7 +113,7 @@ owns or hands out, and a change usually belongs in exactly one of them:
109
113
 
110
114
  | Module | Owns |
111
115
  | --- | --- |
112
- | `src/registry.ts` | The connector set, address resolution, catalog TTL/persistence/completeness, shared refresh single-flight, connector health, per-connector call limiters, and drift. Construction-time refusals live here. |
116
+ | `src/registry.ts` | The connector set, identity-scoped views, personal storage partitions, address resolution, catalog TTL/persistence/completeness, refresh single-flight, connector health, per-connector call limiters, and drift. Construction-time refusals live here. |
113
117
  | `src/catalog-service.ts` | Request-local tool listing, search, and describe. It coalesces reads inside one request and opts agent reads into the runtime's deferred catalog channel when one exists. |
114
118
  | `src/invocation.ts` | One tool call: argument validation, call admission, per-attempt timeout, retry with the connector's own `Retry-After` honoured exactly or declined, result unwrapping, size capping, and the activity record. |
115
119
  | `src/catalog.ts` | Ranking, description summarizing, and the compact schema renderer discovery shows. |
@@ -184,10 +188,12 @@ src/
184
188
 
185
189
  ## Sharp edges
186
190
 
187
- - **The registry is shared; the request is not.** Anything you cache on the
188
- registry is visible to every later request in that isolate. Anything you
189
- cache per request dies with it. Putting a downstream client on the wrong side
190
- of that line is the highest-severity mistake available here.
191
+ - **The root registry is shared; identity views are partitioned.** Shared
192
+ connector caches are visible to later requests in the isolate. Personal
193
+ connectors use a bounded principal registry, and transient results use the
194
+ authenticated subject. Anything cached per request still dies with it.
195
+ Putting a downstream client or credential on the wrong side of those lines
196
+ is the highest-severity mistake available here.
191
197
  - **Route order is behavior.** Moving a built-in below the connector dispatch
192
198
  hands a connector the ability to shadow it. Moving a mutation route below the
193
199
  wildcard `OPTIONS` opts it into CORS preflight.
@@ -6,6 +6,63 @@ Access identities on Workers, or a mixture. Static bearers are checked first;
6
6
  the remaining providers keep configuration order. The first successful
7
7
  identity owns the activity actor for that request.
8
8
 
9
+ ## Principals, visibility, and operators
10
+
11
+ Connecta distinguishes three identities. The actor is the exact caller written
12
+ to activity. The subject is any stable authenticated caller and owns transient
13
+ results such as `get_result` pages. The principal is the human owner of personal
14
+ connector auth. An interactive Clerk or Access user supplies all three. A
15
+ Cloudflare service identity has an actor and subject but no principal. A
16
+ connecta access token has its own actor and subject and inherits the principal
17
+ that created it, so agents using that token reach the creator's personal
18
+ connections without becoming operators.
19
+
20
+ `identity.connectorAccess` derives the connector ids a caller may discover and
21
+ invoke. The resolver receives authenticated identity data, never request input,
22
+ and returns `"all"` or a list of ids declared in `connectors`. An unknown id or
23
+ a thrown resolver fails the request closed.
24
+
25
+ `identity.connectorAccess` is also the credential-management boundary. A
26
+ signed-in human may save, test, disconnect, and authorize every visible
27
+ connector: personal auth changes only that principal's partition, while shared
28
+ auth changes the deployment-wide grant for everyone who can see the connector.
29
+ Use `authScope: "personal"` when one member must not rotate another member's
30
+ connection.
31
+
32
+ `identity.operatorAccess` separately reserves deployment-wide administration:
33
+ access-token creation and global activity history. Omit the resolver to
34
+ preserve the prior rule that every interactive human is an operator. When it is
35
+ configured, activity history is operator-only because its global event stream
36
+ contains other principals' connector names and actors.
37
+
38
+ ```ts
39
+ createConnecta({
40
+ auth: cloudflareAccessAuth(),
41
+ identity: {
42
+ connectorAccess: ({ principal }) =>
43
+ principal?.id === "user_a"
44
+ ? ["shared_docs", "personal_linear"]
45
+ : ["shared_docs"],
46
+ operatorAccess: ({ id }) => id === "user_a",
47
+ },
48
+ connectors: [
49
+ remoteMcp("shared_docs", { url: "https://example.com/mcp" }),
50
+ remoteMcp("personal_linear", {
51
+ url: "https://mcp.linear.app/mcp",
52
+ authScope: "personal",
53
+ auth: { type: "oauth" },
54
+ }),
55
+ ],
56
+ executor,
57
+ });
58
+ ```
59
+
60
+ Identity namespaces matter. Built-in Clerk and Access providers supply one.
61
+ A custom interactive provider must set `activityActorNamespace` before its
62
+ users can own personal auth. It may still use the legacy operator behavior
63
+ without one, but connecta will not merge unnamespaced users into personal
64
+ storage.
65
+
9
66
  ## Cloudflare Access on Workers
10
67
 
11
68
  [`cloudflareAccessAuth()`](https://developers.cloudflare.com/workers/configuration/cloudflare-access/)
@@ -35,7 +92,8 @@ also means it is deliberately not a Node or `cloudflared` origin adapter, and
35
92
  it does not survive a Service Binding hop: those shapes need their own explicit
36
93
  trust boundary.
37
94
 
38
- A human identity gets MCP and operator access. A Cloudflare service-token
95
+ A human identity gets MCP and personal-connection access. It gets operator
96
+ access unless `identity.operatorAccess` says otherwise. A Cloudflare service-token
39
97
  identity gets MCP access and a stable activity subject, but no `userId`, so it
40
98
  cannot write credentials, run downstream OAuth mutations, or issue connecta
41
99
  tokens. Access policy decides who reaches the Worker; connecta does not mirror
@@ -48,7 +106,27 @@ the Worker. Enable [**Managed OAuth**](https://developers.cloudflare.com/cloudfl
48
106
  on that Worker-level application for interactive MCP clients.
49
107
  Cloudflare then owns the unauthenticated challenge and `/.well-known/`
50
108
  metadata, issues opaque RFC 8707 tokens, and resolves them into the same trusted
51
- Worker identity. Do not add a bypass for the discovery routes. A fully
109
+ Worker identity. Managed OAuth allows no hosted client callback by default, so
110
+ enable Dynamic Client Registration and add all three values to **Allowed
111
+ redirect URIs**:
112
+
113
+ ```text
114
+ https://claude.ai/api/mcp/auth_callback
115
+ https://chatgpt.com/connector_platform_oauth_redirect
116
+ https://chatgpt.com/connector/oauth/*
117
+ ```
118
+
119
+ Cloudflare exposes that list as
120
+ `oauth_configuration.dynamic_client_registration.allowed_uris`. It belongs to
121
+ the Access application's Managed OAuth settings, not the Access policy that
122
+ selects admitted identities. Claude uses the fixed first value. ChatGPT may use
123
+ its stable callback or a callback-id path covered by the third value. If a
124
+ client registers a different redirect, add that exact URI or the narrowest path
125
+ wildcard that covers it; do not allow the client's whole origin. Without these
126
+ entries discovery succeeds and client registration fails later, which makes a
127
+ missing allowlist look like a broken MCP server.
128
+
129
+ Do not add a bypass for the discovery routes. A fully
52
130
  automated client instead uses a [Cloudflare Access service token](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/)
53
131
  through the
54
132
  `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers.
@@ -102,7 +180,9 @@ secure.
102
180
  Each token has an immutable ID. Activity records store that ID and resolve its
103
181
  current friendly name only while an authorized operator reads activity.
104
182
  Revoked records remain as metadata tombstones so historical calls keep their
105
- friendly attribution.
183
+ friendly attribution. New tokens also retain the creating principal. Their MCP
184
+ requests use that principal's connector visibility and personal auth while the
185
+ token itself remains the activity actor and result owner.
106
186
 
107
187
  Access tokens authenticate MCP clients; they are never operator credentials.
108
188
  Creation, rename, and revocation require the same eligible human identity and
@@ -113,20 +193,20 @@ Issuance and revocation inherit the consistency guarantees of the configured
113
193
  storage adapter. Use strongly consistent storage when either change must take
114
194
  effect globally without a convergence window.
115
195
 
116
- Operator credential mutation is a separate, narrower boundary. The
196
+ Human credential mutation is a separate, narrower boundary. The
117
197
  `/credentials` shell contains no secret data before authentication, and the
118
- mutation API requires same-origin requests from an admitted operator. An MCP
119
- bearer is never treated as an operator credential, even when it can call every
120
- connector.
198
+ mutation API requires same-origin requests from an admitted interactive human.
199
+ That human may mutate only visible connector slots. An MCP bearer is never
200
+ treated as a browser credential, even when it can call every connector.
121
201
 
122
202
  This split is visible in recovery:
123
203
 
124
204
  - a bearer-authenticated agent may receive `recovery: "operator_config"` and
125
205
  pass its `operatorUrl` to a human;
126
- - an interactive operator opens that URL, signs in, and updates the
206
+ - an interactive human with connector access opens that URL, signs in, and updates the
127
207
  credential; and
128
208
  - a bearer-only deployment still returns the handoff honestly, but mutation
129
- remains unavailable until interactive operator auth is configured.
209
+ remains unavailable until interactive user auth is configured.
130
210
 
131
211
  See [meta-tools](./meta-tools.md#authorization-recovery) for the stable recovery
132
212
  envelope and [storage and credentials](./storage-and-credentials.md) for vault