@zackbart/connecta 0.21.1 → 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 (66) hide show
  1. package/CHANGELOG.md +83 -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/auth/downstream-oauth.d.ts +65 -2
  6. package/dist/auth/downstream-oauth.js +408 -20
  7. package/dist/connectors/api.d.ts +2 -0
  8. package/dist/connectors/api.js +1 -0
  9. package/dist/connectors/remote-mcp.d.ts +2 -0
  10. package/dist/connectors/remote-mcp.js +14 -4
  11. package/dist/credentials.d.ts +6 -6
  12. package/dist/credentials.js +25 -21
  13. package/dist/executors/quickjs.js +4 -0
  14. package/dist/identity.d.ts +4 -0
  15. package/dist/identity.js +17 -0
  16. package/dist/index.d.ts +16 -2
  17. package/dist/index.js +6 -1
  18. package/dist/meta-tools.js +7 -2
  19. package/dist/operator-ui/generated.js +1 -1
  20. package/dist/operator-ui/model.d.ts +4 -2
  21. package/dist/operator-ui/view.js +1 -1
  22. package/dist/providers/cloudflare.d.ts +2 -0
  23. package/dist/providers/cloudflare.js +1 -0
  24. package/dist/providers/linear.d.ts +2 -0
  25. package/dist/providers/linear.js +1 -0
  26. package/dist/providers/mixpanel.d.ts +2 -0
  27. package/dist/providers/mixpanel.js +1 -0
  28. package/dist/providers/notion.d.ts +2 -0
  29. package/dist/providers/notion.js +1 -0
  30. package/dist/providers/revenuecat.d.ts +2 -0
  31. package/dist/providers/revenuecat.js +1 -0
  32. package/dist/providers/stripe.d.ts +2 -0
  33. package/dist/providers/stripe.js +1 -0
  34. package/dist/registry.d.ts +25 -0
  35. package/dist/registry.js +200 -4
  36. package/dist/routes/access-tokens.js +2 -2
  37. package/dist/routes/activity.js +4 -1
  38. package/dist/routes/credentials.js +31 -12
  39. package/dist/routes/mcp.js +17 -2
  40. package/dist/routes/oauth.js +55 -11
  41. package/dist/routes/shared.d.ts +20 -4
  42. package/dist/routes/shared.js +92 -24
  43. package/dist/routes/ui.js +32 -13
  44. package/dist/types.d.ts +28 -2
  45. package/dist/ui.d.ts +3 -3
  46. package/dist/ui.js +18 -5
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/documentation/architecture.md +31 -8
  50. package/documentation/auth.md +90 -10
  51. package/documentation/code-mode.md +4 -4
  52. package/documentation/connectors.md +13 -0
  53. package/documentation/meta-tools.md +4 -3
  54. package/documentation/operations.md +5 -3
  55. package/documentation/operator-ui.md +13 -4
  56. package/documentation/request-admission.md +2 -1
  57. package/documentation/storage-and-credentials.md +77 -4
  58. package/documentation/upgrading.md +38 -7
  59. package/ethos.md +8 -8
  60. package/examples/worker/AGENTS.md +44 -0
  61. package/examples/worker/README.md +63 -14
  62. package/examples/worker/src/index.ts +26 -22
  63. package/package.json +1 -1
  64. package/templates/node/README.md +7 -0
  65. package/templates/node/package.json +1 -1
  66. 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.1";
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.1";
7
+ export const CONNECTA_VERSION = "0.22.0";
@@ -20,6 +20,23 @@ isolate or process —
20
20
  on Workers that means a lazy module-scope singleton, which is why both
21
21
  deployment shapes build it outside the request handler.
22
22
 
23
+ An OAuth `remoteMcp()` connector also owns a runtime-local refresh completion
24
+ gate. It coordinates credential mutation across concurrent request scopes but
25
+ never shares their clients, transports, or responses, and never lets a follower
26
+ cancel the owner. Every participant still awaits the refresh inside its own
27
+ request lifetime; a cancelled follower leaves the shared owner untouched and
28
+ removes only its own wait. The owner's request signal belongs to its token
29
+ fetch. Cancelling that owner fails current joiners too because promoting one
30
+ could replay a refresh token the authorization server already consumed.
31
+
32
+ The coordinator retains the owner's abort signal only through one temporary
33
+ listener on the exact active refresh. Save, failure, cancellation, or
34
+ generation retirement removes it along with the map entry. It never retains a
35
+ token response, client, or transport. If cancellation lands after a valid
36
+ response while its credential write is still running, a generation-keyed
37
+ identity marker rejects new owners until that exact write finishes. The marker
38
+ contains no promise and generation retirement removes it.
39
+
23
40
  **Per request, and no longer.** The MCP server, its transport, downstream MCP
24
41
  clients, abort signals, and the connector scope a probe opens all belong to the
25
42
  request that created them. `Nothing request-bound survives a request` is an
@@ -53,7 +70,7 @@ read top to bottom.
53
70
  | 2 | `OPTIONS` | Each auth provider's `handleMetadata` gets a chance (CORS preflight for browser MCP clients); otherwise 204 with MCP CORS. |
54
71
  | 3 | `/.well-known/*` | Auth providers' `handleMetadata`, open. 404 when none handles it. |
55
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. |
56
- | 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. |
57
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. |
58
75
  | 7 | `/ui/activity` | Gated, plus the optional `activity.readGate`. `GET` only; 404 with no `activity.store.list`. |
59
76
  | 8 | `/mcp` | **Admission before auth**, then the auth gate, then a fresh MCP server. |
@@ -76,11 +93,15 @@ any one file and a reordering reads like a harmless refactor.
76
93
  before interactive providers. First `ok` admits; if all fail, the last provider's challenge
77
94
  response is returned. No providers configured means open — development
78
95
  only, and it warns at construction.
79
- 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))
80
101
  but the URLs naming them were handed out, so the parameter is a 404 rather
81
102
  than silently serving the full registry. Retiring a scoping boundary into
82
103
  fail-open is the one outcome worse than the 404.
83
- 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
84
105
  against the registry, the Apps shell resource registered (and
85
106
  `resources/list` deliberately answering with nothing), and the response
86
107
  handed back.
@@ -92,7 +113,7 @@ owns or hands out, and a change usually belongs in exactly one of them:
92
113
 
93
114
  | Module | Owns |
94
115
  | --- | --- |
95
- | `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. |
96
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. |
97
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. |
98
119
  | `src/catalog.ts` | Ranking, description summarizing, and the compact schema renderer discovery shows. |
@@ -167,10 +188,12 @@ src/
167
188
 
168
189
  ## Sharp edges
169
190
 
170
- - **The registry is shared; the request is not.** Anything you cache on the
171
- registry is visible to every later request in that isolate. Anything you
172
- cache per request dies with it. Putting a downstream client on the wrong side
173
- 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.
174
197
  - **Route order is behavior.** Moving a built-in below the connector dispatch
175
198
  hands a connector the ability to shadow it. Moving a mutation route below the
176
199
  wildcard `OPTIONS` opts it into CORS preflight.