@zackbart/connecta 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +18 -10
  4. package/dist/activity-friction.d.ts +3 -0
  5. package/dist/activity-friction.js +19 -0
  6. package/dist/activity.d.ts +11 -2
  7. package/dist/activity.js +15 -19
  8. package/dist/auth/downstream-oauth.d.ts +2 -1
  9. package/dist/auth/downstream-oauth.js +10 -1
  10. package/dist/branding.d.ts +67 -0
  11. package/dist/branding.js +176 -0
  12. package/dist/connectors/remote-mcp.js +3 -5
  13. package/dist/credential-contract.d.ts +24 -0
  14. package/dist/credential-contract.js +1 -0
  15. package/dist/credential-rules.d.ts +85 -0
  16. package/dist/credential-rules.js +107 -0
  17. package/dist/credentials.d.ts +4 -100
  18. package/dist/credentials.js +3 -107
  19. package/dist/index.d.ts +22 -55
  20. package/dist/index.js +30 -58
  21. package/dist/invocation.js +2 -3
  22. package/dist/meta-tools.d.ts +4 -0
  23. package/dist/meta-tools.js +8 -4
  24. package/dist/module-contracts.d.ts +19 -0
  25. package/dist/module-contracts.js +1 -0
  26. package/dist/operator-ui/generated.js +2 -2
  27. package/dist/operator-ui/model.d.ts +6 -3
  28. package/dist/operator-ui/view.d.ts +2 -18
  29. package/dist/operator-ui/view.js +3 -20
  30. package/dist/registry.d.ts +4 -1
  31. package/dist/registry.js +4 -6
  32. package/dist/routes/activity.js +1 -1
  33. package/dist/routes/credentials.js +5 -2
  34. package/dist/routes/mcp.js +7 -3
  35. package/dist/routes/oauth-management.d.ts +2 -0
  36. package/dist/routes/oauth-management.js +108 -0
  37. package/dist/routes/oauth.d.ts +0 -1
  38. package/dist/routes/oauth.js +21 -121
  39. package/dist/routes/shared.d.ts +19 -17
  40. package/dist/routes/shared.js +48 -44
  41. package/dist/routes/ui.js +36 -33
  42. package/dist/server.js +6 -26
  43. package/dist/types.d.ts +2 -0
  44. package/dist/ui.d.ts +15 -70
  45. package/dist/ui.js +176 -317
  46. package/dist/version.d.ts +1 -1
  47. package/dist/version.js +1 -1
  48. package/documentation/architecture.md +26 -17
  49. package/documentation/auth.md +61 -106
  50. package/documentation/cloudflare.md +1 -1
  51. package/documentation/connectors.md +1 -1
  52. package/documentation/linear.md +1 -1
  53. package/documentation/meta-tools.md +6 -4
  54. package/documentation/mixpanel.md +1 -1
  55. package/documentation/notion.md +2 -2
  56. package/documentation/operations.md +12 -14
  57. package/documentation/operator-ui.md +82 -104
  58. package/documentation/optional-modules-upgrade.md +243 -0
  59. package/documentation/provider-conventions.md +5 -3
  60. package/documentation/revenuecat.md +1 -1
  61. package/documentation/storage-and-credentials.md +59 -40
  62. package/documentation/stripe.md +1 -1
  63. package/documentation/upgrading.md +29 -4
  64. package/ethos.md +22 -30
  65. package/examples/worker/AGENTS.md +3 -1
  66. package/examples/worker/README.md +68 -84
  67. package/examples/worker/src/d1-activity.ts +1 -1
  68. package/examples/worker/src/index.ts +11 -6
  69. package/package.json +18 -2
  70. package/templates/node/AGENTS.md +8 -6
  71. package/templates/node/README.md +56 -67
  72. package/templates/node/package.json +1 -1
  73. package/templates/node/src/file-activity.ts +1 -1
  74. package/templates/node/src/index.ts +11 -12
  75. package/dist/access-tokens.d.ts +0 -31
  76. package/dist/access-tokens.js +0 -236
  77. package/dist/routes/access-tokens.d.ts +0 -6
  78. package/dist/routes/access-tokens.js +0 -83
package/AGENTS.md CHANGED
@@ -82,6 +82,11 @@ Two boundaries CI enforces that are not obvious from reading a file:
82
82
  unreachable from the root entry. `test/purity.test.ts` walks the import graph
83
83
  and fails otherwise. Need a Node API? It goes behind an explicit Node-only
84
84
  subpath (`/node` or `/quickjs`), never the root.
85
+ - **Optional modules.** Core owns catalog discovery, execution, invocation, and
86
+ enforcement. UI, activity history, encrypted credentials, and bearer auth
87
+ implementations stay behind explicit subpaths and outside the root import
88
+ graph. Use the typed `ui`, `activity`, and `vault` slots; do not add a generic
89
+ plugin registry or runtime installation.
85
90
  - **The published surface.** Platform-specific storage adapters live in
86
91
  `examples/worker/`, never in `src/` and never in the `exports` map. That
87
92
  example does ship in the tarball, Cloudflare KV and D1 adapters included —
package/CHANGELOG.md CHANGED
@@ -2,6 +2,46 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
+ ## 0.24.0 — 2026-09-07
6
+
7
+ Deployments now select UI, encrypted credentials, activity history, and inbound
8
+ auth through explicit module imports. Core keeps discovery, execution,
9
+ invocation, and enforcement together. This breaks configuration and removes
10
+ Connecta-issued client tokens; migrate those clients before upgrading. Shared
11
+ and personal auth management now require explicit permissions. Existing vault
12
+ and OAuth state need no format migration. See the
13
+ [detailed migration guide](./documentation/upgrading.md#0240-optional-modules)
14
+ for before-and-after configuration, team and personal deployment examples,
15
+ client migration, and verification.
16
+
17
+ ### Added
18
+
19
+ - Optional `operatorUi`, `encryptedCredentialVault`, and `activityHistory`
20
+ factories behind `/ui`, `/credentials`, and `/activity`, with contracts in core.
21
+ - Separate config-derived `credentialAdministration` and `personalConnection`
22
+ permissions, both denied by default; `activityAccess` controls history reads.
23
+ - Explicit `logger: "silent"`, independent of activity recording.
24
+
25
+ ### Changed
26
+
27
+ - Move configured bearer authentication to `/auth/bearer`. Remove Connecta-issued
28
+ tokens and their management routes; old records remain inert in storage.
29
+ - Move root branding into UI options, replace `credentials` with `vault`, and
30
+ construct activity through its factory. Removed configuration fails at startup.
31
+ - Put credential and OAuth controls inside Connections; remove separate
32
+ Credentials and Tokens tabs. Show the current user's effective permissions.
33
+ - Keep optional implementations outside the root import graph. Omitted modules
34
+ contribute no runtime work or UI routes; OAuth callbacks remain in core.
35
+
36
+ ### Fixed
37
+
38
+ - Return the configured connection list without awaiting provider checks, then
39
+ load bounded connection details independently. Auth action feedback no longer
40
+ waits for an unrelated catalog reload.
41
+ - Start OAuth only through explicit authorized actions, never status reads.
42
+ - Return unavailable credential recovery when no UI or vault is mounted, and
43
+ complete UI-free OAuth without a dead return link.
44
+
5
45
  ## 0.23.0 — 2026-09-07
6
46
 
7
47
  This release removes server-owned program views, connector HTTP routes, and
package/README.md CHANGED
@@ -78,18 +78,26 @@ Fifty issues in, one small object out. Your context window notices.
78
78
  - **Run it where you like.** Node, a Docker container, or a Cloudflare Worker,
79
79
  from the same small deployment file.
80
80
 
81
- There is also an operator surface, off until you turn it on: sign-in, an
82
- encrypted credential vault with rotation, revocable per-client tokens, and a
83
- payload-free activity log. Worker deployments can use Cloudflare Access for
84
- both MCP and operator identity; Node deployments and existing Workers can use
85
- Clerk.
81
+ Deployments explicitly compose optional features: `operatorUi()` from
82
+ `@zackbart/connecta/ui`, `encryptedCredentialVault()` from `/credentials`,
83
+ `activityHistory()` from `/activity`, and inbound authentication adapters from
84
+ `/auth/*`. Omit a module and its implementation does no runtime work. Core
85
+ keeps connector discovery, execution, invocation, and enforcement together.
86
+
87
+ The optional UI shows each person's connections and effective permissions.
88
+ Authentication controls live inside each connection, with optional activity
89
+ history. The configured connection list loads before downstream checks finish;
90
+ a slow provider does not hold up the page. Connector selection and access rules
91
+ remain in deployment code.
86
92
 
87
93
  One deployment may serve several authenticated people inside the same tenant.
88
- Configuration can derive connector visibility from the admitted identity, and
89
- each connector may keep one shared downstream grant or a separate encrypted
90
- grant per human. Connecta does not own accounts or groups; Clerk or Cloudflare
91
- Access remains the identity provider. See [inbound auth](./documentation/auth.md#principals-visibility-and-operators)
92
- and [shared and personal auth](./documentation/storage-and-credentials.md#shared-and-personal-auth).
94
+ Cloudflare Access supplies Worker identity; Node can use Clerk or the optional
95
+ configured bearer adapter. Connecta owns no accounts or groups and issues no
96
+ client access tokens. Shared-credential administration and personal connection
97
+ setup require separate explicit permissions, both denied by default. See
98
+ [inbound auth](./documentation/auth.md#principals-visibility-and-operators),
99
+ [shared and personal auth](./documentation/storage-and-credentials.md#shared-and-personal-auth),
100
+ and the [module migration guide](./documentation/upgrading.md#0240-optional-modules).
93
101
 
94
102
  Connecta is not a platform, a marketplace, a policy engine, or a multi-tenant
95
103
  service. Those are decisions, and the [ethos](./ethos.md) records each one
@@ -0,0 +1,3 @@
1
+ import type { AgentFriction } from "./activity.js";
2
+ /** Coarse recovery class derived without inspecting payloads or error prose. */
3
+ export declare function agentFrictionForCode(code: string | undefined): AgentFriction | undefined;
@@ -0,0 +1,19 @@
1
+ /** Coarse recovery class derived without inspecting payloads or error prose. */
2
+ export function agentFrictionForCode(code) {
3
+ switch (code) {
4
+ case "unknown_address":
5
+ case "unknown_tool":
6
+ case "ambiguous_tool_alias":
7
+ return "tool_not_found";
8
+ case "invalid_args":
9
+ return "schema_retry";
10
+ case "destructive_tool_requires_approval":
11
+ return "destructive_reroute";
12
+ case "auth_required":
13
+ return "auth_required";
14
+ case "result_too_large":
15
+ return "result_too_large";
16
+ default:
17
+ return undefined;
18
+ }
19
+ }
@@ -1,9 +1,9 @@
1
+ import type { ActivityModule } from "./module-contracts.js";
1
2
  import type { CatalogDriftCounts, Logger } from "./types.js";
2
3
  export type ActivityCallSource = "call_tool" | "call_destructive_tool" | "batch_call" | "execute_code";
3
4
  export type ActivityOutcome = "success" | "error" | "timeout" | "cancelled";
4
5
  export type AgentFriction = "tool_not_found" | "schema_retry" | "destructive_reroute" | "auth_required" | "result_too_large";
5
- /** Coarse recovery class derived without inspecting payloads or error prose. */
6
- export declare function agentFrictionForCode(code: string | undefined): AgentFriction | undefined;
6
+ export { agentFrictionForCode } from "./activity-friction.js";
7
7
  /**
8
8
  * Authenticated identity attached to an activity event. `id` is intentionally
9
9
  * optional: open deployments and shared bearer tokens cannot honestly identify
@@ -119,6 +119,7 @@ export type ActivityReadGate = (actor: ActivityActor) => boolean | Promise<boole
119
119
  * did, so it carries no actor and no request id to attribute it to one.
120
120
  */
121
121
  export interface CatalogDriftActivityContext {
122
+ recordDrift?: typeof recordCatalogDriftActivity;
122
123
  sink: ActivitySink;
123
124
  serverInfo: {
124
125
  name: string;
@@ -129,6 +130,7 @@ export interface CatalogDriftActivityContext {
129
130
  }
130
131
  /** Request-scoped context shared by direct, batch, and code-mode call paths. */
131
132
  export interface ActivityRequestContext {
133
+ recordTool?: typeof recordToolActivity | undefined;
132
134
  sink: ActivitySink;
133
135
  actor: ActivityActor;
134
136
  requestId: string;
@@ -155,3 +157,10 @@ export declare function recordToolActivity(context: ActivityRequestContext | und
155
157
  export declare function recordCatalogDriftActivity(context: CatalogDriftActivityContext | undefined, input: {
156
158
  connectorId: string;
157
159
  } & CatalogDriftCounts): void;
160
+ export interface ActivityHistoryOptions {
161
+ store: ActivityStore;
162
+ deploymentId?: string;
163
+ readGate?: ActivityReadGate;
164
+ }
165
+ /** Attach payload-free history without changing tool results on store failure. */
166
+ export declare function activityHistory(options: ActivityHistoryOptions): ActivityModule;
package/dist/activity.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { routeActivity } from "./routes/activity.js";
1
2
  import { boundedEchoText } from "./errors.js";
2
3
  /**
3
4
  * How long an identity field may be before the store stops believing it.
@@ -16,25 +17,8 @@ import { boundedEchoText } from "./errors.js";
16
17
  const MAX_ACTIVITY_NAME_BYTES = 128;
17
18
  /** Two names and the dot between them. */
18
19
  const MAX_ACTIVITY_ADDRESS_BYTES = MAX_ACTIVITY_NAME_BYTES * 2 + 1;
19
- /** Coarse recovery class derived without inspecting payloads or error prose. */
20
- export function agentFrictionForCode(code) {
21
- switch (code) {
22
- case "unknown_address":
23
- case "unknown_tool":
24
- case "ambiguous_tool_alias":
25
- return "tool_not_found";
26
- case "invalid_args":
27
- return "schema_retry";
28
- case "destructive_tool_requires_approval":
29
- return "destructive_reroute";
30
- case "auth_required":
31
- return "auth_required";
32
- case "result_too_large":
33
- return "result_too_large";
34
- default:
35
- return undefined;
36
- }
37
- }
20
+ import { agentFrictionForCode } from "./activity-friction.js";
21
+ export { agentFrictionForCode } from "./activity-friction.js";
38
22
  /** Reader implementations throw this for an opaque cursor they cannot decode. */
39
23
  export class InvalidActivityCursorError extends Error {
40
24
  name = "InvalidActivityCursorError";
@@ -123,3 +107,15 @@ export function recordCatalogDriftActivity(context, input) {
123
107
  context.logger.warn("[connecta] catalog drift record failed", error);
124
108
  }
125
109
  }
110
+ /** Attach payload-free history without changing tool results on store failure. */
111
+ export function activityHistory(options) {
112
+ if (!options || typeof options.store?.record !== "function") {
113
+ throw new Error("activityHistory.store must implement record(event)");
114
+ }
115
+ return {
116
+ ...options,
117
+ handle: routeActivity,
118
+ recordTool: recordToolActivity,
119
+ recordDrift: recordCatalogDriftActivity,
120
+ };
121
+ }
@@ -71,6 +71,7 @@ export declare class KvOAuthProvider implements OAuthClientProvider {
71
71
  private readonly storage;
72
72
  private readonly redirectUri;
73
73
  private readonly refreshCoordinator?;
74
+ private readonly allowAuthorization;
74
75
  /**
75
76
  * The reset generation this provider's flow started under. Every OAuth value
76
77
  * it writes carries this epoch, so a late write can land after a reset without
@@ -80,7 +81,7 @@ export declare class KvOAuthProvider implements OAuthClientProvider {
80
81
  private refreshFlight;
81
82
  /** Tokens this request's issuer-aware auth flow decided to refresh. */
82
83
  private refreshBasis;
83
- constructor(connectorId: string, storage: KVStorage, redirectUri: string, refreshCoordinator?: OAuthRefreshCoordinator | undefined);
84
+ constructor(connectorId: string, storage: KVStorage, redirectUri: string, refreshCoordinator?: OAuthRefreshCoordinator | undefined, allowAuthorization?: boolean);
84
85
  /**
85
86
  * Stamp the force-reauth generation the current connect flow started under.
86
87
  * Called by the connector once per connect, before c.connect(). The callback
@@ -1,3 +1,4 @@
1
+ import { UnauthorizedError } from "@modelcontextprotocol/client";
1
2
  /** A 256-bit random opaque value, hex-encoded — used for the OAuth `state`. */
2
3
  function randomState() {
3
4
  const bytes = new Uint8Array(32);
@@ -383,6 +384,7 @@ export class KvOAuthProvider {
383
384
  storage;
384
385
  redirectUri;
385
386
  refreshCoordinator;
387
+ allowAuthorization;
386
388
  /**
387
389
  * The reset generation this provider's flow started under. Every OAuth value
388
390
  * it writes carries this epoch, so a late write can land after a reset without
@@ -392,11 +394,12 @@ export class KvOAuthProvider {
392
394
  refreshFlight;
393
395
  /** Tokens this request's issuer-aware auth flow decided to refresh. */
394
396
  refreshBasis;
395
- constructor(connectorId, storage, redirectUri, refreshCoordinator) {
397
+ constructor(connectorId, storage, redirectUri, refreshCoordinator, allowAuthorization = true) {
396
398
  this.connectorId = connectorId;
397
399
  this.storage = storage;
398
400
  this.redirectUri = redirectUri;
399
401
  this.refreshCoordinator = refreshCoordinator;
402
+ this.allowAuthorization = allowAuthorization;
400
403
  }
401
404
  /**
402
405
  * Stamp the force-reauth generation the current connect flow started under.
@@ -666,6 +669,8 @@ export class KvOAuthProvider {
666
669
  * clearPending() once the flow completes.
667
670
  */
668
671
  async state() {
672
+ if (!this.allowAuthorization)
673
+ throw new UnauthorizedError("Authorization required. Use authorize_connector or Connect to start consent.");
669
674
  const value = randomState();
670
675
  await this.writeValue("oauth:state", value, (raw) => raw);
671
676
  return value;
@@ -684,6 +689,8 @@ export class KvOAuthProvider {
684
689
  return matches;
685
690
  }
686
691
  async saveCodeVerifier(verifier) {
692
+ if (!this.allowAuthorization)
693
+ throw new UnauthorizedError("Authorization required. Use authorize_connector or Connect to start consent.");
687
694
  await this.writeValue("oauth:verifier", verifier, (raw) => raw);
688
695
  }
689
696
  async codeVerifier() {
@@ -694,6 +701,8 @@ export class KvOAuthProvider {
694
701
  return stored.value;
695
702
  }
696
703
  async redirectToAuthorization(authorizationUrl) {
704
+ if (!this.allowAuthorization)
705
+ throw new UnauthorizedError("Authorization required. Use authorize_connector or Connect to start consent.");
697
706
  try {
698
707
  await this.writeValue("oauth:pending", authorizationUrl.toString(), (raw) => raw);
699
708
  this.failRefreshFlight(new Error("OAuth refresh required reauthorization before tokens were saved."));
@@ -0,0 +1,67 @@
1
+ import type { ConnectaBranding, UiAuthConfig } from "./types.js";
2
+ /** Connecta's default monochrome "C" mark. */
3
+ export declare const CONNECTA_FAVICON_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 32 32\">\n <style>\n .fg { fill: #000 }\n @media (prefers-color-scheme: dark) { .fg { fill: #fff } }\n </style>\n <path class=\"fg\" d=\"M27 9.4A13 13 0 1 0 27 22.6l-4.4-2.5a8 8 0 1 1 0-8.2z\"/>\n</svg>";
4
+ interface ResolvedBranding {
5
+ productName: string;
6
+ productUrl?: string;
7
+ ownerName?: string;
8
+ ownerUrl?: string;
9
+ description: string;
10
+ /** Browser tab title and page meta name. */
11
+ pageTitle: string;
12
+ /** href for the page's icon link. */
13
+ faviconHref: string;
14
+ themeColor: string;
15
+ }
16
+ export declare function resolveBranding(branding?: ConnectaBranding): ResolvedBranding;
17
+ /**
18
+ * Names of the branding URLs the operator set that failed their gate and were
19
+ * replaced by a default. Lives beside the gates so the startup warning cannot
20
+ * drift from them, and takes `unknown` fields for the same reason
21
+ * `resolveBranding` does — a warning helper must never throw.
22
+ */
23
+ export declare function droppedBrandingUrls(branding?: ConnectaBranding): string[];
24
+ export declare function isSafeHttpUrl(url: unknown): boolean;
25
+ /**
26
+ * True for values allowed in the page's `<link rel="icon" href>`: an absolute
27
+ * `http(s)` URL (an icon the operator hosts elsewhere) or a path rooted at this
28
+ * origin. The relative carve-out is deliberate rather than accidental — the
29
+ * default href is the relative `/favicon.svg`, which `isSafeHttpUrl` alone would
30
+ * reject — and it is kept narrow on both ends.
31
+ *
32
+ * Root-relative only, because operator and OAuth callback pages sit at
33
+ * different depths and a document-relative path would resolve differently.
34
+ *
35
+ * "Root-relative" is enforced structurally: exactly one leading `/` followed by
36
+ * a character that is neither `/` nor `\`. Both of those would make the value an
37
+ * authority (`//host`, and `/\host` because the URL parser folds `\` to `/` in
38
+ * special schemes), pointing at an origin this server does not control. The test
39
+ * runs on a copy with tab/newline/CR removed, since the parser strips those
40
+ * anywhere and `/\t/host` would otherwise slip through as single-slash. The
41
+ * origin comparison that follows is defense in depth, not the authority check —
42
+ * on its own it would accept an authority that happened to equal the probe host.
43
+ */
44
+ export declare function isSafeIconHref(href: unknown): boolean;
45
+ /** Absolute HTTPS gate for the `UiAuthConfig` URL fields documented in types.ts. */
46
+ export declare function isSafeHttpsUrl(url: unknown): boolean;
47
+ /**
48
+ * Names of the `uiAuth` URLs an inbound-auth provider supplied that failed their
49
+ * gate. Lives beside the gate for the same reason `droppedBrandingUrls` does: the
50
+ * startup warning cannot then drift from what rendering actually drops. Every
51
+ * field is read defensively rather than trusted, because a custom `InboundAuth`
52
+ * is untyped at a JS call site — `isSafeHttpsUrl` takes `unknown`, and a
53
+ * `uiAuth` that is not the clerk shape is reported as nothing to warn about.
54
+ *
55
+ * `frontendApiUrl` is required, so anything that fails its gate is a drop.
56
+ * `signInUrl` and `signUpUrl` are optional, so only a value the operator
57
+ * *supplied* and the gate then rejected is worth a warning — an unset field
58
+ * took no default away from anyone. `isSetUrlValue` decides that, the same way
59
+ * and for the same reasons it decides it for the branding URLs: a warning that
60
+ * fires for one and not the other would be reporting on the field rather than
61
+ * on the operator's intent. Rendering is not consulted for this: it drops on
62
+ * the gate alone, and a blank string fails that gate too — it is simply not
63
+ * *reported*, because a blank is indistinguishable from leaving the field
64
+ * alone.
65
+ */
66
+ export declare function droppedUiAuthUrls(uiAuth?: UiAuthConfig): string[];
67
+ export {};
@@ -0,0 +1,176 @@
1
+ /** Connecta's default monochrome "C" mark. */
2
+ export const CONNECTA_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
3
+ <style>
4
+ .fg { fill: #000 }
5
+ @media (prefers-color-scheme: dark) { .fg { fill: #fff } }
6
+ </style>
7
+ <path class="fg" d="M27 9.4A13 13 0 1 0 27 22.6l-4.4-2.5a8 8 0 1 1 0-8.2z"/>
8
+ </svg>`;
9
+ const DEFAULT_FAVICON_HREF = "/favicon.svg";
10
+ /**
11
+ * Branding arrives from operator config, which is untyped at a JS call site, so
12
+ * every field is treated as `unknown`: a non-string is read as unset rather than
13
+ * throwing on `.trim()`. Rendering must degrade to defaults for a malformed
14
+ * value, never fail — `createConnecta` calls this during construction.
15
+ */
16
+ function trimmedString(value) {
17
+ return typeof value === "string" ? value.trim() || undefined : undefined;
18
+ }
19
+ export function resolveBranding(branding) {
20
+ const productName = trimmedString(branding?.productName) ?? "Connecta";
21
+ const ownerName = trimmedString(branding?.ownerName);
22
+ // Operator branding URLs become masthead/callback hrefs, so a non-http(s)
23
+ // scheme (javascript:, data:) is dropped the same as an unset URL — the
24
+ // callers already render a <span> instead of an <a> when it is absent.
25
+ const productUrl = trimmedString(branding?.productUrl);
26
+ const ownerUrl = trimmedString(branding?.ownerUrl);
27
+ const faviconHref = trimmedString(branding?.favicon?.href);
28
+ return {
29
+ productName,
30
+ ...(productUrl && isSafeHttpUrl(productUrl) ? { productUrl } : {}),
31
+ ...(ownerName ? { ownerName } : {}),
32
+ ...(ownerUrl && isSafeHttpUrl(ownerUrl) ? { ownerUrl } : {}),
33
+ description: trimmedString(branding?.description) ??
34
+ `Manage the services this ${productName} instance makes available to agents.`,
35
+ pageTitle: trimmedString(branding?.pageTitle) ??
36
+ (ownerName ? `${productName} — ${ownerName}` : productName),
37
+ faviconHref: faviconHref && isSafeIconHref(faviconHref)
38
+ ? faviconHref
39
+ : DEFAULT_FAVICON_HREF,
40
+ themeColor: trimmedString(branding?.themeColor) ?? "#ffffff",
41
+ };
42
+ }
43
+ /**
44
+ * Whether the operator meant to supply a value here — the question every
45
+ * dropped-URL warning asks before naming a field, and one definition so the
46
+ * branding and `uiAuth` warnings cannot answer it differently. A non-string
47
+ * counts as set: the intent was there and is exactly what the warning reports
48
+ * on. A blank or whitespace-only string does not; that is indistinguishable
49
+ * from leaving the field alone, and both take the default silently.
50
+ */
51
+ function isSetUrlValue(value) {
52
+ return typeof value === "string"
53
+ ? trimmedString(value) !== undefined
54
+ : value !== undefined && value !== null;
55
+ }
56
+ /**
57
+ * Names of the branding URLs the operator set that failed their gate and were
58
+ * replaced by a default. Lives beside the gates so the startup warning cannot
59
+ * drift from them, and takes `unknown` fields for the same reason
60
+ * `resolveBranding` does — a warning helper must never throw.
61
+ */
62
+ export function droppedBrandingUrls(branding) {
63
+ if (!branding)
64
+ return [];
65
+ const resolved = resolveBranding(branding);
66
+ const faviconHref = branding.favicon?.href;
67
+ return [
68
+ ...(isSetUrlValue(branding.productUrl) && !resolved.productUrl
69
+ ? ["productUrl"]
70
+ : []),
71
+ ...(isSetUrlValue(branding.ownerUrl) && !resolved.ownerUrl
72
+ ? ["ownerUrl"]
73
+ : []),
74
+ ...(isSetUrlValue(faviconHref) &&
75
+ trimmedString(faviconHref) !== resolved.faviconHref
76
+ ? ["favicon.href"]
77
+ : []),
78
+ ];
79
+ }
80
+ /**
81
+ * True only for absolute `http:`/`https:` URLs. Downstream connectors control
82
+ * their `authorizationUrl`, so a hostile/misconfigured one could hand back a
83
+ * `javascript:` (or other) scheme; gate it before it can become an href.
84
+ */
85
+ function safeUrl(url, schemes) {
86
+ if (typeof url !== "string")
87
+ return false;
88
+ try {
89
+ return schemes.includes(new URL(url).protocol);
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
95
+ export function isSafeHttpUrl(url) {
96
+ return safeUrl(url, ["http:", "https:"]);
97
+ }
98
+ /**
99
+ * Only the second check's base; any origin works because the check is whether
100
+ * the href stays on whatever origin it is resolved against. It is deliberately
101
+ * never the sole gate: a value whose own authority equals this host (say
102
+ * `//connecta.invalid/x`) would resolve to this exact origin and pass, so the
103
+ * structural check below runs first and is what actually rejects `//host`.
104
+ */
105
+ const SAME_ORIGIN_PROBE = "https://connecta.invalid";
106
+ /** Removed anywhere in a URL by the parser, so a gate must ignore them too. */
107
+ const URL_STRIPPED_CHARS = /[\t\n\r]/g;
108
+ /**
109
+ * True for values allowed in the page's `<link rel="icon" href>`: an absolute
110
+ * `http(s)` URL (an icon the operator hosts elsewhere) or a path rooted at this
111
+ * origin. The relative carve-out is deliberate rather than accidental — the
112
+ * default href is the relative `/favicon.svg`, which `isSafeHttpUrl` alone would
113
+ * reject — and it is kept narrow on both ends.
114
+ *
115
+ * Root-relative only, because operator and OAuth callback pages sit at
116
+ * different depths and a document-relative path would resolve differently.
117
+ *
118
+ * "Root-relative" is enforced structurally: exactly one leading `/` followed by
119
+ * a character that is neither `/` nor `\`. Both of those would make the value an
120
+ * authority (`//host`, and `/\host` because the URL parser folds `\` to `/` in
121
+ * special schemes), pointing at an origin this server does not control. The test
122
+ * runs on a copy with tab/newline/CR removed, since the parser strips those
123
+ * anywhere and `/\t/host` would otherwise slip through as single-slash. The
124
+ * origin comparison that follows is defense in depth, not the authority check —
125
+ * on its own it would accept an authority that happened to equal the probe host.
126
+ */
127
+ export function isSafeIconHref(href) {
128
+ if (typeof href !== "string")
129
+ return false;
130
+ if (isSafeHttpUrl(href))
131
+ return true;
132
+ if (!/^\/(?![/\\])/.test(href.replace(URL_STRIPPED_CHARS, "")))
133
+ return false;
134
+ try {
135
+ return new URL(href, SAME_ORIGIN_PROBE).origin === SAME_ORIGIN_PROBE;
136
+ }
137
+ catch {
138
+ return false;
139
+ }
140
+ }
141
+ /** Absolute HTTPS gate for the `UiAuthConfig` URL fields documented in types.ts. */
142
+ export function isSafeHttpsUrl(url) {
143
+ return safeUrl(url, ["https:"]);
144
+ }
145
+ /**
146
+ * Names of the `uiAuth` URLs an inbound-auth provider supplied that failed their
147
+ * gate. Lives beside the gate for the same reason `droppedBrandingUrls` does: the
148
+ * startup warning cannot then drift from what rendering actually drops. Every
149
+ * field is read defensively rather than trusted, because a custom `InboundAuth`
150
+ * is untyped at a JS call site — `isSafeHttpsUrl` takes `unknown`, and a
151
+ * `uiAuth` that is not the clerk shape is reported as nothing to warn about.
152
+ *
153
+ * `frontendApiUrl` is required, so anything that fails its gate is a drop.
154
+ * `signInUrl` and `signUpUrl` are optional, so only a value the operator
155
+ * *supplied* and the gate then rejected is worth a warning — an unset field
156
+ * took no default away from anyone. `isSetUrlValue` decides that, the same way
157
+ * and for the same reasons it decides it for the branding URLs: a warning that
158
+ * fires for one and not the other would be reporting on the field rather than
159
+ * on the operator's intent. Rendering is not consulted for this: it drops on
160
+ * the gate alone, and a blank string fails that gate too — it is simply not
161
+ * *reported*, because a blank is indistinguishable from leaving the field
162
+ * alone.
163
+ */
164
+ export function droppedUiAuthUrls(uiAuth) {
165
+ if (!uiAuth || uiAuth.kind !== "clerk")
166
+ return [];
167
+ return [
168
+ ...(isSafeHttpsUrl(uiAuth.frontendApiUrl) ? [] : ["uiAuth.frontendApiUrl"]),
169
+ ...(isSetUrlValue(uiAuth.signInUrl) && !isSafeHttpsUrl(uiAuth.signInUrl)
170
+ ? ["uiAuth.signInUrl"]
171
+ : []),
172
+ ...(isSetUrlValue(uiAuth.signUpUrl) && !isSafeHttpsUrl(uiAuth.signUpUrl)
173
+ ? ["uiAuth.signUpUrl"]
174
+ : []),
175
+ ];
176
+ }
@@ -505,7 +505,7 @@ export function remoteMcp(id, opts) {
505
505
  const newProvider = (ctx, state) => {
506
506
  if (state?.provider)
507
507
  return state.provider;
508
- const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`, refreshCoordinator);
508
+ const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`, refreshCoordinator, ctx.allowAuthorization === true);
509
509
  if (state)
510
510
  state.provider = provider;
511
511
  return provider;
@@ -1025,12 +1025,8 @@ export function remoteMcp(id, opts) {
1025
1025
  // Only an OAuth connector has a pending consent URL to offer. A
1026
1026
  // credential connector's downstream 401 is repaired on /credentials,
1027
1027
  // so do not reach into OAuth storage to look for one.
1028
- const url = isOauth
1029
- ? await newProvider(ctx, state).pendingAuthorizationUrl()
1030
- : undefined;
1031
1028
  return {
1032
1029
  state: "auth_required",
1033
- ...(url !== undefined ? { authorizationUrl: url } : {}),
1034
1030
  message: credentialAuth
1035
1031
  ? "Authorization required — the downstream rejected this connector's stored credential."
1036
1032
  : "Authorization required — open the URL to connect.",
@@ -1070,7 +1066,9 @@ export function remoteMcp(id, opts) {
1070
1066
  await disconnectAuthorization(ctx, stateFor(ctx), true);
1071
1067
  };
1072
1068
  connector.startAuth = async (ctx, startOpts) => {
1069
+ ctx = { ...ctx, requestScope: ctx.requestScope ?? ctx, allowAuthorization: true };
1073
1070
  const state = stateFor(ctx);
1071
+ state.provider = null;
1074
1072
  const p = newProvider(ctx, state);
1075
1073
  if (startOpts?.force || (await p.operatorDisconnected())) {
1076
1074
  await disconnectAuthorization(ctx, state);
@@ -0,0 +1,24 @@
1
+ import type { ConnectorCredentialValues } from "./types.js";
2
+ interface CredentialFieldMetadata {
3
+ configured: true;
4
+ /** Only emitted when the value is long enough that four chars don't leak much. */
5
+ lastFour?: string;
6
+ updatedAt: string;
7
+ }
8
+ export interface CredentialMetadata {
9
+ configured: true;
10
+ /** Backward-compatible metadata for the reserved single credential field. */
11
+ lastFour?: string;
12
+ updatedAt: string;
13
+ /** Per-field masked metadata for named multi-value credentials. */
14
+ fields?: Record<string, CredentialFieldMetadata>;
15
+ }
16
+ export interface CredentialVault {
17
+ get(connectorId: string, field?: string, owner?: string): Promise<string | null>;
18
+ getAll(connectorId: string, owner?: string): Promise<ConnectorCredentialValues | null>;
19
+ metadata(connectorId: string, owner?: string): Promise<CredentialMetadata | null>;
20
+ set(connectorId: string, value: string, updatedBy: string, owner?: string): Promise<CredentialMetadata>;
21
+ setAll(connectorId: string, values: ConnectorCredentialValues, updatedBy: string, owner?: string): Promise<CredentialMetadata>;
22
+ delete(connectorId: string, owner?: string): Promise<void>;
23
+ }
24
+ export {};
@@ -0,0 +1 @@
1
+ export {};