@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.
- package/CHANGELOG.md +98 -0
- package/README.md +95 -116
- package/SECURITY.md +1 -1
- package/bin/connecta.mjs +24 -6
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.d.ts +167 -23
- package/dist/access-tokens.js +676 -140
- package/dist/activity.d.ts +3 -0
- package/dist/agent-identity.js +47 -0
- package/dist/auth/cloudflare-access.js +13 -5
- package/dist/branding.d.ts +7 -0
- package/dist/branding.js +76 -9
- package/dist/call-admission-context.js +15 -0
- package/dist/call-admission.js +30 -43
- package/dist/call-recovery.js +6 -2
- package/dist/catalog-service.js +15 -1
- package/dist/config-value-sources.js +16 -9
- package/dist/config.d.ts +48 -75
- package/dist/config.js +144 -36
- package/dist/connector-access.d.ts +4 -34
- package/dist/connector-access.js +15 -99
- package/dist/connector-caller.d.ts +7 -1
- package/dist/connector-caller.js +4 -2
- package/dist/connectors/api-connector.d.ts +27 -4
- package/dist/connectors/api-connector.js +2 -0
- package/dist/connectors/remote-mcp.js +2 -0
- package/dist/describe-config.d.ts +31 -7
- package/dist/describe-config.js +8 -17
- package/dist/execute.js +1 -11
- package/dist/fix-prompt.js +2 -2
- package/dist/index.d.ts +4 -3
- package/dist/index.js +30 -10
- package/dist/invocation.js +5 -3
- package/dist/membership.d.ts +53 -0
- package/dist/membership.js +121 -0
- package/dist/module-contracts.d.ts +23 -6
- package/dist/oauth-callback-outcome.js +2 -2
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -4
- package/dist/operator-ui/agents.d.ts +74 -0
- package/dist/operator-ui/agents.js +104 -0
- package/dist/operator-ui/config-summary.js +562 -0
- package/dist/operator-ui/connection-map.js +375 -0
- package/dist/operator-ui/contract.d.ts +149 -28
- package/dist/operator-ui/endpoints.js +221 -0
- package/dist/operator-ui/fix-prompts.js +5 -5
- package/dist/operator-ui/generated.js +5 -4
- package/dist/operator-ui/member-page.js +97 -0
- package/dist/operator-ui/model.d.ts +14 -6
- package/dist/operator-ui/overview.js +134 -0
- package/dist/operator-ui/view.d.ts +107 -37
- package/dist/operator-ui/view.js +163 -86
- package/dist/page-styles.js +5 -3
- package/dist/provider.d.ts +61 -8
- package/dist/provider.js +46 -7
- package/dist/providers/_shared/google/workspace.d.ts +56 -17
- package/dist/providers/_shared/google/workspace.js +34 -8
- package/dist/providers/_shared/rest/tools.js +2 -2
- package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
- package/dist/providers/_shared/rest/value-safety.js +699 -7
- package/dist/providers/ccb/index.js +2 -2
- package/dist/providers/cloudflare/index.js +3 -3
- package/dist/providers/cloudflare/rest.js +25 -3
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/cloudflare/value-safety.js +32 -0
- package/dist/providers/docs/index.js +4 -4
- package/dist/providers/docs/skill.generated.js +2 -2
- package/dist/providers/drive/index.js +4 -4
- package/dist/providers/drive/skill.generated.js +2 -2
- package/dist/providers/forms/index.js +4 -4
- package/dist/providers/forms/skill.generated.js +2 -2
- package/dist/providers/gmail/index.js +11 -9
- package/dist/providers/gmail/skill.generated.js +2 -2
- package/dist/providers/mixpanel/index.js +1 -1
- package/dist/providers/mixpanel/skill.generated.js +3 -3
- package/dist/providers/notion/index.d.ts +7 -0
- package/dist/providers/notion/index.js +100 -11
- package/dist/providers/notion/rest.js +45 -5
- package/dist/providers/notion/skill.generated.js +2 -2
- package/dist/providers/notion/value-safety.js +30 -5
- package/dist/providers/overflow/index.js +2 -2
- package/dist/providers/overflow/skill.generated.js +2 -2
- package/dist/providers/planning-center/index.js +2 -2
- package/dist/providers/planning-center/skill.generated.js +4 -4
- package/dist/providers/revenuecat/skill.generated.js +2 -2
- package/dist/providers/sheets/index.d.ts +3 -0
- package/dist/providers/sheets/index.js +7 -4
- package/dist/providers/sheets/skill.generated.js +2 -2
- package/dist/providers/slides/index.js +4 -4
- package/dist/providers/slides/skill.generated.js +2 -2
- package/dist/providers/stripe/rest.js +9 -2
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/stripe/value-safety.js +22 -0
- package/dist/providers/tithely/index.js +1 -1
- package/dist/providers/vercel/index.js +1 -1
- package/dist/providers/vercel/rest.js +20 -2
- package/dist/providers/vercel/skill.generated.js +2 -2
- package/dist/providers/vercel/value-safety.js +22 -0
- package/dist/registry.d.ts +2 -3
- package/dist/registry.js +23 -9
- package/dist/routes/access-tokens.js +82 -75
- package/dist/routes/activity-disclosure.js +17 -18
- package/dist/routes/activity.js +34 -54
- package/dist/routes/actor-labels.js +33 -4
- package/dist/routes/agent-path.js +43 -0
- package/dist/routes/connect.js +16 -12
- package/dist/routes/credentials.js +16 -40
- package/dist/routes/mcp.js +161 -104
- package/dist/routes/oauth-management.js +11 -5
- package/dist/routes/oauth.js +5 -11
- package/dist/routes/operator.js +24 -23
- package/dist/routes/shared.d.ts +60 -20
- package/dist/routes/shared.js +187 -96
- package/dist/routes/ui-config.js +437 -97
- package/dist/routes/ui-data.js +2 -2
- package/dist/routes/ui.js +9 -16
- package/dist/runtime/call-admission.js +9 -0
- package/dist/server.js +5 -0
- package/dist/skills.js +10 -0
- package/dist/storage/keys.js +9 -1
- package/dist/storage/sql.js +5 -1
- package/dist/types.d.ts +29 -7
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +23 -16
- package/dist/usage-guide.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace-address.js +9 -0
- package/dist/workspace-subject.js +23 -0
- package/documentation/README.md +47 -23
- package/documentation/architecture.md +371 -61
- package/documentation/auth.md +897 -249
- package/documentation/code-mode.md +37 -16
- package/documentation/deploying.md +126 -25
- package/documentation/integrating.md +97 -23
- package/documentation/meta-tools.md +50 -31
- package/documentation/migration-0.33.md +352 -0
- package/documentation/migration-0.34.md +67 -0
- package/documentation/operating.md +3 -1
- package/documentation/operator-tests.md +147 -34
- package/documentation/operator-ui.md +519 -64
- package/documentation/provider-auth-migration.md +37 -23
- package/documentation/provider-migration-0.29.md +2 -2
- package/examples/worker/AGENTS.md +37 -9
- package/examples/worker/README.md +127 -61
- package/examples/worker/src/connecta.config.ts +40 -23
- package/examples/worker/wrangler.jsonc +4 -1
- package/package.json +19 -18
- package/templates/node/.env.example +3 -0
- package/templates/node/AGENTS.md +33 -6
- package/templates/node/Dockerfile +2 -2
- package/templates/node/README.md +80 -52
- package/templates/node/docker-compose.yml +3 -2
- package/templates/node/package.json +4 -3
- package/templates/node/src/adopt-token.ts +23 -0
- package/templates/node/src/connecta.config.ts +28 -12
- package/templates/node/src/rotate-token.ts +17 -0
- package/templates/node/src/provision-token.ts +0 -17
package/dist/activity.d.ts
CHANGED
|
@@ -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
|
|
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 };
|
package/dist/branding.d.ts
CHANGED
|
@@ -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) ??
|
|
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}
|
|
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("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
|
157
200
|
}
|
|
158
201
|
/**
|
|
159
|
-
* The
|
|
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: "#
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/call-admission.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
this.
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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)
|
package/dist/call-recovery.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/catalog-service.js
CHANGED
|
@@ -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 === "
|
|
60
|
-
source = provided(raw.
|
|
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 === "
|
|
64
|
-
source = provided(raw.
|
|
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 =
|
|
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
|
-
|
|
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;
|