@zackbart/connecta 0.4.1 → 0.5.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 (64) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/README.md +40 -5
  3. package/SECURITY.md +10 -6
  4. package/dist/activity.d.ts +8 -0
  5. package/dist/activity.d.ts.map +1 -1
  6. package/dist/activity.js +1 -0
  7. package/dist/activity.js.map +1 -1
  8. package/dist/connectors/api.d.ts +13 -0
  9. package/dist/connectors/api.d.ts.map +1 -1
  10. package/dist/connectors/api.js +2 -0
  11. package/dist/connectors/api.js.map +1 -1
  12. package/dist/connectors/remote-mcp.d.ts +13 -0
  13. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  14. package/dist/connectors/remote-mcp.js +2 -0
  15. package/dist/connectors/remote-mcp.js.map +1 -1
  16. package/dist/execute.d.ts +4 -4
  17. package/dist/execute.d.ts.map +1 -1
  18. package/dist/execute.js.map +1 -1
  19. package/dist/index.d.ts +35 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +27 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/meta-tools.d.ts +22 -4
  24. package/dist/meta-tools.d.ts.map +1 -1
  25. package/dist/meta-tools.js +91 -18
  26. package/dist/meta-tools.js.map +1 -1
  27. package/dist/registry.d.ts +183 -2
  28. package/dist/registry.d.ts.map +1 -1
  29. package/dist/registry.js +293 -27
  30. package/dist/registry.js.map +1 -1
  31. package/dist/server.d.ts +7 -1
  32. package/dist/server.d.ts.map +1 -1
  33. package/dist/server.js +67 -7
  34. package/dist/server.js.map +1 -1
  35. package/dist/skills.d.ts +52 -1
  36. package/dist/skills.d.ts.map +1 -1
  37. package/dist/skills.js +161 -1
  38. package/dist/skills.js.map +1 -1
  39. package/dist/toolkits.d.ts +44 -0
  40. package/dist/toolkits.d.ts.map +1 -0
  41. package/dist/toolkits.js +134 -0
  42. package/dist/toolkits.js.map +1 -0
  43. package/dist/types.d.ts +20 -1
  44. package/dist/types.d.ts.map +1 -1
  45. package/dist/ui.d.ts +28 -0
  46. package/dist/ui.d.ts.map +1 -1
  47. package/dist/ui.js +89 -8
  48. package/dist/ui.js.map +1 -1
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/package.json +5 -2
  52. package/src/activity.ts +9 -0
  53. package/src/connectors/api.ts +15 -0
  54. package/src/connectors/remote-mcp.ts +15 -0
  55. package/src/execute.ts +4 -4
  56. package/src/index.ts +69 -1
  57. package/src/meta-tools.ts +126 -25
  58. package/src/registry.ts +416 -29
  59. package/src/server.ts +98 -7
  60. package/src/skills.ts +184 -1
  61. package/src/toolkits.ts +215 -0
  62. package/src/types.ts +20 -1
  63. package/src/ui.ts +92 -8
  64. package/src/version.ts +1 -1
package/src/skills.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { Connector } from "./types.js";
2
+
1
3
  export const CONNECTA_INSTRUCTIONS =
2
4
  'Connecta exposes many integrations behind meta-tools. When an address is unknown, start with search_tools and includeSchemas="compact"; use describe_tools only when that schema is insufficient. Use call_tool for one explicitly read-only call, batch_call for 2–10 independent explicitly read-only calls, and execute_code (when available) only for dependent read-only steps, loops, joins, or reducing large results. Unannotated, write-capable, and destructive tools must use call_destructive_tool individually. Use authorize_connector only after auth_required and get_result only for truncated results. For the detailed workflow, call skills({ name: "usage" }) once per task.';
3
5
 
@@ -53,11 +55,192 @@ async () => {
53
55
  \`\`\`
54
56
  `;
55
57
 
58
+ /**
59
+ * Appended to USAGE_SKILL only when the deployment actually has at least one
60
+ * connector guide. A deployment with none — every deployment that has not
61
+ * adopted the feature — keeps the base guide byte-for-byte, rather than paying
62
+ * context for an instruction to fetch guides that do not exist.
63
+ */
64
+ export const CONNECTOR_GUIDES_SECTION = `
65
+ ## Per-connector guides
66
+
67
+ Some connectors here ship their own usage guide — preferred tools, address quirks, pagination conventions, rate-limit etiquette, query patterns. \`skills({})\` lists each one as \`connector:<connectorId>\`; fetch it with \`skills({ name: "connector:<connectorId>" })\`. \`search_tools\` and \`describe_tools\` set \`guide\` on matches whose connector has one. Read a connector's guide before working with it for the first time in a task.
68
+ `;
69
+
70
+ /** True when at least one of `connectors` carries a usage guide. */
71
+ export function hasConnectorGuides(connectors: readonly Connector[]): boolean {
72
+ return connectors.some(
73
+ (connector) => connectorGuide(connector) !== undefined,
74
+ );
75
+ }
76
+
77
+ /** The built-in usage guide, plus the guides section when there is one to point at. */
78
+ export function usageSkill(connectors: readonly Connector[]): string {
79
+ return hasConnectorGuides(connectors)
80
+ ? USAGE_SKILL + CONNECTOR_GUIDES_SECTION
81
+ : USAGE_SKILL;
82
+ }
83
+
56
84
  export const AVAILABLE_SKILLS = [
57
85
  {
58
86
  name: "usage",
59
87
  description:
60
88
  "How to choose among Connecta discovery, direct, batch, destructive, and code-mode tools.",
61
- content: USAGE_SKILL,
89
+ content: usageSkill,
62
90
  },
63
91
  ] as const;
92
+
93
+ /**
94
+ * Namespace for operator-authored per-connector guides. Built-in skill names
95
+ * are bare identifiers and never contain ":", so `connector:<id>` cannot
96
+ * collide with one — not even when a connector's id is literally "usage".
97
+ * The prefixed form is the ONLY way to reach a connector guide: a bare
98
+ * connector id is never resolved, so nothing shadows anything silently.
99
+ */
100
+ export const CONNECTOR_SKILL_PREFIX = "connector:";
101
+
102
+ /** The skill name that fetches `connector`'s guide. */
103
+ export function connectorSkillName(connectorId: string): string {
104
+ return `${CONNECTOR_SKILL_PREFIX}${connectorId}`;
105
+ }
106
+
107
+ /** The connector's guide, or undefined when it declares none (or a blank one). */
108
+ export function connectorGuide(connector: Connector): string | undefined {
109
+ const guide = connector.usageGuide;
110
+ return guide && guide.trim() !== "" ? guide : undefined;
111
+ }
112
+
113
+ const SUMMARY_LENGTH = 120;
114
+
115
+ /** A `---`/`***`/`___` rule, which also opens and closes YAML frontmatter. */
116
+ const RULE_RE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
117
+
118
+ /** A fenced code block's delimiter. */
119
+ const FENCE_RE = /^\s*(?:```|~~~)/;
120
+
121
+ /**
122
+ * Markup that carries no summary text of its own: horizontal rules, HTML
123
+ * comments, and table rows. Skipped so a guide that opens with one is
124
+ * summarized by its first real line instead of by punctuation.
125
+ */
126
+ const NOT_SUMMARY_RE = /^\s*(?:<!--|\|)|^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
127
+
128
+ /** Drop a leading YAML frontmatter block — metadata, not summary text. */
129
+ function withoutFrontmatter(lines: string[]): string[] {
130
+ let start = 0;
131
+ while (start < lines.length && lines[start].trim() === "") start++;
132
+ if (start >= lines.length || !RULE_RE.test(lines[start])) return lines;
133
+ const close = lines.findIndex((line, i) => i > start && RULE_RE.test(line));
134
+ return close === -1 ? lines : lines.slice(close + 1);
135
+ }
136
+
137
+ /**
138
+ * One line describing a guide, for the cheap list view: the guide's first
139
+ * meaningful line (heading marks and list bullets stripped), falling back to
140
+ * the connector's own description when the guide opens with nothing but
141
+ * markup.
142
+ */
143
+ function summarizeGuide(connector: Connector, guide: string): string {
144
+ let inFence = false;
145
+ for (const raw of withoutFrontmatter(guide.split("\n"))) {
146
+ if (FENCE_RE.test(raw)) {
147
+ inFence = !inFence;
148
+ continue;
149
+ }
150
+ if (inFence) continue;
151
+ if (raw.trim() === "" || NOT_SUMMARY_RE.test(raw)) continue;
152
+ const line = raw
153
+ // `\s*` (not `\s+`) so a bare `#` strips to nothing and is skipped, and
154
+ // an unspaced `#Heading` is still read as a heading.
155
+ .replace(/^\s*#{1,6}\s*/, "")
156
+ .replace(/^\s*[-*+]\s+/, "")
157
+ .replace(/\s+/g, " ")
158
+ .trim();
159
+ if (line === "") continue;
160
+ return line.length <= SUMMARY_LENGTH
161
+ ? line
162
+ : `${line.slice(0, SUMMARY_LENGTH - 1).trimEnd()}…`;
163
+ }
164
+ return connector.description ?? `Usage guide for "${connector.id}".`;
165
+ }
166
+
167
+ export interface SkillListing {
168
+ name: string;
169
+ description: string;
170
+ }
171
+
172
+ /**
173
+ * Every fetchable skill: the built-in guides plus one entry per connector that
174
+ * carries a usage guide. Derived from the connector list passed in — the single
175
+ * place guide visibility is decided. The `skills` meta-tool passes its
176
+ * connection's `registry.listConnectors()`, so a toolkit-scoped session lists
177
+ * only in-scope guides, and `resolveSkill` below reports an out-of-scope
178
+ * `connector:<id>` exactly as it reports an unknown connector.
179
+ */
180
+ export function listSkills(connectors: readonly Connector[]): SkillListing[] {
181
+ const listing: SkillListing[] = AVAILABLE_SKILLS.map((skill) => ({
182
+ name: skill.name,
183
+ description: skill.description,
184
+ }));
185
+ for (const connector of connectors) {
186
+ const guide = connectorGuide(connector);
187
+ if (!guide) continue;
188
+ listing.push({
189
+ name: connectorSkillName(connector.id),
190
+ description: summarizeGuide(connector, guide),
191
+ });
192
+ }
193
+ return listing;
194
+ }
195
+
196
+ export type SkillLookup =
197
+ { found: true; content: string } | { found: false; message: string };
198
+
199
+ /**
200
+ * Resolve one skill name. Built-in names match exactly; connector guides are
201
+ * reachable only through the `connector:` prefix. Every miss — unknown name,
202
+ * unknown connector, connector without a guide — is an explicit error, never a
203
+ * silent fallback to the generic guide.
204
+ */
205
+ export function resolveSkill(
206
+ name: string,
207
+ connectors: readonly Connector[],
208
+ ): SkillLookup {
209
+ const builtIn = AVAILABLE_SKILLS.find((skill) => skill.name === name);
210
+ if (builtIn) return { found: true, content: builtIn.content(connectors) };
211
+ const available = () =>
212
+ listSkills(connectors)
213
+ .map((skill) => skill.name)
214
+ .join(", ");
215
+ if (name.startsWith(CONNECTOR_SKILL_PREFIX)) {
216
+ const id = name.slice(CONNECTOR_SKILL_PREFIX.length);
217
+ const connector = connectors.find((c) => c.id === id);
218
+ if (!connector) {
219
+ return {
220
+ found: false,
221
+ message: `Unknown connector "${id}". Available skills: ${available()}.`,
222
+ };
223
+ }
224
+ const guide = connectorGuide(connector);
225
+ if (!guide) {
226
+ return {
227
+ found: false,
228
+ message: `Connector "${id}" has no usage guide. Available skills: ${available()}.`,
229
+ };
230
+ }
231
+ return { found: true, content: guide };
232
+ }
233
+ const bare = connectors.find((c) => c.id === name);
234
+ if (bare) {
235
+ return {
236
+ found: false,
237
+ message: connectorGuide(bare)
238
+ ? `Unknown skill "${name}". Connector guides are fetched as "${connectorSkillName(name)}". Available: ${available()}.`
239
+ : `Connector "${name}" has no usage guide. Available skills: ${available()}.`,
240
+ };
241
+ }
242
+ return {
243
+ found: false,
244
+ message: `Unknown skill "${name}". Available: ${available()}.`,
245
+ };
246
+ }
@@ -0,0 +1,215 @@
1
+ // Toolkits: named, operator-defined scoped views over one deployment's
2
+ // registry, selected per client connection with `?toolkit=<name>` on /mcp.
3
+ //
4
+ // A connecta deployment belongs to an ORG; a toolkit is the view a GROUP OF
5
+ // TEAM MEMBERS inside that org gets — a "support" toolkit seeing Zendesk and
6
+ // Notion, an "exec" toolkit that also sees Gmail. This module only *defines and
7
+ // validates* scopes. Enforcement lives in one place: `ScopedRegistry`
8
+ // (src/registry.ts), which every meta-tool inherits through `RegistryView`.
9
+
10
+ import type { Connector } from "./types.js";
11
+
12
+ /** Toolkit names share the connector-id grammar: URL-safe, no separators. */
13
+ export const TOOLKIT_NAME_RE = /^[a-z0-9_-]+$/;
14
+
15
+ /** One named scope, declared in `ConnectaConfig.toolkits` (config as code). */
16
+ export interface ToolkitDefinition {
17
+ /** Connector ids this toolkit may see. Required, and at least one. */
18
+ connectors: string[];
19
+ /**
20
+ * Optional finer grain: full tool addresses (`"<connectorId>.<toolName>"`).
21
+ * Naming ANY address of a connector narrows that connector to exactly the
22
+ * addresses named; connectors with no entry here keep their whole tool list.
23
+ */
24
+ includeTools?: string[];
25
+ /** Optional tool addresses to hide, applied after `includeTools`. */
26
+ excludeTools?: string[];
27
+ /** Operator note. Never sent to clients — this is documentation for config. */
28
+ description?: string;
29
+ }
30
+
31
+ /** `ConnectaConfig.toolkits` — toolkit name → definition. */
32
+ export type ToolkitConfig = Record<string, ToolkitDefinition>;
33
+
34
+ /** A validated toolkit: the visibility predicate the scoped registry consults. */
35
+ export interface Toolkit {
36
+ readonly name: string;
37
+ readonly description?: string;
38
+ /** True when `connectorId` is inside this toolkit's scope. */
39
+ hasConnector(connectorId: string): boolean;
40
+ /** True when `<connectorId>.<toolName>` is inside this toolkit's scope. */
41
+ hasTool(connectorId: string, toolName: string): boolean;
42
+ }
43
+
44
+ /**
45
+ * Split `"<connectorId>.<toolName>"` on the FIRST dot — connector ids contain
46
+ * no dots, so a downstream tool name may. Returns null for a malformed address.
47
+ */
48
+ export function splitAddress(
49
+ address: string,
50
+ ): { connectorId: string; toolName: string } | null {
51
+ const dot = address.indexOf(".");
52
+ if (dot <= 0 || dot === address.length - 1) return null;
53
+ return {
54
+ connectorId: address.slice(0, dot),
55
+ toolName: address.slice(dot + 1),
56
+ };
57
+ }
58
+
59
+ /** Group tool addresses by connector id, validating each against the toolkit. */
60
+ function toolFilter(
61
+ name: string,
62
+ addresses: string[] | undefined,
63
+ connectorIds: ReadonlySet<string>,
64
+ staticTools: ReadonlyMap<string, ReadonlySet<string>>,
65
+ field: "includeTools" | "excludeTools",
66
+ ): Map<string, Set<string>> {
67
+ const byConnector = new Map<string, Set<string>>();
68
+ if (addresses !== undefined && !Array.isArray(addresses)) {
69
+ // A bare string would otherwise iterate character by character and produce
70
+ // a stream of confusing address errors; anything else would throw "not
71
+ // iterable" from deep inside the loop. Name the field instead.
72
+ throw new Error(
73
+ `Toolkit "${name}" ${field} must be an array of "<connectorId>.<toolName>" addresses.`,
74
+ );
75
+ }
76
+ if (
77
+ addresses !== undefined &&
78
+ addresses.length === 0 &&
79
+ field === "includeTools"
80
+ ) {
81
+ // An empty allowlist reads as "only these tools" but would behave as "all
82
+ // of them" — the one shape here that fails OPEN. (An empty excludeTools is
83
+ // an honest no-op and is allowed.)
84
+ throw new Error(
85
+ `Toolkit "${name}" has an empty includeTools: remove it to expose every tool, or list the addresses this toolkit may use.`,
86
+ );
87
+ }
88
+ for (const address of addresses ?? []) {
89
+ const parts = splitAddress(address);
90
+ if (!parts) {
91
+ throw new Error(
92
+ `Toolkit "${name}" ${field} entry "${address}" is not a tool address: expected "<connectorId>.<toolName>".`,
93
+ );
94
+ }
95
+ if (!connectorIds.has(parts.connectorId)) {
96
+ // A typo here would silently do nothing, quietly widening the scope the
97
+ // operator believes they wrote. Fail at construction instead.
98
+ throw new Error(
99
+ `Toolkit "${name}" ${field} entry "${address}" names connector "${parts.connectorId}", which is not in this toolkit's connectors list.`,
100
+ );
101
+ }
102
+ // Static-only, exactly like the registry's convention checks: an in-code
103
+ // connector's tool list is known now, so a misspelled name — an exclude
104
+ // that silently excludes nothing — is caught. Remote catalogs are fetched
105
+ // lazily over the network and cannot be checked at construction.
106
+ const known = staticTools.get(parts.connectorId);
107
+ if (known && !known.has(parts.toolName)) {
108
+ throw new Error(
109
+ `Toolkit "${name}" ${field} entry "${address}" names no tool on connector "${parts.connectorId}".`,
110
+ );
111
+ }
112
+ const tools = byConnector.get(parts.connectorId) ?? new Set<string>();
113
+ tools.add(parts.toolName);
114
+ byConnector.set(parts.connectorId, tools);
115
+ }
116
+ return byConnector;
117
+ }
118
+
119
+ /**
120
+ * Validate one toolkit definition against the deployment's connectors.
121
+ *
122
+ * Structural mistakes THROW at construction rather than warn: a typo'd id in
123
+ * an allowlist is a scope the operator did not write, and a scope nobody wrote
124
+ * is not one an operator can reason about. (A toolkit scopes visibility, not
125
+ * identity — it is not itself an access check; see the module header and
126
+ * documentation.md §16.) Tool names are checked only for connectors that expose
127
+ * `staticTools` (i.e. `api()`); a remote connector's catalog is fetched lazily
128
+ * over the network and is unknown at construction time.
129
+ */
130
+ function resolveToolkit(
131
+ name: string,
132
+ definition: ToolkitDefinition,
133
+ known: ReadonlySet<string>,
134
+ staticTools: ReadonlyMap<string, ReadonlySet<string>>,
135
+ ): Toolkit {
136
+ if (!TOOLKIT_NAME_RE.test(name)) {
137
+ throw new Error(
138
+ `Invalid toolkit name "${name}": must match ${TOOLKIT_NAME_RE.source}`,
139
+ );
140
+ }
141
+ if (
142
+ !Array.isArray(definition.connectors) ||
143
+ definition.connectors.length === 0
144
+ ) {
145
+ throw new Error(
146
+ `Toolkit "${name}" selects no connectors: list at least one connector id in "connectors".`,
147
+ );
148
+ }
149
+ const connectorIds = new Set<string>();
150
+ for (const id of definition.connectors) {
151
+ if (!known.has(id)) {
152
+ throw new Error(
153
+ `Toolkit "${name}" references unknown connector "${id}".`,
154
+ );
155
+ }
156
+ connectorIds.add(id);
157
+ }
158
+ const includes = toolFilter(
159
+ name,
160
+ definition.includeTools,
161
+ connectorIds,
162
+ staticTools,
163
+ "includeTools",
164
+ );
165
+ const excludes = toolFilter(
166
+ name,
167
+ definition.excludeTools,
168
+ connectorIds,
169
+ staticTools,
170
+ "excludeTools",
171
+ );
172
+ return {
173
+ name,
174
+ ...(definition.description ? { description: definition.description } : {}),
175
+ hasConnector: (connectorId) => connectorIds.has(connectorId),
176
+ hasTool: (connectorId, toolName) => {
177
+ if (!connectorIds.has(connectorId)) return false;
178
+ const include = includes.get(connectorId);
179
+ if (include && !include.has(toolName)) return false;
180
+ return !excludes.get(connectorId)?.has(toolName);
181
+ },
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Validate every declared toolkit against the connector set. Returns undefined
187
+ * when no toolkits are configured, so an existing deployment keeps exactly its
188
+ * current (unscoped) behavior.
189
+ */
190
+ export function resolveToolkits(
191
+ toolkits: ToolkitConfig | undefined,
192
+ connectors: readonly Connector[],
193
+ ): ReadonlyMap<string, Toolkit> | undefined {
194
+ if (!toolkits) return undefined;
195
+ // Object.entries (not a keyed lookup) so no config key — `__proto__` and
196
+ // friends included — can ever resolve through the prototype chain. Names are
197
+ // then held in a Map, which has no prototype to pollute.
198
+ const entries = Object.entries(toolkits);
199
+ if (entries.length === 0) return undefined;
200
+ const known = new Set(connectors.map((connector) => connector.id));
201
+ const staticTools = new Map<string, ReadonlySet<string>>();
202
+ for (const connector of connectors) {
203
+ if (connector.staticTools) {
204
+ staticTools.set(
205
+ connector.id,
206
+ new Set(connector.staticTools.map((tool) => tool.name)),
207
+ );
208
+ }
209
+ }
210
+ const resolved = new Map<string, Toolkit>();
211
+ for (const [name, definition] of entries) {
212
+ resolved.set(name, resolveToolkit(name, definition, known, staticTools));
213
+ }
214
+ return resolved;
215
+ }
package/src/types.ts CHANGED
@@ -135,6 +135,23 @@ export interface Connector {
135
135
  /** How call_tool wraps results. "mcp" passes the content array through; anything else is JSON-wrapped. */
136
136
  kind?: "mcp" | "api";
137
137
  description?: string;
138
+ /**
139
+ * Max inline result size (bytes) for this connector's tools before
140
+ * call_tool/batch_call truncate and stash the full text for get_result
141
+ * paging. Overrides the deployment-wide `ConnectaConfig.maxResultBytes`;
142
+ * omit to inherit it (which itself defaults to 50_000). Must be a whole
143
+ * number of bytes >= 1; anything else warns at startup and is ignored, so
144
+ * the connector inherits the deployment-wide cap.
145
+ */
146
+ maxResultBytes?: number;
147
+ /**
148
+ * Optional agent-facing usage guide (markdown) for this connector — preferred
149
+ * tools, address quirks, pagination conventions, rate-limit etiquette, good
150
+ * query patterns. Listed by the `skills` meta-tool as `connector:<id>` and
151
+ * returned verbatim by `skills({ name: "connector:<id>" })`. Keep it concise
152
+ * and imperative; it is read by agents, not operators.
153
+ */
154
+ usageGuide?: string;
138
155
  /** Optional operator-managed credential slot rendered inside this connector's /ui card. */
139
156
  credential?: ConnectorCredentialConfig;
140
157
  /** Optional server-side check used by /ui's Test action. */
@@ -262,7 +279,9 @@ export interface ConnectaBranding {
262
279
  * `/favicon.svg`, `ico` at `/favicon.ico`; omit either to keep the default
263
280
  * for that format. Use `href` instead to point the page at an icon you host
264
281
  * elsewhere (it replaces the `/favicon.svg` link in the page head; the
265
- * `/favicon.*` routes still serve whatever `svg`/`ico` provide).
282
+ * `/favicon.*` routes still serve whatever `svg`/`ico` provide). `href` must
283
+ * be an absolute `http(s)` URL or a root-relative path; anything else falls
284
+ * back to the default mark.
266
285
  */
267
286
  favicon?: {
268
287
  svg?: string;
package/src/ui.ts CHANGED
@@ -24,32 +24,74 @@ interface ResolvedBranding {
24
24
  themeColor: string;
25
25
  }
26
26
 
27
+ export const DEFAULT_FAVICON_HREF = "/favicon.svg";
28
+
29
+ /**
30
+ * Branding arrives from operator config, which is untyped at a JS call site, so
31
+ * every field is treated as `unknown`: a non-string is read as unset rather than
32
+ * throwing on `.trim()`. Rendering must degrade to defaults for a malformed
33
+ * value, never fail — `createConnecta` calls this during construction.
34
+ */
35
+ function trimmedString(value: unknown): string | undefined {
36
+ return typeof value === "string" ? value.trim() || undefined : undefined;
37
+ }
38
+
27
39
  export function resolveBranding(
28
40
  branding?: ConnectaBranding,
29
41
  ): ResolvedBranding {
30
- const productName = branding?.productName?.trim() || "Connecta";
31
- const ownerName = branding?.ownerName?.trim();
42
+ const productName = trimmedString(branding?.productName) ?? "Connecta";
43
+ const ownerName = trimmedString(branding?.ownerName);
32
44
  // Operator branding URLs become masthead/callback hrefs, so a non-http(s)
33
45
  // scheme (javascript:, data:) is dropped the same as an unset URL — the
34
46
  // callers already render a <span> instead of an <a> when it is absent.
35
- const productUrl = branding?.productUrl?.trim();
36
- const ownerUrl = branding?.ownerUrl?.trim();
47
+ const productUrl = trimmedString(branding?.productUrl);
48
+ const ownerUrl = trimmedString(branding?.ownerUrl);
49
+ const faviconHref = trimmedString(branding?.favicon?.href);
37
50
  return {
38
51
  productName,
39
52
  ...(productUrl && isSafeHttpUrl(productUrl) ? { productUrl } : {}),
40
53
  ...(ownerName ? { ownerName } : {}),
41
54
  ...(ownerUrl && isSafeHttpUrl(ownerUrl) ? { ownerUrl } : {}),
42
55
  description:
43
- branding?.description?.trim() ||
56
+ trimmedString(branding?.description) ??
44
57
  `Manage the services this ${productName} instance makes available to agents.`,
45
58
  pageTitle:
46
- branding?.pageTitle?.trim() ||
59
+ trimmedString(branding?.pageTitle) ??
47
60
  (ownerName ? `${productName} — ${ownerName}` : productName),
48
- faviconHref: branding?.favicon?.href?.trim() || "/favicon.svg",
49
- themeColor: branding?.themeColor?.trim() || "#ffffff",
61
+ faviconHref:
62
+ faviconHref && isSafeIconHref(faviconHref)
63
+ ? faviconHref
64
+ : DEFAULT_FAVICON_HREF,
65
+ themeColor: trimmedString(branding?.themeColor) ?? "#ffffff",
50
66
  };
51
67
  }
52
68
 
69
+ /**
70
+ * Names of the branding URLs the operator set that failed their gate and were
71
+ * replaced by a default. Lives beside the gates so the startup warning cannot
72
+ * drift from them, and takes `unknown` fields for the same reason
73
+ * `resolveBranding` does — a warning helper must never throw.
74
+ */
75
+ export function droppedBrandingUrls(branding?: ConnectaBranding): string[] {
76
+ if (!branding) return [];
77
+ const resolved = resolveBranding(branding);
78
+ // A non-string still counts as "set": the operator meant to supply a URL, and
79
+ // that intent is exactly what the warning reports on. A blank string does not.
80
+ const isSet = (value: unknown) =>
81
+ typeof value === "string"
82
+ ? trimmedString(value) !== undefined
83
+ : value !== undefined && value !== null;
84
+ const faviconHref = branding.favicon?.href;
85
+ return [
86
+ ...(isSet(branding.productUrl) && !resolved.productUrl ? ["productUrl"] : []),
87
+ ...(isSet(branding.ownerUrl) && !resolved.ownerUrl ? ["ownerUrl"] : []),
88
+ ...(isSet(faviconHref) &&
89
+ trimmedString(faviconHref) !== resolved.faviconHref
90
+ ? ["favicon.href"]
91
+ : []),
92
+ ];
93
+ }
94
+
53
95
  /**
54
96
  * A JS string literal safe to inline in a <script> block. JSON.stringify alone
55
97
  * leaves `/` untouched, so a value containing "</script>" would close the
@@ -74,6 +116,48 @@ export function isSafeHttpUrl(url: unknown): boolean {
74
116
  }
75
117
  }
76
118
 
119
+ /**
120
+ * Only the second check's base; any origin works because the check is whether
121
+ * the href stays on whatever origin it is resolved against. It is deliberately
122
+ * never the sole gate: a value whose own authority equals this host (say
123
+ * `//connecta.invalid/x`) would resolve to this exact origin and pass, so the
124
+ * structural check below runs first and is what actually rejects `//host`.
125
+ */
126
+ const SAME_ORIGIN_PROBE = "https://connecta.invalid";
127
+
128
+ /** Removed anywhere in a URL by the parser, so a gate must ignore them too. */
129
+ const URL_STRIPPED_CHARS = /[\t\n\r]/g;
130
+
131
+ /**
132
+ * True for values allowed in the page's `<link rel="icon" href>`: an absolute
133
+ * `http(s)` URL (an icon the operator hosts elsewhere) or a path rooted at this
134
+ * origin. The relative carve-out is deliberate rather than accidental — the
135
+ * default href is the relative `/favicon.svg`, which `isSafeHttpUrl` alone would
136
+ * reject — and it is kept narrow on both ends.
137
+ *
138
+ * Root-relative only, because `/ui` and `/oauth/callback/<id>` sit at different
139
+ * depths and a document-relative path would resolve differently on each.
140
+ *
141
+ * "Root-relative" is enforced structurally: exactly one leading `/` followed by
142
+ * a character that is neither `/` nor `\`. Both of those would make the value an
143
+ * authority (`//host`, and `/\host` because the URL parser folds `\` to `/` in
144
+ * special schemes), pointing at an origin this server does not control. The test
145
+ * runs on a copy with tab/newline/CR removed, since the parser strips those
146
+ * anywhere and `/\t/host` would otherwise slip through as single-slash. The
147
+ * origin comparison that follows is defense in depth, not the authority check —
148
+ * on its own it would accept an authority that happened to equal the probe host.
149
+ */
150
+ export function isSafeIconHref(href: unknown): boolean {
151
+ if (typeof href !== "string") return false;
152
+ if (isSafeHttpUrl(href)) return true;
153
+ if (!/^\/(?![/\\])/.test(href.replace(URL_STRIPPED_CHARS, ""))) return false;
154
+ try {
155
+ return new URL(href, SAME_ORIGIN_PROBE).origin === SAME_ORIGIN_PROBE;
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
77
161
  export interface UiTool {
78
162
  name: string;
79
163
  address: string;
package/src/version.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 const CONNECTA_VERSION = "0.4.1";
7
+ export const CONNECTA_VERSION = "0.5.0";