@zackbart/connecta 0.32.0 → 0.34.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 (158) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +95 -116
  3. package/SECURITY.md +1 -1
  4. package/bin/connecta.mjs +24 -6
  5. package/bin/version.mjs +1 -1
  6. package/dist/access-tokens.d.ts +167 -23
  7. package/dist/access-tokens.js +676 -140
  8. package/dist/activity.d.ts +3 -0
  9. package/dist/agent-identity.js +47 -0
  10. package/dist/auth/cloudflare-access.js +13 -5
  11. package/dist/branding.d.ts +7 -0
  12. package/dist/branding.js +76 -9
  13. package/dist/call-admission-context.js +15 -0
  14. package/dist/call-admission.js +30 -43
  15. package/dist/call-recovery.js +6 -2
  16. package/dist/catalog-service.js +15 -1
  17. package/dist/config-value-sources.js +16 -9
  18. package/dist/config.d.ts +48 -75
  19. package/dist/config.js +144 -36
  20. package/dist/connector-access.d.ts +4 -34
  21. package/dist/connector-access.js +15 -99
  22. package/dist/connector-caller.d.ts +7 -1
  23. package/dist/connector-caller.js +4 -2
  24. package/dist/connectors/api-connector.d.ts +27 -4
  25. package/dist/connectors/api-connector.js +2 -0
  26. package/dist/connectors/remote-mcp.js +2 -0
  27. package/dist/describe-config.d.ts +31 -7
  28. package/dist/describe-config.js +8 -17
  29. package/dist/execute.js +1 -11
  30. package/dist/fix-prompt.js +2 -2
  31. package/dist/index.d.ts +4 -3
  32. package/dist/index.js +30 -10
  33. package/dist/invocation.js +5 -3
  34. package/dist/membership.d.ts +53 -0
  35. package/dist/membership.js +121 -0
  36. package/dist/module-contracts.d.ts +23 -6
  37. package/dist/oauth-callback-outcome.js +2 -2
  38. package/dist/operator-record.d.ts +2 -2
  39. package/dist/operator-record.js +2 -4
  40. package/dist/operator-ui/agents.d.ts +74 -0
  41. package/dist/operator-ui/agents.js +104 -0
  42. package/dist/operator-ui/config-summary.js +562 -0
  43. package/dist/operator-ui/connection-map.js +375 -0
  44. package/dist/operator-ui/contract.d.ts +149 -28
  45. package/dist/operator-ui/endpoints.js +221 -0
  46. package/dist/operator-ui/fix-prompts.js +5 -5
  47. package/dist/operator-ui/generated.js +5 -4
  48. package/dist/operator-ui/member-page.js +97 -0
  49. package/dist/operator-ui/model.d.ts +14 -6
  50. package/dist/operator-ui/overview.js +134 -0
  51. package/dist/operator-ui/view.d.ts +107 -37
  52. package/dist/operator-ui/view.js +163 -86
  53. package/dist/page-styles.js +5 -3
  54. package/dist/provider.d.ts +61 -8
  55. package/dist/provider.js +46 -7
  56. package/dist/providers/_shared/google/workspace.d.ts +56 -17
  57. package/dist/providers/_shared/google/workspace.js +34 -8
  58. package/dist/providers/_shared/rest/tools.js +2 -2
  59. package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
  60. package/dist/providers/_shared/rest/value-safety.js +699 -7
  61. package/dist/providers/ccb/index.js +2 -2
  62. package/dist/providers/cloudflare/index.js +3 -3
  63. package/dist/providers/cloudflare/rest.js +25 -3
  64. package/dist/providers/cloudflare/skill.generated.js +1 -1
  65. package/dist/providers/cloudflare/value-safety.js +32 -0
  66. package/dist/providers/docs/index.js +4 -4
  67. package/dist/providers/docs/skill.generated.js +2 -2
  68. package/dist/providers/drive/index.js +4 -4
  69. package/dist/providers/drive/skill.generated.js +2 -2
  70. package/dist/providers/forms/index.js +4 -4
  71. package/dist/providers/forms/skill.generated.js +2 -2
  72. package/dist/providers/gmail/index.js +11 -9
  73. package/dist/providers/gmail/skill.generated.js +2 -2
  74. package/dist/providers/mixpanel/index.js +1 -1
  75. package/dist/providers/mixpanel/skill.generated.js +3 -3
  76. package/dist/providers/notion/index.d.ts +7 -0
  77. package/dist/providers/notion/index.js +100 -11
  78. package/dist/providers/notion/rest.js +45 -5
  79. package/dist/providers/notion/skill.generated.js +2 -2
  80. package/dist/providers/notion/value-safety.js +30 -5
  81. package/dist/providers/overflow/index.js +2 -2
  82. package/dist/providers/overflow/skill.generated.js +2 -2
  83. package/dist/providers/planning-center/index.js +2 -2
  84. package/dist/providers/planning-center/skill.generated.js +4 -4
  85. package/dist/providers/revenuecat/skill.generated.js +2 -2
  86. package/dist/providers/sheets/index.d.ts +3 -0
  87. package/dist/providers/sheets/index.js +7 -4
  88. package/dist/providers/sheets/skill.generated.js +2 -2
  89. package/dist/providers/slides/index.js +4 -4
  90. package/dist/providers/slides/skill.generated.js +2 -2
  91. package/dist/providers/stripe/rest.js +9 -2
  92. package/dist/providers/stripe/skill.generated.js +1 -1
  93. package/dist/providers/stripe/value-safety.js +22 -0
  94. package/dist/providers/tithely/index.js +1 -1
  95. package/dist/providers/vercel/index.js +1 -1
  96. package/dist/providers/vercel/rest.js +20 -2
  97. package/dist/providers/vercel/skill.generated.js +2 -2
  98. package/dist/providers/vercel/value-safety.js +22 -0
  99. package/dist/registry.d.ts +2 -3
  100. package/dist/registry.js +23 -9
  101. package/dist/routes/access-tokens.js +82 -75
  102. package/dist/routes/activity-disclosure.js +17 -18
  103. package/dist/routes/activity.js +34 -54
  104. package/dist/routes/actor-labels.js +33 -4
  105. package/dist/routes/agent-path.js +43 -0
  106. package/dist/routes/connect.js +16 -12
  107. package/dist/routes/credentials.js +16 -40
  108. package/dist/routes/mcp.js +161 -104
  109. package/dist/routes/oauth-management.js +11 -5
  110. package/dist/routes/oauth.js +5 -11
  111. package/dist/routes/operator.js +24 -23
  112. package/dist/routes/shared.d.ts +60 -20
  113. package/dist/routes/shared.js +187 -96
  114. package/dist/routes/ui-config.js +437 -97
  115. package/dist/routes/ui-data.js +2 -2
  116. package/dist/routes/ui.js +9 -16
  117. package/dist/runtime/call-admission.js +9 -0
  118. package/dist/server.js +5 -0
  119. package/dist/skills.js +10 -0
  120. package/dist/storage/keys.js +9 -1
  121. package/dist/storage/sql.js +5 -1
  122. package/dist/types.d.ts +29 -7
  123. package/dist/ui.d.ts +1 -1
  124. package/dist/ui.js +23 -16
  125. package/dist/usage-guide.js +1 -1
  126. package/dist/version.d.ts +1 -1
  127. package/dist/version.js +1 -1
  128. package/dist/workspace-address.js +9 -0
  129. package/dist/workspace-subject.js +23 -0
  130. package/documentation/README.md +47 -23
  131. package/documentation/architecture.md +371 -61
  132. package/documentation/auth.md +897 -249
  133. package/documentation/code-mode.md +37 -16
  134. package/documentation/deploying.md +126 -25
  135. package/documentation/integrating.md +97 -23
  136. package/documentation/meta-tools.md +50 -31
  137. package/documentation/migration-0.33.md +352 -0
  138. package/documentation/migration-0.34.md +67 -0
  139. package/documentation/operating.md +3 -1
  140. package/documentation/operator-tests.md +147 -34
  141. package/documentation/operator-ui.md +519 -64
  142. package/documentation/provider-auth-migration.md +37 -23
  143. package/documentation/provider-migration-0.29.md +2 -2
  144. package/examples/worker/AGENTS.md +37 -9
  145. package/examples/worker/README.md +127 -61
  146. package/examples/worker/src/connecta.config.ts +40 -23
  147. package/examples/worker/wrangler.jsonc +4 -1
  148. package/package.json +19 -18
  149. package/templates/node/.env.example +3 -0
  150. package/templates/node/AGENTS.md +33 -6
  151. package/templates/node/Dockerfile +2 -2
  152. package/templates/node/README.md +80 -52
  153. package/templates/node/docker-compose.yml +3 -2
  154. package/templates/node/package.json +4 -3
  155. package/templates/node/src/adopt-token.ts +23 -0
  156. package/templates/node/src/connecta.config.ts +28 -12
  157. package/templates/node/src/rotate-token.ts +17 -0
  158. package/templates/node/src/provision-token.ts +0 -17
@@ -126,10 +126,13 @@ export interface ActivityStore extends ActivitySink {
126
126
  /**
127
127
  * The adapter and its retention, for `Connecta.describeConfig()`. Kinds
128
128
  * other than the shipped `"d1"` and `"sqlite"` are described as `"custom"`.
129
+ * `optionSources` says whether code set `retentionDays` (`"config"`) or the
130
+ * store chose it (`"default"`); without it the retention reads as a default.
129
131
  */
130
132
  describe?(): {
131
133
  kind: string;
132
134
  retentionDays?: number;
135
+ optionSources?: Readonly<Record<string, "default" | "config">>;
133
136
  };
134
137
  }
135
138
  /** Reader implementations throw this for an opaque cursor they cannot decode. */
@@ -0,0 +1,47 @@
1
+ // Code-declared agents (#837): the names deployment code lists in `agents`,
2
+ // the identity an admitted agent carries, and the one provider trusted to
3
+ // assert it. Pure, so core reads it without reaching the token module.
4
+ import { ConfigError } from "./config-schema.js";
5
+ /** Agent names: lowercase letters, digits, `_` and `-`. */
6
+ const AGENT_NAME_RE = /^[a-z0-9_-]+$/u;
7
+ /** Longest accepted agent name, so a name always fits a storage key. */
8
+ const MAX_AGENT_NAME_LENGTH = 64;
9
+ /** Directory namespace of agent subjects and activity actors. */
10
+ export const AGENT_NAMESPACE = "connecta:access-tokens:v1";
11
+ /** Whether `value` is an acceptable agent name. */
12
+ export function isAgentName(value) {
13
+ return typeof value === "string" && value.length <= MAX_AGENT_NAME_LENGTH && AGENT_NAME_RE.test(value);
14
+ }
15
+ /**
16
+ * The subject id that partitions an agent's per-caller state, such as result
17
+ * handles. The `agent:` prefix keeps it apart from the UUID subjects older
18
+ * tokens used in the same namespace.
19
+ */
20
+ export function agentSubjectId(name) {
21
+ return `agent:${name}`;
22
+ }
23
+ /** Validate a declared agent list: an array of unique, well-formed names. */
24
+ export function assertAgentNames(value, path) {
25
+ if (!Array.isArray(value))
26
+ throw new ConfigError(`${path} must be an array of agent names.`);
27
+ const seen = new Set();
28
+ value.forEach((name, index) => {
29
+ if (!isAgentName(name)) {
30
+ throw new ConfigError(`${path}[${index}] must be an agent name: 1 to ${MAX_AGENT_NAME_LENGTH} characters of ` +
31
+ "lowercase letters, digits, _ and -.");
32
+ }
33
+ if (seen.has(name))
34
+ throw new ConfigError(`${path}[${index}] repeats the agent name "${name}".`);
35
+ seen.add(name);
36
+ });
37
+ }
38
+ const agentProviders = new WeakSet();
39
+ /** Mark the built-in access-token adapter as the one provider whose `agent` is trusted. */
40
+ export function trustAgentProvider(provider) {
41
+ agentProviders.add(provider);
42
+ return provider;
43
+ }
44
+ /** Whether `provider` is the built-in access-token adapter. */
45
+ export function assertsAgents(provider) {
46
+ return agentProviders.has(provider);
47
+ }
@@ -1,3 +1,4 @@
1
+ import { withDeadline } from "../timeout.js";
1
2
  import { authorizationCredential } from "../inbound-credential.js";
2
3
  import { validIdentityReference } from "../identity.js";
3
4
  function identityString(identity, field) {
@@ -9,7 +10,7 @@ function identityString(identity, field) {
9
10
  function unauthorized() {
10
11
  return {
11
12
  ok: false,
12
- response: Response.json({ error: "Cloudflare Access authentication required" }, { status: 401, headers: { "WWW-Authenticate": 'Bearer scope="openid email"' } }),
13
+ response: Response.json({ error: "Cloudflare Access authentication required" }, { status: 401, headers: { "WWW-Authenticate": 'Bearer scope="openid email"', "Cache-Control": "no-store" } }),
13
14
  };
14
15
  }
15
16
  /**
@@ -34,17 +35,24 @@ export function cloudflareAccessAuth() {
34
35
  return unauthorized();
35
36
  let identity;
36
37
  try {
37
- identity = await access.getIdentity();
38
+ identity = await withDeadline(() => access.getIdentity(), {
39
+ timeoutMs: 5000,
40
+ signal: request.signal,
41
+ timeoutError: new Error("Access identity lookup timed out"),
42
+ });
38
43
  }
39
44
  catch {
40
- return unauthorized();
45
+ return {
46
+ ok: false,
47
+ response: Response.json({ error: "Cloudflare Access identity unavailable", problem: "identity_unavailable" }, { status: 503, headers: { "Cache-Control": "no-store", "Retry-After": "1" } }),
48
+ };
41
49
  }
42
50
  // Access binds ctx.access.aud to its Worker application at the edge.
43
51
  // Service credentials can pass that edge, but connecta machines must
44
52
  // present a cta_ token to the machine provider instead.
45
53
  const forbidden = () => ({
46
54
  ok: false,
47
- response: Response.json({ error: "Cloudflare Access human identity required" }, { status: 403 }),
55
+ response: Response.json({ error: "Cloudflare Access human identity required" }, { status: 403, headers: { "Cache-Control": "no-store" } }),
48
56
  });
49
57
  if (!identity || typeof identity !== "object" || Array.isArray(identity))
50
58
  return forbidden();
@@ -56,7 +64,7 @@ export function cloudflareAccessAuth() {
56
64
  if (!userId) {
57
65
  return {
58
66
  ok: false,
59
- response: Response.json({ error: "Cloudflare Access user identity required" }, { status: 403 }),
67
+ response: Response.json({ error: "Cloudflare Access user identity required" }, { status: 403, headers: { "Cache-Control": "no-store" } }),
60
68
  };
61
69
  }
62
70
  return { ok: true, userId, subjectId: userId };
@@ -99,6 +99,13 @@ export interface PageLayout {
99
99
  * `mastheadEnd`, and `tail` are the caller's markup and are not.
100
100
  */
101
101
  export declare function renderPage(branding: ConnectaBranding | undefined, layout: PageLayout): string;
102
+ /**
103
+ * The mark beside the brand name. Connecta's drawn "C" belongs to an
104
+ * unbranded deployment only; a deployment that serves its own SVG favicon
105
+ * shows that; any other branding shows its name alone.
106
+ */
107
+ export type BrandMark = "connecta" | "favicon" | "none";
108
+ export declare function brandMark(branding: ConnectaBranding | undefined, uiMounted: boolean): BrandMark;
102
109
  /**
103
110
  * Status marks for one-message pages. Decorative: the label and heading beside
104
111
  * each carry the meaning, so the shape repeats it and color is never alone.
package/dist/branding.js CHANGED
@@ -1,4 +1,4 @@
1
- import { PAGE_CSS, TOKENS_CSS } from "./page-styles.js";
1
+ import { PAGE_CSS, PAGE_FONTS, TOKENS_CSS } from "./page-styles.js";
2
2
  import { htmlSecurityHeaders } from "./html-security.js";
3
3
  /** Connecta's default monochrome "C" mark. */
4
4
  export const CONNECTA_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
@@ -9,6 +9,7 @@ export const CONNECTA_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" vie
9
9
  <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"/>
10
10
  </svg>`;
11
11
  const DEFAULT_FAVICON_HREF = "/favicon.svg";
12
+ const DEFAULT_PRODUCT_NAME = "Connecta";
12
13
  /**
13
14
  * Branding arrives from operator config, which is untyped at a JS call site, so
14
15
  * every field is treated as `unknown`: a non-string is read as unset rather than
@@ -19,7 +20,7 @@ function trimmedString(value) {
19
20
  return typeof value === "string" ? value.trim() || undefined : undefined;
20
21
  }
21
22
  export function resolveBranding(branding) {
22
- const productName = trimmedString(branding?.productName) ?? "Connecta";
23
+ const productName = trimmedString(branding?.productName) ?? DEFAULT_PRODUCT_NAME;
23
24
  const ownerName = trimmedString(branding?.ownerName);
24
25
  // Operator branding URLs become masthead/callback hrefs, so a non-http(s)
25
26
  // scheme (javascript:, data:) is dropped the same as an unset URL — the
@@ -34,7 +35,7 @@ export function resolveBranding(branding) {
34
35
  ...(ownerUrl && isSafeHttpUrl(ownerUrl) ? { ownerUrl } : {}),
35
36
  description: trimmedString(branding?.description) ??
36
37
  `Manage the services this ${productName} instance makes available to agents.`,
37
- pageTitle: trimmedString(branding?.pageTitle) ?? (ownerName ? `${productName} — ${ownerName}` : productName),
38
+ pageTitle: trimmedString(branding?.pageTitle) ?? (ownerName ? `${productName} · ${ownerName}` : productName),
38
39
  faviconHref: faviconHref && isSafeIconHref(faviconHref) ? faviconHref : DEFAULT_FAVICON_HREF,
39
40
  themeColor: configuredThemeColor(branding) ?? "#ffffff",
40
41
  theme: resolveTheme(branding?.theme),
@@ -128,6 +129,46 @@ export function droppedThemeTokens(theme) {
128
129
  }
129
130
  return dropped.map((token) => `theme.${token}`);
130
131
  }
132
+ function rgbOf(hex) {
133
+ const digits = hex.slice(1);
134
+ // `#rgb` and `#rgba` double each digit; alpha never affects contrast here.
135
+ const short = digits.length <= 4;
136
+ const pairs = [0, 1, 2].map((i) => (short ? digits[i].repeat(2) : digits.slice(i * 2, i * 2 + 2)));
137
+ return pairs.map((pair) => parseInt(pair, 16));
138
+ }
139
+ function luminance([r, g, b]) {
140
+ const channel = (value) => {
141
+ const c = value / 255;
142
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
143
+ };
144
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
145
+ }
146
+ function contrast(a, b) {
147
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
148
+ return (hi + 0.05) / (lo + 0.05);
149
+ }
150
+ const hexOf = (rgb) => `#${rgb.map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")}`;
151
+ /** The dark palette's lightest surface that carries accent text (`--hover`). */
152
+ const DARK_TEXT_GROUND = [0x24, 0x22, 0x1e];
153
+ /**
154
+ * A configured accent's two derived colors. Text on its fill is white or
155
+ * near-black, whichever reads better; accent text on dark surfaces mixes the
156
+ * accent with white until it reaches 4.5:1, starting from the built-in
157
+ * accent's 80% so a light accent keeps its color.
158
+ */
159
+ function accentTokens(accent) {
160
+ const rgb = rgbOf(accent);
161
+ const ink = [0x0f, 0x0e, 0x1f];
162
+ const onAccent = contrast(rgb, [255, 255, 255]) >= contrast(rgb, ink) ? "#ffffff" : hexOf(ink);
163
+ let darkText = hexOf(rgb.map((c) => c * 0.8 + 255 * 0.2));
164
+ for (let share = 0.8; share >= 0; share -= 0.05) {
165
+ const mixed = rgb.map((c) => c * share + 255 * (1 - share));
166
+ darkText = hexOf(mixed);
167
+ if (contrast(mixed, DARK_TEXT_GROUND) >= 4.5)
168
+ break;
169
+ }
170
+ return { onAccent, darkText };
171
+ }
131
172
  /**
132
173
  * The resolved theme as a `:root` block, or "" when a deployment configured
133
174
  * nothing. It is emitted after the stylesheet so it overrides the defaults.
@@ -135,8 +176,10 @@ export function droppedThemeTokens(theme) {
135
176
  * anything that would need escaping is dropped rather than rewritten.
136
177
  */
137
178
  export function themeCss(theme) {
179
+ const derived = theme.accent ? accentTokens(theme.accent) : undefined;
138
180
  const declarations = [
139
181
  theme.accent ? `--accent:${theme.accent}` : "",
182
+ derived ? `--custom-on-accent:${derived.onAccent};--custom-accent-text:${derived.darkText}` : "",
140
183
  theme.radius !== undefined ? `--radius:${theme.radius}` : "",
141
184
  theme.fontFamily ? `--sans:${theme.fontFamily}` : "",
142
185
  theme.monoFamily ? `--mono:${theme.monoFamily}` : "",
@@ -156,11 +199,11 @@ export function escapeHtml(value) {
156
199
  return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
157
200
  }
158
201
  /**
159
- * The masthead's `--surface` in each palette, for the browser chrome a
202
+ * The top bar's `--bg` in each palette, for the browser chrome a
160
203
  * `theme-color` paints. A configured `themeColor` wins; without one a dark
161
204
  * page should not sit under a white status bar.
162
205
  */
163
- const SURFACE = { light: "#ffffff", dark: "#151a21" };
206
+ const SURFACE = { light: "#f6f4ef", dark: "#12110f" };
164
207
  /** The operator's own `themeColor`, read the one way both callers read it. */
165
208
  function configuredThemeColor(branding) {
166
209
  return trimmedString(branding?.themeColor);
@@ -202,6 +245,7 @@ export function renderPage(branding, layout) {
202
245
  : label("brand", brand.productName, brand.productUrl);
203
246
  const product = brand.ownerName ? label("product", brand.productName, brand.productUrl) : "";
204
247
  const customIcon = brand.faviconHref !== DEFAULT_FAVICON_HREF;
248
+ const mark = brandMarkHtml(brandMark(branding, layout.uiMounted));
205
249
  // `isSafeIconHref` already limited a relative href to one leading slash, so
206
250
  // resolving it can only land on `iconOrigin` itself.
207
251
  const iconHref = (href) => layout.iconOrigin && href.startsWith("/") ? new URL(href, layout.iconOrigin).toString() : href;
@@ -214,7 +258,13 @@ export function renderPage(branding, layout) {
214
258
  const skip = layout.skipTo
215
259
  ? `<a class="skip-link" href="#${escapeHtml(layout.skipTo.id)}">${escapeHtml(layout.skipTo.label)}</a>\n`
216
260
  : "";
217
- const styles = (layout.styles ?? TOKENS_CSS + PAGE_CSS) + (layout.extraStyles ?? "");
261
+ // The operator bundle declares its own fonts. Other pages link the same
262
+ // files only where the UI module serves them, root-relative so the page's
263
+ // own origin serves them; elsewhere the stacks fall back to system fonts.
264
+ const fonts = layout.uiMounted
265
+ ? PAGE_FONTS.map((font) => `@font-face{font-family:"${font.family}";font-style:normal;font-weight:${font.weight};font-display:swap;src:url("${font.path}") format("woff2")}`).join("")
266
+ : "";
267
+ const styles = (layout.styles ?? fonts + TOKENS_CSS + PAGE_CSS) + (layout.extraStyles ?? "");
218
268
  return `<!doctype html>
219
269
  <html lang="en"${schemeAttribute(brand.theme)}>
220
270
  <head>
@@ -231,7 +281,7 @@ ${layout.head ?? ""}
231
281
  ${skip}<header class="masthead shell">
232
282
  <div class="masthead-inner">
233
283
  <div class="mast-nav">
234
- ${owner}${product ? `\n ${product}` : ""}
284
+ ${mark}${owner}${product ? `\n ${product}` : ""}
235
285
  </div>${layout.mastheadEnd ? `\n ${layout.mastheadEnd}` : ""}
236
286
  </div>
237
287
  </header>
@@ -239,6 +289,23 @@ ${layout.body}${layout.tail ? `\n${layout.tail}` : ""}
239
289
  </body>
240
290
  </html>`;
241
291
  }
292
+ export function brandMark(branding, uiMounted) {
293
+ if (uiMounted && trimmedString(branding?.favicon?.svg))
294
+ return "favicon";
295
+ const brand = resolveBranding(branding);
296
+ const unbranded = brand.productName === DEFAULT_PRODUCT_NAME &&
297
+ !brand.ownerName &&
298
+ brand.faviconHref === DEFAULT_FAVICON_HREF &&
299
+ !branding?.favicon?.svg;
300
+ return unbranded ? "connecta" : "none";
301
+ }
302
+ function brandMarkHtml(mark) {
303
+ if (mark === "connecta")
304
+ return '<span class="brand-mark" aria-hidden="true"></span>';
305
+ if (mark === "favicon")
306
+ return `<img class="brand-mark" src="${DEFAULT_FAVICON_HREF}" alt="">`;
307
+ return "";
308
+ }
242
309
  /**
243
310
  * Status marks for one-message pages. Decorative: the label and heading beside
244
311
  * each carry the meaning, so the shape repeats it and color is never alone.
@@ -281,12 +348,12 @@ export function notFoundResponse(request, opts) {
281
348
  <p class="status-label">404</p>
282
349
  </div>
283
350
  <h1>Page not found</h1>
284
- <p class="status-copy">Nothing is served at this address. Check the link, or ask whoever runs ${escapeHtml(brand.productName)} for the right one.</p>
351
+ <p class="status-copy">Nothing is here. Check the link, or ask whoever runs ${escapeHtml(brand.productName)}.</p>
285
352
  ${home}
286
353
  </section>
287
354
  </main>`;
288
355
  return new Response(renderPage(opts.ui?.branding, {
289
- title: `Page not found — ${brand.pageTitle}`,
356
+ title: `Page not found · ${brand.pageTitle}`,
290
357
  uiMounted,
291
358
  // Same bytes on either host: the icons, like the home link, come from
292
359
  // the public origin.
@@ -0,0 +1,15 @@
1
+ import { chargeCallBudget } from "./runtime/call-admission.js";
2
+ import { runEdge } from "./runtime/run.js";
3
+ const permits = new WeakMap();
4
+ export function attachCallAdmission(ctx, permit) {
5
+ permits.set(ctx, permit);
6
+ }
7
+ /**
8
+ * Charge one sequential auxiliary request using the enclosing concurrency slot.
9
+ * Await before dispatch and propagate refusal. Contexts outside an invocation
10
+ * have no registry policy to charge.
11
+ */
12
+ export function chargeConnectorCall(ctx) {
13
+ const permit = permits.get(ctx);
14
+ return permit ? runEdge(chargeCallBudget(permit, ctx.signal)) : Promise.resolve();
15
+ }
@@ -1,6 +1,7 @@
1
1
  import { Deferred, Duration, Effect } from "effect";
2
+ import { CALL_ADMISSION } from "./provider.js";
2
3
  import { ConnectorCallError } from "./errors.js";
3
- import { provideCallAdmissionProgram, startCallAdmission } from "./runtime/call-admission.js";
4
+ import { provideCallAdmissionProgram, provideCallBudgetCharge, startCallAdmission } from "./runtime/call-admission.js";
4
5
  import { fromSignal, runEdge } from "./runtime/run.js";
5
6
  const DEFAULT_MAX_QUEUE_SIZE = 32;
6
7
  const DEFAULT_QUEUE_TIMEOUT_MS = 5_000;
@@ -50,18 +51,6 @@ export function aggregateCallAdmissionSnapshots(snapshots) {
50
51
  }
51
52
  return aggregate;
52
53
  }
53
- function positiveWhole(value, name) {
54
- if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
55
- throw new TypeError(`${name} must be a positive whole number.`);
56
- }
57
- return value;
58
- }
59
- function nonNegativeWhole(value, name) {
60
- if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
61
- throw new TypeError(`${name} must be a non-negative whole number.`);
62
- }
63
- return value;
64
- }
65
54
  /**
66
55
  * Per-runtime, per-connector call admission. State contains only bounded
67
56
  * partition keys, counters, timestamps, signals, and promise continuations;
@@ -88,36 +77,14 @@ export class ConnectorCallAdmissionController {
88
77
  queueWaitMaxMs = 0;
89
78
  constructor(connectorId, policy) {
90
79
  this.connectorId = connectorId;
91
- if (!Array.isArray(policy.rules) || policy.rules.length !== 1) {
92
- throw new TypeError(`connector "${connectorId}" callAdmission.rules must contain exactly one rule in this release.`);
93
- }
80
+ policy = CALL_ADMISSION.parse(policy, `connector "${connectorId}" callAdmission`);
94
81
  const rule = policy.rules[0];
95
- if (rule.maxConcurrency === undefined && rule.budget === undefined) {
96
- throw new TypeError(`connector "${connectorId}" callAdmission rule must declare maxConcurrency or budget.`);
97
- }
98
- this.maxConcurrency =
99
- rule.maxConcurrency === undefined
100
- ? undefined
101
- : positiveWhole(rule.maxConcurrency, `connector "${connectorId}" callAdmission maxConcurrency`);
102
- if (this.maxConcurrency === undefined &&
103
- (rule.maxQueueSize !== undefined || rule.queueTimeoutMs !== undefined || rule.retryAfterMs !== undefined)) {
104
- throw new TypeError(`connector "${connectorId}" callAdmission queue settings require maxConcurrency.`);
105
- }
106
- this.maxQueueSize = nonNegativeWhole(rule.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE, `connector "${connectorId}" callAdmission maxQueueSize`);
107
- this.queueTimeoutMs = positiveWhole(rule.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS, `connector "${connectorId}" callAdmission queueTimeoutMs`);
108
- this.retryAfterMs = nonNegativeWhole(rule.retryAfterMs ?? DEFAULT_RETRY_AFTER_MS, `connector "${connectorId}" callAdmission retryAfterMs`);
109
- this.maxPartitions = positiveWhole(policy.maxPartitions ?? DEFAULT_MAX_PARTITIONS, `connector "${connectorId}" callAdmission maxPartitions`);
110
- if (rule.budget) {
111
- if (rule.budget.kind !== "rolling-window") {
112
- throw new TypeError(`connector "${connectorId}" callAdmission budget kind must be "rolling-window".`);
113
- }
114
- const maxCalls = positiveWhole(rule.budget.maxCalls, `connector "${connectorId}" callAdmission budget.maxCalls`);
115
- const windowMs = positiveWhole(rule.budget.windowMs, `connector "${connectorId}" callAdmission budget.windowMs`);
116
- this.budget = { maxCalls, windowMs };
117
- }
118
- else {
119
- this.budget = undefined;
120
- }
82
+ this.maxConcurrency = rule.maxConcurrency;
83
+ this.maxQueueSize = rule.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE;
84
+ this.queueTimeoutMs = rule.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS;
85
+ this.retryAfterMs = rule.retryAfterMs ?? DEFAULT_RETRY_AFTER_MS;
86
+ this.maxPartitions = policy.maxPartitions ?? DEFAULT_MAX_PARTITIONS;
87
+ this.budget = rule.budget ? { maxCalls: rule.budget.maxCalls, windowMs: rule.budget.windowMs } : undefined;
121
88
  this.partitionKey = rule.partitionKey;
122
89
  }
123
90
  acquire(input) {
@@ -179,7 +146,7 @@ export class ConnectorCallAdmissionController {
179
146
  this.queueWaitMaxMs = Math.max(this.queueWaitMaxMs, waitMs);
180
147
  }
181
148
  let released = false;
182
- return {
149
+ const permit = {
183
150
  waitMs,
184
151
  release: () => {
185
152
  if (released)
@@ -190,6 +157,26 @@ export class ConnectorCallAdmissionController {
190
157
  this.pump(state);
191
158
  },
192
159
  };
160
+ provideCallBudgetCharge(permit, (chargedAt, signal) => {
161
+ if (this.closed || released) {
162
+ return Effect.fail(new CallAdmissionError("closed", "unavailable", `Connector "${this.connectorId}" call admission ${this.closed ? "is closed" : "permit is released"}.`));
163
+ }
164
+ if (signal?.aborted) {
165
+ this.cancelledTotal++;
166
+ return Effect.fail(this.cancelled(signal));
167
+ }
168
+ this.pruneBudget(state, chargedAt);
169
+ const retryAfterMs = this.budgetRetryAfterMs(state, chargedAt);
170
+ if (retryAfterMs !== undefined) {
171
+ this.rateLimitedTotal++;
172
+ return Effect.fail(this.budgetLimited(retryAfterMs));
173
+ }
174
+ if (this.budget)
175
+ state.admittedAt.push(chargedAt);
176
+ this.admittedTotal++;
177
+ return Effect.void;
178
+ });
179
+ return permit;
193
180
  }
194
181
  pump(state) {
195
182
  if (this.closed || this.maxConcurrency === undefined)
@@ -1,18 +1,22 @@
1
1
  const MAX_FACTS = 4;
2
2
  const MAX_TEXT = 128;
3
+ // Shared with the REST write schema so every accepted key survives recovery.
4
+ export const IDEMPOTENCY_KEY_MAX_LENGTH = 255;
3
5
  const NAME = /^[A-Za-z][A-Za-z0-9]*$/;
4
6
  const facts = new WeakMap();
5
7
  /**
6
8
  * Record facts that let the caller reconcile or safely retry this call's
7
9
  * write. Bounded: at most four string facts with short camelCase names; a fact
8
- * outside the bound is dropped rather than truncated.
10
+ * outside the bound is dropped rather than truncated. Idempotency keys allow
11
+ * 255 characters to match the REST write schema; other values allow 128.
9
12
  */
10
13
  export function recordRecovery(ctx, recovery) {
11
14
  const kept = { ...facts.get(ctx) };
12
15
  for (const [name, value] of Object.entries(recovery)) {
13
16
  if (Object.keys(kept).length >= MAX_FACTS && !Object.hasOwn(kept, name))
14
17
  break;
15
- if (!NAME.test(name) || name.length > 32 || typeof value !== "string" || value.length > MAX_TEXT)
18
+ const maxLength = name === "idempotencyKey" ? IDEMPOTENCY_KEY_MAX_LENGTH : MAX_TEXT;
19
+ if (!NAME.test(name) || name.length > 32 || typeof value !== "string" || value.length > maxLength)
16
20
  continue;
17
21
  kept[name] = value;
18
22
  }
@@ -582,7 +582,7 @@ export class CatalogService {
582
582
  }
583
583
  return withAuthorizationHandoff(this.requestScope, error);
584
584
  }
585
- async search(args) {
585
+ async search(args, onCatalogReference) {
586
586
  if (args.query !== undefined && typeof args.query !== "string") {
587
587
  throw new DiscoveryPolicyError("invalid_args", "query must be a string. Omit it or use an empty string to browse the catalog.");
588
588
  }
@@ -915,6 +915,20 @@ export class CatalogService {
915
915
  : "No single tool matched every term in the catalogs that answered. Split distinct intents into separate searches.";
916
916
  };
917
917
  const guidance = searchGuidance();
918
+ // Counts, ranking, and term analysis use the whole searched corpus, not
919
+ // just this page's tools. Bind every searched connector, including failed
920
+ // catalogs and off-page matches, so paging cannot reveal a hidden
921
+ // connector's contribution. Keep provenance host-only.
922
+ for (const connector of connectors)
923
+ onCatalogReference?.(connector.id);
924
+ for (const entry of entries)
925
+ onCatalogReference?.(entry.connector.id, entry.tool.name);
926
+ for (const connector of absence?.configuredConnectors ?? [])
927
+ onCatalogReference?.(connector);
928
+ // An unknown scope's guidance lists the visible roster without searching it.
929
+ if (reportsQueryAnalysis && args.connector && !scopedConnector)
930
+ for (const connector of visibleConnectors)
931
+ onCatalogReference?.(connector.id);
918
932
  return {
919
933
  catalogErrors,
920
934
  ...(absence ? { absence } : {}),
@@ -44,7 +44,6 @@ function factorySource(sources, parts) {
44
44
  export function describeConfigSources(snapshot, raw) {
45
45
  const sources = {};
46
46
  const connectorSources = new Map(raw.connectors.map((connector) => [connector.id, factorySources(connector)]));
47
- const moduleSources = { accessTokens: factorySources(raw.accessTokens) };
48
47
  const provided = (value) => (value === undefined ? "default" : "config");
49
48
  for (const path of configValuePaths(snapshot)) {
50
49
  const parts = path.split(".").slice(1);
@@ -56,12 +55,12 @@ export function describeConfigSources(snapshot, raw) {
56
55
  source = provided(raw.serverInfo?.[key]);
57
56
  else if (group === "urls")
58
57
  source = key === "mcpPath" ? "default" : provided(raw[key]);
59
- else if (group === "hasGrant")
60
- source = provided(raw.grant);
58
+ else if (group === "maintainers")
59
+ source = provided(raw.maintainers);
61
60
  else if (group === "readOnly")
62
61
  source = provided(raw.readOnly);
63
- else if (group === "identity")
64
- source = provided(raw.identity?.[key]);
62
+ else if (group === "agents")
63
+ source = provided(raw.agents);
65
64
  else if (group === "storage")
66
65
  source = provided(raw.storage);
67
66
  else if (group === "deploymentInfo")
@@ -70,11 +69,19 @@ export function describeConfigSources(snapshot, raw) {
70
69
  source = provided(raw.classification);
71
70
  else if (group === "pools")
72
71
  // An inherited value comes from wherever the deployment's came from.
73
- source = field === "readOnly" ? provided(raw.pools?.[key]?.readOnly ?? raw.readOnly) : provided(raw.pools);
72
+ source =
73
+ field === "readOnly"
74
+ ? provided(raw.pools?.[key]?.readOnly ?? raw.readOnly)
75
+ : field === "owner"
76
+ ? provided(raw.pools?.[key]?.owner)
77
+ : field === "description"
78
+ ? provided(raw.pools?.[key]?.description)
79
+ : provided(raw.pools);
80
+ else if (group === "modules" && key === "activity" && field === "store" && parts[3] !== "kind")
81
+ // A store option such as retentionDays is set only when the store says so.
82
+ source = factorySource(factorySources(raw.activity?.store), parts.slice(3)) ?? "default";
74
83
  else if (group === "modules") {
75
- const module = key === "ui" ? raw.ui : key === "vault" ? raw.vault : key === "activity" ? raw.activity : raw.accessTokens;
76
- const factory = moduleSources[key];
77
- source = (factory ? factorySource(factory, parts.slice(2)) : undefined) ?? provided(module);
84
+ source = provided(key === "ui" ? raw.ui : key === "vault" ? raw.vault : key === "activity" ? raw.activity : raw.accessTokens);
78
85
  }
79
86
  else if (group === "branding") {
80
87
  const brand = raw.ui?.branding;