@zackbart/connecta 0.15.1 → 0.16.1
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/AGENTS.md +40 -20
- package/CHANGELOG.md +653 -0
- package/README.md +25 -5
- package/bin/connecta.mjs +44 -3
- package/dist/access-tokens.d.ts +0 -1
- package/dist/access-tokens.js +0 -1
- package/dist/activity.d.ts +50 -2
- package/dist/activity.js +34 -1
- package/dist/apps-shell.d.ts +0 -1
- package/dist/apps-shell.js +0 -1
- package/dist/auth/bearer.d.ts +0 -1
- package/dist/auth/bearer.js +0 -1
- package/dist/auth/clerk.d.ts +0 -1
- package/dist/auth/clerk.js +45 -9
- package/dist/auth/downstream-oauth.d.ts +0 -1
- package/dist/auth/downstream-oauth.js +0 -1
- package/dist/call-admission.d.ts +0 -1
- package/dist/call-admission.js +0 -1
- package/dist/catalog-drift.d.ts +99 -0
- package/dist/catalog-drift.js +251 -0
- package/dist/catalog-fingerprint.d.ts +0 -1
- package/dist/catalog-fingerprint.js +0 -1
- package/dist/catalog-limits.d.ts +0 -1
- package/dist/catalog-limits.js +0 -1
- package/dist/catalog-service.d.ts +0 -1
- package/dist/catalog-service.js +41 -5
- package/dist/catalog.d.ts +11 -1
- package/dist/catalog.js +20 -1
- package/dist/concurrency.d.ts +0 -1
- package/dist/concurrency.js +0 -1
- package/dist/connector-scope.d.ts +0 -1
- package/dist/connector-scope.js +0 -1
- package/dist/connectors/api.d.ts +29 -21
- package/dist/connectors/api.js +45 -19
- package/dist/connectors/guarded-fetch.d.ts +106 -0
- package/dist/connectors/guarded-fetch.js +289 -0
- package/dist/connectors/remote-mcp.d.ts +0 -1
- package/dist/connectors/remote-mcp.js +0 -1
- package/dist/credentials.d.ts +0 -1
- package/dist/credentials.js +0 -1
- package/dist/errors.d.ts +30 -3
- package/dist/errors.js +1 -1
- package/dist/execute.d.ts +0 -1
- package/dist/execute.js +0 -1
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +28 -1
- package/dist/executor-result.d.ts +0 -1
- package/dist/executor-result.js +0 -1
- package/dist/executors/quickjs-child.d.ts +0 -1
- package/dist/executors/quickjs-child.js +0 -1
- package/dist/executors/quickjs-protocol.d.ts +0 -1
- package/dist/executors/quickjs-protocol.js +0 -1
- package/dist/executors/quickjs-runtime.d.ts +0 -1
- package/dist/executors/quickjs-runtime.js +0 -1
- package/dist/executors/quickjs.d.ts +0 -1
- package/dist/executors/quickjs.js +2 -1
- package/dist/favicon.d.ts +0 -1
- package/dist/favicon.js +0 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.js +24 -7
- package/dist/invocation.d.ts +0 -1
- package/dist/invocation.js +0 -1
- package/dist/json-schema.d.ts +0 -1
- package/dist/json-schema.js +0 -1
- package/dist/mcp-result.d.ts +0 -1
- package/dist/mcp-result.js +0 -1
- package/dist/meta-tools.d.ts +0 -1
- package/dist/meta-tools.js +0 -1
- package/dist/node.d.ts +0 -1
- package/dist/node.js +0 -1
- package/dist/operator-ui/generated.d.ts +2 -3
- package/dist/operator-ui/generated.js +3 -4
- package/dist/operator-ui/model.d.ts +10 -1
- package/dist/operator-ui/model.js +0 -1
- package/dist/operator-ui/view.d.ts +167 -0
- package/dist/operator-ui/view.js +270 -0
- package/dist/providers/cloudflare.d.ts +0 -1
- package/dist/providers/cloudflare.js +261 -299
- package/dist/providers/linear.d.ts +26 -5
- package/dist/providers/linear.js +32 -61
- package/dist/providers/mixpanel.d.ts +35 -4
- package/dist/providers/mixpanel.js +53 -79
- package/dist/providers/notion.d.ts +0 -1
- package/dist/providers/notion.js +87 -42
- package/dist/providers/stripe.d.ts +13 -1
- package/dist/providers/stripe.js +31 -60
- package/dist/registry.d.ts +34 -2
- package/dist/registry.js +85 -5
- package/dist/retired-toolkits.d.ts +0 -1
- package/dist/retired-toolkits.js +0 -1
- package/dist/routes/access-tokens.d.ts +0 -1
- package/dist/routes/access-tokens.js +0 -1
- package/dist/routes/activity.d.ts +0 -1
- package/dist/routes/activity.js +0 -1
- package/dist/routes/credentials.d.ts +0 -1
- package/dist/routes/credentials.js +0 -1
- package/dist/routes/mcp.d.ts +0 -1
- package/dist/routes/mcp.js +0 -1
- package/dist/routes/oauth.d.ts +0 -1
- package/dist/routes/oauth.js +0 -1
- package/dist/routes/shared.d.ts +2 -1
- package/dist/routes/shared.js +0 -1
- package/dist/routes/ui.d.ts +0 -1
- package/dist/routes/ui.js +0 -1
- package/dist/server.d.ts +0 -1
- package/dist/server.js +11 -1
- package/dist/skills.d.ts +0 -1
- package/dist/skills.js +0 -1
- package/dist/storage/file.d.ts +0 -1
- package/dist/storage/file.js +0 -1
- package/dist/storage/memory.d.ts +0 -1
- package/dist/storage/memory.js +0 -1
- package/dist/timeout.d.ts +0 -1
- package/dist/timeout.js +0 -1
- package/dist/tool-safety.d.ts +0 -1
- package/dist/tool-safety.js +0 -1
- package/dist/types.d.ts +45 -1
- package/dist/types.js +0 -1
- package/dist/ui.d.ts +0 -1
- package/dist/ui.js +13 -137
- package/dist/validate.d.ts +13 -15
- package/dist/validate.js +24 -11
- package/dist/version.d.ts +1 -2
- package/dist/version.js +1 -2
- package/documentation/architecture.md +186 -5
- package/documentation/auth.md +11 -0
- package/documentation/call-admission.md +142 -6
- package/documentation/cloudflare.md +92 -10
- package/documentation/code-first-exploration.md +2 -2
- package/documentation/code-mode.md +14 -14
- package/documentation/connector-guides.md +172 -5
- package/documentation/connectors.md +89 -0
- package/documentation/linear.md +20 -2
- package/documentation/mcp-ui-design.md +2 -2
- package/documentation/meta-tools.md +7 -1
- package/documentation/mixpanel.md +42 -9
- package/documentation/notion.md +23 -2
- package/documentation/operations.md +325 -5
- package/documentation/operator-ui.md +120 -5
- package/documentation/provider-audit.md +160 -0
- package/documentation/provider-conventions.md +667 -0
- package/documentation/request-admission.md +167 -5
- package/documentation/rich-output-design.md +1 -2
- package/documentation/stripe.md +15 -0
- package/documentation/upgrading.md +454 -0
- package/ethos.md +14 -9
- package/examples/worker/README.md +102 -12
- package/examples/worker/src/index.ts +46 -1
- package/examples/worker/wrangler.jsonc +14 -1
- package/package.json +10 -6
- package/templates/node/.dockerignore +10 -0
- package/templates/node/.env.example +40 -3
- package/templates/node/AGENTS.md +16 -1
- package/templates/node/Dockerfile +49 -0
- package/templates/node/README.md +117 -2
- package/templates/node/docker-compose.yml +39 -0
- package/templates/node/package.json +1 -1
- package/templates/node/src/file-activity.ts +182 -0
- package/templates/node/src/index.ts +80 -4
- package/assets/connecta-clay-hero.png +0 -0
- package/dist/access-tokens.d.ts.map +0 -1
- package/dist/access-tokens.js.map +0 -1
- package/dist/activity.d.ts.map +0 -1
- package/dist/activity.js.map +0 -1
- package/dist/apps-shell.d.ts.map +0 -1
- package/dist/apps-shell.js.map +0 -1
- package/dist/auth/bearer.d.ts.map +0 -1
- package/dist/auth/bearer.js.map +0 -1
- package/dist/auth/clerk.d.ts.map +0 -1
- package/dist/auth/clerk.js.map +0 -1
- package/dist/auth/downstream-oauth.d.ts.map +0 -1
- package/dist/auth/downstream-oauth.js.map +0 -1
- package/dist/call-admission.d.ts.map +0 -1
- package/dist/call-admission.js.map +0 -1
- package/dist/catalog-fingerprint.d.ts.map +0 -1
- package/dist/catalog-fingerprint.js.map +0 -1
- package/dist/catalog-limits.d.ts.map +0 -1
- package/dist/catalog-limits.js.map +0 -1
- package/dist/catalog-service.d.ts.map +0 -1
- package/dist/catalog-service.js.map +0 -1
- package/dist/catalog.d.ts.map +0 -1
- package/dist/catalog.js.map +0 -1
- package/dist/concurrency.d.ts.map +0 -1
- package/dist/concurrency.js.map +0 -1
- package/dist/connector-scope.d.ts.map +0 -1
- package/dist/connector-scope.js.map +0 -1
- package/dist/connectors/api.d.ts.map +0 -1
- package/dist/connectors/api.js.map +0 -1
- package/dist/connectors/remote-mcp.d.ts.map +0 -1
- package/dist/connectors/remote-mcp.js.map +0 -1
- package/dist/credentials.d.ts.map +0 -1
- package/dist/credentials.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/execute.d.ts.map +0 -1
- package/dist/execute.js.map +0 -1
- package/dist/executor-admission.d.ts.map +0 -1
- package/dist/executor-admission.js.map +0 -1
- package/dist/executor-result.d.ts.map +0 -1
- package/dist/executor-result.js.map +0 -1
- package/dist/executors/quickjs-child.d.ts.map +0 -1
- package/dist/executors/quickjs-child.js.map +0 -1
- package/dist/executors/quickjs-protocol.d.ts.map +0 -1
- package/dist/executors/quickjs-protocol.js.map +0 -1
- package/dist/executors/quickjs-runtime.d.ts.map +0 -1
- package/dist/executors/quickjs-runtime.js.map +0 -1
- package/dist/executors/quickjs.d.ts.map +0 -1
- package/dist/executors/quickjs.js.map +0 -1
- package/dist/favicon.d.ts.map +0 -1
- package/dist/favicon.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/invocation.d.ts.map +0 -1
- package/dist/invocation.js.map +0 -1
- package/dist/json-schema.d.ts.map +0 -1
- package/dist/json-schema.js.map +0 -1
- package/dist/mcp-result.d.ts.map +0 -1
- package/dist/mcp-result.js.map +0 -1
- package/dist/meta-tools.d.ts.map +0 -1
- package/dist/meta-tools.js.map +0 -1
- package/dist/node.d.ts.map +0 -1
- package/dist/node.js.map +0 -1
- package/dist/operator-ui/generated.d.ts.map +0 -1
- package/dist/operator-ui/generated.js.map +0 -1
- package/dist/operator-ui/model.d.ts.map +0 -1
- package/dist/operator-ui/model.js.map +0 -1
- package/dist/providers/cloudflare.d.ts.map +0 -1
- package/dist/providers/cloudflare.js.map +0 -1
- package/dist/providers/linear.d.ts.map +0 -1
- package/dist/providers/linear.js.map +0 -1
- package/dist/providers/mixpanel.d.ts.map +0 -1
- package/dist/providers/mixpanel.js.map +0 -1
- package/dist/providers/notion.d.ts.map +0 -1
- package/dist/providers/notion.js.map +0 -1
- package/dist/providers/stripe.d.ts.map +0 -1
- package/dist/providers/stripe.js.map +0 -1
- package/dist/registry.d.ts.map +0 -1
- package/dist/registry.js.map +0 -1
- package/dist/retired-toolkits.d.ts.map +0 -1
- package/dist/retired-toolkits.js.map +0 -1
- package/dist/routes/access-tokens.d.ts.map +0 -1
- package/dist/routes/access-tokens.js.map +0 -1
- package/dist/routes/activity.d.ts.map +0 -1
- package/dist/routes/activity.js.map +0 -1
- package/dist/routes/credentials.d.ts.map +0 -1
- package/dist/routes/credentials.js.map +0 -1
- package/dist/routes/mcp.d.ts.map +0 -1
- package/dist/routes/mcp.js.map +0 -1
- package/dist/routes/oauth.d.ts.map +0 -1
- package/dist/routes/oauth.js.map +0 -1
- package/dist/routes/shared.d.ts.map +0 -1
- package/dist/routes/shared.js.map +0 -1
- package/dist/routes/ui.d.ts.map +0 -1
- package/dist/routes/ui.js.map +0 -1
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/skills.d.ts.map +0 -1
- package/dist/skills.js.map +0 -1
- package/dist/storage/file.d.ts.map +0 -1
- package/dist/storage/file.js.map +0 -1
- package/dist/storage/memory.d.ts.map +0 -1
- package/dist/storage/memory.js.map +0 -1
- package/dist/timeout.d.ts.map +0 -1
- package/dist/timeout.js.map +0 -1
- package/dist/tool-safety.d.ts.map +0 -1
- package/dist/tool-safety.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/ui.d.ts.map +0 -1
- package/dist/ui.js.map +0 -1
- package/dist/validate.d.ts.map +0 -1
- package/dist/validate.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
- package/examples/node/README.md +0 -52
- package/examples/node/src/index.ts +0 -71
- package/src/access-tokens.ts +0 -289
- package/src/activity.ts +0 -234
- package/src/apps-shell.ts +0 -370
- package/src/auth/bearer.ts +0 -61
- package/src/auth/clerk.ts +0 -553
- package/src/auth/downstream-oauth.ts +0 -575
- package/src/call-admission.ts +0 -521
- package/src/catalog-fingerprint.ts +0 -52
- package/src/catalog-limits.ts +0 -11
- package/src/catalog-service.ts +0 -1153
- package/src/catalog.ts +0 -790
- package/src/concurrency.ts +0 -39
- package/src/connector-scope.ts +0 -66
- package/src/connectors/api.ts +0 -159
- package/src/connectors/remote-mcp.ts +0 -1090
- package/src/credentials.ts +0 -428
- package/src/errors.ts +0 -364
- package/src/execute.ts +0 -1406
- package/src/executor-admission.ts +0 -328
- package/src/executor-result.ts +0 -107
- package/src/executors/quickjs-child.ts +0 -172
- package/src/executors/quickjs-protocol.ts +0 -99
- package/src/executors/quickjs-runtime.ts +0 -470
- package/src/executors/quickjs.ts +0 -776
- package/src/favicon.ts +0 -53
- package/src/index.ts +0 -666
- package/src/invocation.ts +0 -655
- package/src/json-schema.ts +0 -11
- package/src/mcp-result.ts +0 -43
- package/src/meta-tools.ts +0 -1607
- package/src/node.ts +0 -206
- package/src/operator-ui/browser.css +0 -423
- package/src/operator-ui/browser.ts +0 -1281
- package/src/operator-ui/generated.ts +0 -4
- package/src/operator-ui/model.ts +0 -111
- package/src/providers/cloudflare.ts +0 -3999
- package/src/providers/linear.ts +0 -302
- package/src/providers/mixpanel.ts +0 -229
- package/src/providers/notion.ts +0 -1879
- package/src/providers/stripe.ts +0 -307
- package/src/registry.ts +0 -1085
- package/src/retired-toolkits.ts +0 -14
- package/src/routes/access-tokens.ts +0 -115
- package/src/routes/activity.ts +0 -215
- package/src/routes/credentials.ts +0 -254
- package/src/routes/mcp.ts +0 -429
- package/src/routes/oauth.ts +0 -322
- package/src/routes/shared.ts +0 -247
- package/src/routes/ui.ts +0 -152
- package/src/server.ts +0 -195
- package/src/skills.ts +0 -271
- package/src/storage/file.ts +0 -123
- package/src/storage/memory.ts +0 -44
- package/src/timeout.ts +0 -84
- package/src/tool-safety.ts +0 -15
- package/src/types.ts +0 -523
- package/src/ui.ts +0 -744
- package/src/validate.ts +0 -387
- package/src/version.ts +0 -7
package/src/auth/clerk.ts
DELETED
|
@@ -1,553 +0,0 @@
|
|
|
1
|
-
// Clerk as the OAuth 2.1 authorization server; connecta is the resource server.
|
|
2
|
-
// Single tenant, no tenant-tag requirement, optional allowedDomains/gate() with
|
|
3
|
-
// ~60s identity caching.
|
|
4
|
-
|
|
5
|
-
import { createClerkClient } from "@clerk/backend";
|
|
6
|
-
import { assertNoRetiredToolkitOptions } from "../retired-toolkits.js";
|
|
7
|
-
import type { AuthResult, InboundAuth } from "../types.js";
|
|
8
|
-
|
|
9
|
-
type ClerkClient = ReturnType<typeof createClerkClient>;
|
|
10
|
-
type ClerkUser = Awaited<ReturnType<ClerkClient["users"]["getUser"]>>;
|
|
11
|
-
|
|
12
|
-
export interface ClerkAuthOptions {
|
|
13
|
-
publishableKey: string;
|
|
14
|
-
secretKey: string;
|
|
15
|
-
/** Public base URL of this deployment. Defaults to the request origin. */
|
|
16
|
-
publicUrl?: string;
|
|
17
|
-
/**
|
|
18
|
-
* Email domains this deployment admits, e.g. `["acme.com"]`. An
|
|
19
|
-
* authenticated user whose verified primary email is not on one of them is
|
|
20
|
-
* rejected exactly like a `gate` rejection. Matching is exact on the whole
|
|
21
|
-
* domain and case-insensitive: `acme.com` admits neither `evil-acme.com` nor
|
|
22
|
-
* `mail.acme.com` — spell a subdomain out to allow it. Entries must be ASCII
|
|
23
|
-
* (punycode for an internationalized domain) and are validated at
|
|
24
|
-
* construction. Absent ⇒ every authenticated user passes this check, as
|
|
25
|
-
* before the option existed. Governs Clerk sign-in only: a co-configured
|
|
26
|
-
* `bearerToken` has no email to read and is admitted without a domain check.
|
|
27
|
-
*/
|
|
28
|
-
allowedDomains?: readonly string[];
|
|
29
|
-
/** Optional allow-list hook. Return false to reject an authenticated user. */
|
|
30
|
-
gate?: (userId: string, clerk: ClerkClient) => boolean | Promise<boolean>;
|
|
31
|
-
/** Advertised scopes in protected-resource metadata. */
|
|
32
|
-
scopes?: string[];
|
|
33
|
-
/** Optional hosted Account Portal sign-in URL for operator pages. Absolute https only. */
|
|
34
|
-
signInUrl?: string;
|
|
35
|
-
/** Optional hosted Account Portal sign-up URL for operator pages. Absolute https only. */
|
|
36
|
-
signUpUrl?: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const CORS_HEADERS = {
|
|
40
|
-
"Access-Control-Allow-Origin": "*",
|
|
41
|
-
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
42
|
-
"Access-Control-Allow-Headers":
|
|
43
|
-
"Content-Type, Authorization, mcp-protocol-version",
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
/** Coarse bearer shape for diagnostics — never the token itself. */
|
|
47
|
-
function tokenShape(request: Request): string {
|
|
48
|
-
const header = request.headers.get("authorization");
|
|
49
|
-
if (!header) return "none";
|
|
50
|
-
const token = header.replace(/^Bearer\s+/i, "");
|
|
51
|
-
if (token.startsWith("oat_")) return "oauth-opaque";
|
|
52
|
-
if (token.startsWith("eyJ")) return "jwt";
|
|
53
|
-
return "other";
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Clerk Frontend API origin, derived from pk_(test|live)_<b64 domain>. */
|
|
57
|
-
function fapiUrl(publishableKey: string): string {
|
|
58
|
-
const key = publishableKey.replace(/^pk_(test|live)_/, "");
|
|
59
|
-
const decoded = atob(key).replace(/\$$/, "");
|
|
60
|
-
return `https://${decoded}`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const GATE_ALLOWED_TTL_MS = 60 * 1000;
|
|
64
|
-
const GATE_FORBIDDEN_TTL_MS = 30 * 1000;
|
|
65
|
-
const ACTIVITY_LABEL_TTL_MS = 5 * 60 * 1000;
|
|
66
|
-
const ACTIVITY_LABEL_MISS_TTL_MS = 30 * 1000;
|
|
67
|
-
const ACTIVITY_LABEL_LOOKUP_TIMEOUT_MS = 1_250;
|
|
68
|
-
const ACTIVITY_LABEL_MAX_IN_FLIGHT = 8;
|
|
69
|
-
// Per clerkAuth instance. This is deliberately fixed rather than an operator
|
|
70
|
-
// knob: admission correctness never depends on retaining an entry, and 1,024
|
|
71
|
-
// keeps the common steady identity set hot without letting one-off denied
|
|
72
|
-
// identities define the isolate's lifetime memory footprint.
|
|
73
|
-
const GATE_CACHE_MAX_IDENTITIES = 1_024;
|
|
74
|
-
const ACTIVITY_LABEL_MAX_LENGTH = 160;
|
|
75
|
-
|
|
76
|
-
function readIdentityCache<K, V extends { exp: number }>(
|
|
77
|
-
cache: Map<K, V>,
|
|
78
|
-
key: K,
|
|
79
|
-
): V | undefined {
|
|
80
|
-
const hit = cache.get(key);
|
|
81
|
-
if (!hit) return undefined;
|
|
82
|
-
cache.delete(key);
|
|
83
|
-
if (Date.now() >= hit.exp) return undefined;
|
|
84
|
-
cache.set(key, hit);
|
|
85
|
-
return hit;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function writeIdentityCache<K, V>(
|
|
89
|
-
cache: Map<K, V>,
|
|
90
|
-
key: K,
|
|
91
|
-
value: V,
|
|
92
|
-
): void {
|
|
93
|
-
cache.delete(key);
|
|
94
|
-
cache.set(key, value);
|
|
95
|
-
if (cache.size <= GATE_CACHE_MAX_IDENTITIES) return;
|
|
96
|
-
const oldest = cache.keys().next();
|
|
97
|
-
if (!oldest.done) cache.delete(oldest.value);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function cleanActivityLabel(
|
|
101
|
-
value: string | null | undefined,
|
|
102
|
-
): string | undefined {
|
|
103
|
-
if (!value) return undefined;
|
|
104
|
-
const compact = value.replace(/\s+/gu, " ").trim();
|
|
105
|
-
if (!compact) return undefined;
|
|
106
|
-
return Array.from(compact).slice(0, ACTIVITY_LABEL_MAX_LENGTH).join("");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Prefer a person's name, then a verified primary email, then username. */
|
|
110
|
-
function activityLabelForUser(user: ClerkUser): string | undefined {
|
|
111
|
-
const fullName =
|
|
112
|
-
user.fullName ??
|
|
113
|
-
[user.firstName, user.lastName].filter(Boolean).join(" ");
|
|
114
|
-
const primary = user.emailAddresses?.find(
|
|
115
|
-
(address) =>
|
|
116
|
-
address.id === user.primaryEmailAddressId &&
|
|
117
|
-
address.verification?.status === "verified",
|
|
118
|
-
);
|
|
119
|
-
return (
|
|
120
|
-
cleanActivityLabel(fullName) ??
|
|
121
|
-
cleanActivityLabel(primary?.emailAddress) ??
|
|
122
|
-
cleanActivityLabel(user.username)
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* One label of a domain: ASCII letters/digits, interior hyphens only, 63
|
|
128
|
-
* characters at most. ASCII-only is deliberate — an internationalized domain
|
|
129
|
-
* must be in its punycode (`xn--…`) form, so a Unicode confusable can neither be
|
|
130
|
-
* typed into the allowlist nor arrive in an email address and pass for a domain
|
|
131
|
-
* the operator cannot tell from theirs by eye.
|
|
132
|
-
*/
|
|
133
|
-
const DOMAIN_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Is this string a domain, before any case folding? Both sides of the
|
|
137
|
-
* comparison — the operator's allowlist entries and the domain read off a
|
|
138
|
-
* user's email — are checked against this one grammar, so neither side can be
|
|
139
|
-
* *repaired* into a match by the normalization that follows: `"acme.com\n"` and
|
|
140
|
-
* `" acme.com"` are malformed, not `acme.com`, and an `akme.com` spelled with a
|
|
141
|
-
* U+212A KELVIN SIGN is rejected here rather than folded to plain ASCII `k` by
|
|
142
|
-
* `toLowerCase`.
|
|
143
|
-
*/
|
|
144
|
-
function isDomain(domain: string): boolean {
|
|
145
|
-
return (
|
|
146
|
-
domain.length > 0 &&
|
|
147
|
-
domain.length <= 253 &&
|
|
148
|
-
domain.includes(".") &&
|
|
149
|
-
domain.split(".").every((label) => DOMAIN_LABEL_RE.test(label))
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Validate and lowercase `allowedDomains` at construction. Everything here
|
|
155
|
-
* throws rather than dropping the entry: an allowlist that does not say what
|
|
156
|
-
* its author meant is invisible until the day it admits the wrong caller.
|
|
157
|
-
*/
|
|
158
|
-
function normalizeAllowedDomains(
|
|
159
|
-
value: readonly string[] | undefined,
|
|
160
|
-
): ReadonlySet<string> | undefined {
|
|
161
|
-
if (value === undefined) return undefined;
|
|
162
|
-
if (!Array.isArray(value)) {
|
|
163
|
-
throw new Error("clerkAuth: `allowedDomains` must be an array of domains.");
|
|
164
|
-
}
|
|
165
|
-
if (value.length === 0) {
|
|
166
|
-
// Fail-closed, an empty list admits nobody and the deployment is dead on
|
|
167
|
-
// arrival; read as "no restriction", it is the one shape here that fails
|
|
168
|
-
// OPEN. Neither is what anyone meant to write.
|
|
169
|
-
throw new Error(
|
|
170
|
-
"clerkAuth: `allowedDomains` is empty. List at least one domain, or " +
|
|
171
|
-
"drop the option to admit every authenticated user.",
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
const domains = new Set<string>();
|
|
175
|
-
for (const entry of value) {
|
|
176
|
-
if (typeof entry !== "string") {
|
|
177
|
-
throw new Error(
|
|
178
|
-
`clerkAuth: \`allowedDomains\` entry ${JSON.stringify(entry)} is not a string.`,
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
// Surrounding whitespace is the one thing forgiven, and only here: this is
|
|
182
|
-
// operator config read at construction, where a stray space is a typo the
|
|
183
|
-
// operator can see in the throw. Nothing is forgiven on the email side.
|
|
184
|
-
const domain = entry.trim();
|
|
185
|
-
if (!isDomain(domain)) {
|
|
186
|
-
const hint = domain.includes("@")
|
|
187
|
-
? " Write the domain alone, with no `@` and no local part."
|
|
188
|
-
: "";
|
|
189
|
-
throw new Error(
|
|
190
|
-
`clerkAuth: \`allowedDomains\` entry ${JSON.stringify(entry)} is not a ` +
|
|
191
|
-
`domain (expected something like "acme.com").${hint}`,
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
domains.add(domain.toLowerCase());
|
|
195
|
-
}
|
|
196
|
-
return domains;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Bounded, escaped form of the denied domain for the operator log — the same
|
|
201
|
-
* treatment server logs give other caller-controlled values. An email domain is
|
|
202
|
-
* caller-influenced (anyone who controls a mailbox controls its domain): the
|
|
203
|
-
* bound is what a 253-byte domain needs, and the escaping — JSON.stringify plus
|
|
204
|
-
* the hand-rolled U+2028/U+2029 pass it leaves raw — is defense in depth behind
|
|
205
|
-
* `isDomain`, which has already ruled out the newline that would forge a line.
|
|
206
|
-
*/
|
|
207
|
-
function loggableDomain(domain: string): string {
|
|
208
|
-
const bounded = domain.slice(0, 100);
|
|
209
|
-
const escaped = JSON.stringify(bounded).replace(
|
|
210
|
-
/[\u2028\u2029]/g,
|
|
211
|
-
(ch) => `\\u${ch.charCodeAt(0).toString(16)}`,
|
|
212
|
-
);
|
|
213
|
-
return escaped + (bounded.length < domain.length ? " (truncated)" : "");
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* The domain of an email address, lowercased for comparison, or null when the
|
|
218
|
-
* address does not have exactly one well-formed domain to read.
|
|
219
|
-
*
|
|
220
|
-
* Nothing here repairs the input. The domain is validated as it arrived and
|
|
221
|
-
* only then lowercased, so `dev@ acme.com`, `dev@acme.com\n` and `dev@acme.com.`
|
|
222
|
-
* are malformed addresses that DENY, rather than whitespace-trimmed or
|
|
223
|
-
* dot-stripped into a match for `acme.com`. The split is on the last `@`, the
|
|
224
|
-
* part a mail system routes on, so this reads the same domain that would
|
|
225
|
-
* receive the mail. (Under exact set matching a first-`@` split could not fail
|
|
226
|
-
* open either — it would just read a domain nobody delivers to.)
|
|
227
|
-
*/
|
|
228
|
-
function emailDomain(email: string): string | null {
|
|
229
|
-
const at = email.lastIndexOf("@");
|
|
230
|
-
if (at <= 0 || at === email.length - 1) return null;
|
|
231
|
-
const domain = email.slice(at + 1);
|
|
232
|
-
return isDomain(domain) ? domain.toLowerCase() : null;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Clerk inbound auth.
|
|
237
|
-
*
|
|
238
|
-
* `allowedDomains` and `gate` decide who is admitted; both must pass.
|
|
239
|
-
*/
|
|
240
|
-
export function clerkAuth(opts: ClerkAuthOptions): InboundAuth {
|
|
241
|
-
assertNoRetiredToolkitOptions("clerkAuth", opts);
|
|
242
|
-
const clerk = createClerkClient({
|
|
243
|
-
secretKey: opts.secretKey,
|
|
244
|
-
publishableKey: opts.publishableKey,
|
|
245
|
-
});
|
|
246
|
-
const allowedDomains = normalizeAllowedDomains(opts.allowedDomains);
|
|
247
|
-
const scopes = opts.scopes ?? ["openid", "profile", "email"];
|
|
248
|
-
const gateCache = new Map<string, { allowed: boolean; exp: number }>();
|
|
249
|
-
const activityLabelCache = new Map<
|
|
250
|
-
string,
|
|
251
|
-
{ label?: string; exp: number }
|
|
252
|
-
>();
|
|
253
|
-
const pendingActivityLabels = new Map<
|
|
254
|
-
string,
|
|
255
|
-
Promise<string | undefined>
|
|
256
|
-
>();
|
|
257
|
-
const inFlightActivityLabelIds = new Set<string>();
|
|
258
|
-
let activeActivityLabelLookups = 0;
|
|
259
|
-
|
|
260
|
-
const resolveBase = (baseUrl: string) => opts.publicUrl ?? baseUrl;
|
|
261
|
-
|
|
262
|
-
const cacheActivityLabel = (
|
|
263
|
-
userId: string,
|
|
264
|
-
label: string | undefined,
|
|
265
|
-
): void => {
|
|
266
|
-
writeIdentityCache(activityLabelCache, userId, {
|
|
267
|
-
...(label ? { label } : {}),
|
|
268
|
-
exp:
|
|
269
|
-
Date.now() +
|
|
270
|
-
(label ? ACTIVITY_LABEL_TTL_MS : ACTIVITY_LABEL_MISS_TTL_MS),
|
|
271
|
-
});
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
const resolveActivityLabel = async (
|
|
275
|
-
userId: string,
|
|
276
|
-
): Promise<string | undefined> => {
|
|
277
|
-
const cached = readIdentityCache(activityLabelCache, userId);
|
|
278
|
-
if (cached) return cached.label;
|
|
279
|
-
const existing = pendingActivityLabels.get(userId);
|
|
280
|
-
if (existing) return existing;
|
|
281
|
-
// The Clerk SDK's getUser call has no AbortSignal. Keep the real upstream
|
|
282
|
-
// concurrency bounded even when requests hang forever: do not queue more
|
|
283
|
-
// identities in memory, and do not start a duplicate for an id whose raw
|
|
284
|
-
// lookup outlived its caller-facing deadline.
|
|
285
|
-
if (
|
|
286
|
-
inFlightActivityLabelIds.has(userId) ||
|
|
287
|
-
activeActivityLabelLookups >= ACTIVITY_LABEL_MAX_IN_FLIGHT
|
|
288
|
-
) {
|
|
289
|
-
cacheActivityLabel(userId, undefined);
|
|
290
|
-
return undefined;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
activeActivityLabelLookups++;
|
|
294
|
-
inFlightActivityLabelIds.add(userId);
|
|
295
|
-
const upstream = clerk.users
|
|
296
|
-
.getUser(userId)
|
|
297
|
-
.then((user) => {
|
|
298
|
-
const label = activityLabelForUser(user);
|
|
299
|
-
cacheActivityLabel(userId, label);
|
|
300
|
-
return label;
|
|
301
|
-
})
|
|
302
|
-
.catch(() => {
|
|
303
|
-
cacheActivityLabel(userId, undefined);
|
|
304
|
-
return undefined;
|
|
305
|
-
})
|
|
306
|
-
.finally(() => {
|
|
307
|
-
activeActivityLabelLookups--;
|
|
308
|
-
inFlightActivityLabelIds.delete(userId);
|
|
309
|
-
});
|
|
310
|
-
const lookup = new Promise<string | undefined>((resolve) => {
|
|
311
|
-
let settled = false;
|
|
312
|
-
const finish = (label: string | undefined) => {
|
|
313
|
-
if (settled) return;
|
|
314
|
-
settled = true;
|
|
315
|
-
clearTimeout(timer);
|
|
316
|
-
resolve(label);
|
|
317
|
-
};
|
|
318
|
-
const timer = setTimeout(() => {
|
|
319
|
-
cacheActivityLabel(userId, undefined);
|
|
320
|
-
finish(undefined);
|
|
321
|
-
}, ACTIVITY_LABEL_LOOKUP_TIMEOUT_MS);
|
|
322
|
-
void upstream.then(finish);
|
|
323
|
-
}).finally(() => {
|
|
324
|
-
pendingActivityLabels.delete(userId);
|
|
325
|
-
});
|
|
326
|
-
pendingActivityLabels.set(userId, lookup);
|
|
327
|
-
return lookup;
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
const unauthorized = (baseUrl: string, tokenPresent: boolean): Response => {
|
|
331
|
-
const error = tokenPresent ? `error="invalid_token", ` : "";
|
|
332
|
-
const meta = `${resolveBase(baseUrl)}/.well-known/oauth-protected-resource`;
|
|
333
|
-
return new Response(
|
|
334
|
-
JSON.stringify({ error: "unauthorized" }),
|
|
335
|
-
{
|
|
336
|
-
status: 401,
|
|
337
|
-
headers: {
|
|
338
|
-
"Content-Type": "application/json",
|
|
339
|
-
"WWW-Authenticate": `Bearer ${error}resource_metadata="${meta}"`,
|
|
340
|
-
},
|
|
341
|
-
},
|
|
342
|
-
);
|
|
343
|
-
};
|
|
344
|
-
|
|
345
|
-
const forbidden = (): Response =>
|
|
346
|
-
new Response(
|
|
347
|
-
JSON.stringify({ error: "forbidden" }),
|
|
348
|
-
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
349
|
-
);
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* The domain half of admission. Fails CLOSED on every uncertainty — no
|
|
353
|
-
* primary email, an unverified one, a malformed address, or the lookup
|
|
354
|
-
* itself failing — because "we could not tell" and "they belong here" must
|
|
355
|
-
* not be the same answer for a membership rule.
|
|
356
|
-
*/
|
|
357
|
-
const checkDomain = async (userId: string): Promise<boolean> => {
|
|
358
|
-
if (!allowedDomains) return true;
|
|
359
|
-
let email: string | undefined;
|
|
360
|
-
try {
|
|
361
|
-
const user = await clerk.users.getUser(userId);
|
|
362
|
-
const primary = user.emailAddresses?.find(
|
|
363
|
-
(address) => address.id === user.primaryEmailAddressId,
|
|
364
|
-
);
|
|
365
|
-
if (primary?.verification?.status === "verified") {
|
|
366
|
-
email = primary.emailAddress;
|
|
367
|
-
}
|
|
368
|
-
} catch (error) {
|
|
369
|
-
console.warn(
|
|
370
|
-
`[connecta] clerk email lookup failed for ${userId}: ${
|
|
371
|
-
error instanceof Error ? error.message : String(error)
|
|
372
|
-
} — denying`,
|
|
373
|
-
);
|
|
374
|
-
return false;
|
|
375
|
-
}
|
|
376
|
-
const domain = email ? emailDomain(email) : null;
|
|
377
|
-
if (!domain) {
|
|
378
|
-
// One line for three cases (no primary email, unverified, or an address
|
|
379
|
-
// with no readable domain) because the caller must not be able to tell
|
|
380
|
-
// them apart — but it must not claim the email is missing when it is
|
|
381
|
-
// there and malformed.
|
|
382
|
-
console.warn(
|
|
383
|
-
`[connecta] clerk user ${userId} has no verified primary email with a ` +
|
|
384
|
-
"well-formed domain — denying",
|
|
385
|
-
);
|
|
386
|
-
return false;
|
|
387
|
-
}
|
|
388
|
-
if (!allowedDomains.has(domain)) {
|
|
389
|
-
// The domain, never the address: this is an operator log, not a place to
|
|
390
|
-
// spill the local part of someone's email on every denied request.
|
|
391
|
-
console.warn(
|
|
392
|
-
`[connecta] clerk user ${userId} denied: email domain ` +
|
|
393
|
-
`${loggableDomain(domain)} is not on allowedDomains`,
|
|
394
|
-
);
|
|
395
|
-
return false;
|
|
396
|
-
}
|
|
397
|
-
return true;
|
|
398
|
-
};
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* Is this authenticated user admitted? The domain allowlist and `gate` both
|
|
402
|
-
* have to say yes, and the allowlist runs first so an outsider never reaches
|
|
403
|
-
* operator gate code. One cached verdict covers both, so composing them costs
|
|
404
|
-
* no more Clerk calls than `gate` alone did.
|
|
405
|
-
*/
|
|
406
|
-
const checkGate = async (userId: string): Promise<boolean> => {
|
|
407
|
-
if (!opts.gate && !allowedDomains) return true;
|
|
408
|
-
const hit = readIdentityCache(gateCache, userId);
|
|
409
|
-
if (hit) return hit.allowed;
|
|
410
|
-
let allowed = false;
|
|
411
|
-
try {
|
|
412
|
-
allowed =
|
|
413
|
-
(await checkDomain(userId)) &&
|
|
414
|
-
(opts.gate ? await opts.gate(userId, clerk) : true);
|
|
415
|
-
} catch {
|
|
416
|
-
allowed = false;
|
|
417
|
-
}
|
|
418
|
-
writeIdentityCache(gateCache, userId, {
|
|
419
|
-
allowed,
|
|
420
|
-
exp:
|
|
421
|
-
Date.now() +
|
|
422
|
-
(allowed ? GATE_ALLOWED_TTL_MS : GATE_FORBIDDEN_TTL_MS),
|
|
423
|
-
});
|
|
424
|
-
return allowed;
|
|
425
|
-
};
|
|
426
|
-
|
|
427
|
-
return {
|
|
428
|
-
kind: "clerk",
|
|
429
|
-
activityActorNamespace: fapiUrl(opts.publishableKey),
|
|
430
|
-
activityActorLabel: resolveActivityLabel,
|
|
431
|
-
uiAuth: {
|
|
432
|
-
kind: "clerk",
|
|
433
|
-
publishableKey: opts.publishableKey,
|
|
434
|
-
frontendApiUrl: fapiUrl(opts.publishableKey),
|
|
435
|
-
...(opts.signInUrl ? { signInUrl: opts.signInUrl } : {}),
|
|
436
|
-
...(opts.signUpUrl ? { signUpUrl: opts.signUpUrl } : {}),
|
|
437
|
-
},
|
|
438
|
-
|
|
439
|
-
async handleMetadata(request, baseUrl) {
|
|
440
|
-
const { pathname } = new URL(request.url);
|
|
441
|
-
if (!pathname.startsWith("/.well-known/")) return null;
|
|
442
|
-
|
|
443
|
-
if (request.method === "OPTIONS") {
|
|
444
|
-
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
const base = resolveBase(baseUrl);
|
|
448
|
-
if (
|
|
449
|
-
pathname === "/.well-known/oauth-protected-resource" ||
|
|
450
|
-
pathname === "/.well-known/oauth-protected-resource/mcp"
|
|
451
|
-
) {
|
|
452
|
-
return Response.json(
|
|
453
|
-
{
|
|
454
|
-
resource: `${base}/mcp`,
|
|
455
|
-
authorization_servers: [fapiUrl(opts.publishableKey)],
|
|
456
|
-
bearer_methods_supported: ["header"],
|
|
457
|
-
scopes_supported: scopes,
|
|
458
|
-
},
|
|
459
|
-
{ headers: CORS_HEADERS },
|
|
460
|
-
);
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
if (pathname === "/.well-known/oauth-authorization-server") {
|
|
464
|
-
try {
|
|
465
|
-
const upstream = await fetch(
|
|
466
|
-
`${fapiUrl(opts.publishableKey)}/.well-known/oauth-authorization-server`,
|
|
467
|
-
);
|
|
468
|
-
if (!upstream.ok) {
|
|
469
|
-
return Response.json(
|
|
470
|
-
{ error: "upstream authorization server metadata unavailable" },
|
|
471
|
-
{ status: 502, headers: CORS_HEADERS },
|
|
472
|
-
);
|
|
473
|
-
}
|
|
474
|
-
return Response.json(await upstream.json(), { headers: CORS_HEADERS });
|
|
475
|
-
} catch {
|
|
476
|
-
return Response.json(
|
|
477
|
-
{ error: "upstream authorization server metadata unavailable" },
|
|
478
|
-
{ status: 502, headers: CORS_HEADERS },
|
|
479
|
-
);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
return null;
|
|
484
|
-
},
|
|
485
|
-
|
|
486
|
-
async authorize(request, baseUrl): Promise<AuthResult> {
|
|
487
|
-
const tokenPresent = Boolean(request.headers.get("authorization"));
|
|
488
|
-
let userId: string | undefined;
|
|
489
|
-
try {
|
|
490
|
-
const state = await clerk.authenticateRequest(request, {
|
|
491
|
-
// MCP clients use Clerk OAuth access tokens; the browser operator UI
|
|
492
|
-
// uses the signed-in operator's short-lived Clerk session token.
|
|
493
|
-
// authorizedParties must NOT be passed here: OAuth access tokens may
|
|
494
|
-
// be JWTs without an azp claim, and Clerk rejects azp=undefined when
|
|
495
|
-
// that option is set. The sibling-subdomain pin it provided is
|
|
496
|
-
// enforced below, only for session tokens that actually carry azp.
|
|
497
|
-
acceptsToken: ["oauth_token", "session_token"],
|
|
498
|
-
});
|
|
499
|
-
const auth = state.toAuth();
|
|
500
|
-
if (!auth?.isAuthenticated) {
|
|
501
|
-
// Reason (not the token) in the logs: bearer rejections are
|
|
502
|
-
// otherwise indistinguishable 401s in `wrangler tail`.
|
|
503
|
-
const detail = state as { reason?: string; message?: string };
|
|
504
|
-
console.warn(
|
|
505
|
-
`[connecta] clerk rejected request: status=${state.status}` +
|
|
506
|
-
` reason=${detail.reason ?? "?"} message=${detail.message ?? ""}` +
|
|
507
|
-
` tokenShape=${tokenShape(request)}`,
|
|
508
|
-
);
|
|
509
|
-
return {
|
|
510
|
-
ok: false,
|
|
511
|
-
response: unauthorized(baseUrl, tokenPresent),
|
|
512
|
-
};
|
|
513
|
-
}
|
|
514
|
-
// Session JWTs carry `azp` (the origin they were minted for); pin it
|
|
515
|
-
// to this connecta deployment so a sibling subdomain's cookie/token
|
|
516
|
-
// cannot be replayed here. OAuth access tokens may have no azp.
|
|
517
|
-
const typed = auth as {
|
|
518
|
-
tokenType?: string;
|
|
519
|
-
sessionClaims?: { azp?: string } | null;
|
|
520
|
-
userId?: string | null;
|
|
521
|
-
};
|
|
522
|
-
if (typed.tokenType === "session_token") {
|
|
523
|
-
const azp = typed.sessionClaims?.azp;
|
|
524
|
-
const origin = new URL(resolveBase(baseUrl)).origin;
|
|
525
|
-
if (azp && azp !== origin) {
|
|
526
|
-
console.warn(
|
|
527
|
-
`[connecta] session token azp mismatch: azp=${azp} expected=${origin}`,
|
|
528
|
-
);
|
|
529
|
-
return {
|
|
530
|
-
ok: false,
|
|
531
|
-
response: unauthorized(baseUrl, tokenPresent),
|
|
532
|
-
};
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
userId = typed.userId ?? undefined;
|
|
536
|
-
} catch (error) {
|
|
537
|
-
console.warn(
|
|
538
|
-
`[connecta] clerk authenticateRequest threw: ${
|
|
539
|
-
error instanceof Error ? error.message : String(error)
|
|
540
|
-
} tokenShape=${tokenShape(request)}`,
|
|
541
|
-
);
|
|
542
|
-
return { ok: false, response: unauthorized(baseUrl, true) };
|
|
543
|
-
}
|
|
544
|
-
if (!userId) {
|
|
545
|
-
return { ok: false, response: unauthorized(baseUrl, true) };
|
|
546
|
-
}
|
|
547
|
-
if (!(await checkGate(userId))) {
|
|
548
|
-
return { ok: false, response: forbidden() };
|
|
549
|
-
}
|
|
550
|
-
return { ok: true, userId };
|
|
551
|
-
},
|
|
552
|
-
};
|
|
553
|
-
}
|