@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.
Files changed (335) hide show
  1. package/AGENTS.md +40 -20
  2. package/CHANGELOG.md +653 -0
  3. package/README.md +25 -5
  4. package/bin/connecta.mjs +44 -3
  5. package/dist/access-tokens.d.ts +0 -1
  6. package/dist/access-tokens.js +0 -1
  7. package/dist/activity.d.ts +50 -2
  8. package/dist/activity.js +34 -1
  9. package/dist/apps-shell.d.ts +0 -1
  10. package/dist/apps-shell.js +0 -1
  11. package/dist/auth/bearer.d.ts +0 -1
  12. package/dist/auth/bearer.js +0 -1
  13. package/dist/auth/clerk.d.ts +0 -1
  14. package/dist/auth/clerk.js +45 -9
  15. package/dist/auth/downstream-oauth.d.ts +0 -1
  16. package/dist/auth/downstream-oauth.js +0 -1
  17. package/dist/call-admission.d.ts +0 -1
  18. package/dist/call-admission.js +0 -1
  19. package/dist/catalog-drift.d.ts +99 -0
  20. package/dist/catalog-drift.js +251 -0
  21. package/dist/catalog-fingerprint.d.ts +0 -1
  22. package/dist/catalog-fingerprint.js +0 -1
  23. package/dist/catalog-limits.d.ts +0 -1
  24. package/dist/catalog-limits.js +0 -1
  25. package/dist/catalog-service.d.ts +0 -1
  26. package/dist/catalog-service.js +41 -5
  27. package/dist/catalog.d.ts +11 -1
  28. package/dist/catalog.js +20 -1
  29. package/dist/concurrency.d.ts +0 -1
  30. package/dist/concurrency.js +0 -1
  31. package/dist/connector-scope.d.ts +0 -1
  32. package/dist/connector-scope.js +0 -1
  33. package/dist/connectors/api.d.ts +29 -21
  34. package/dist/connectors/api.js +45 -19
  35. package/dist/connectors/guarded-fetch.d.ts +106 -0
  36. package/dist/connectors/guarded-fetch.js +289 -0
  37. package/dist/connectors/remote-mcp.d.ts +0 -1
  38. package/dist/connectors/remote-mcp.js +0 -1
  39. package/dist/credentials.d.ts +0 -1
  40. package/dist/credentials.js +0 -1
  41. package/dist/errors.d.ts +30 -3
  42. package/dist/errors.js +1 -1
  43. package/dist/execute.d.ts +0 -1
  44. package/dist/execute.js +0 -1
  45. package/dist/executor-admission.d.ts +11 -1
  46. package/dist/executor-admission.js +28 -1
  47. package/dist/executor-result.d.ts +0 -1
  48. package/dist/executor-result.js +0 -1
  49. package/dist/executors/quickjs-child.d.ts +0 -1
  50. package/dist/executors/quickjs-child.js +0 -1
  51. package/dist/executors/quickjs-protocol.d.ts +0 -1
  52. package/dist/executors/quickjs-protocol.js +0 -1
  53. package/dist/executors/quickjs-runtime.d.ts +0 -1
  54. package/dist/executors/quickjs-runtime.js +0 -1
  55. package/dist/executors/quickjs.d.ts +0 -1
  56. package/dist/executors/quickjs.js +2 -1
  57. package/dist/favicon.d.ts +0 -1
  58. package/dist/favicon.js +0 -1
  59. package/dist/index.d.ts +2 -3
  60. package/dist/index.js +24 -7
  61. package/dist/invocation.d.ts +0 -1
  62. package/dist/invocation.js +0 -1
  63. package/dist/json-schema.d.ts +0 -1
  64. package/dist/json-schema.js +0 -1
  65. package/dist/mcp-result.d.ts +0 -1
  66. package/dist/mcp-result.js +0 -1
  67. package/dist/meta-tools.d.ts +0 -1
  68. package/dist/meta-tools.js +0 -1
  69. package/dist/node.d.ts +0 -1
  70. package/dist/node.js +0 -1
  71. package/dist/operator-ui/generated.d.ts +2 -3
  72. package/dist/operator-ui/generated.js +3 -4
  73. package/dist/operator-ui/model.d.ts +10 -1
  74. package/dist/operator-ui/model.js +0 -1
  75. package/dist/operator-ui/view.d.ts +167 -0
  76. package/dist/operator-ui/view.js +270 -0
  77. package/dist/providers/cloudflare.d.ts +0 -1
  78. package/dist/providers/cloudflare.js +261 -299
  79. package/dist/providers/linear.d.ts +26 -5
  80. package/dist/providers/linear.js +32 -61
  81. package/dist/providers/mixpanel.d.ts +35 -4
  82. package/dist/providers/mixpanel.js +53 -79
  83. package/dist/providers/notion.d.ts +0 -1
  84. package/dist/providers/notion.js +87 -42
  85. package/dist/providers/stripe.d.ts +13 -1
  86. package/dist/providers/stripe.js +31 -60
  87. package/dist/registry.d.ts +34 -2
  88. package/dist/registry.js +85 -5
  89. package/dist/retired-toolkits.d.ts +0 -1
  90. package/dist/retired-toolkits.js +0 -1
  91. package/dist/routes/access-tokens.d.ts +0 -1
  92. package/dist/routes/access-tokens.js +0 -1
  93. package/dist/routes/activity.d.ts +0 -1
  94. package/dist/routes/activity.js +0 -1
  95. package/dist/routes/credentials.d.ts +0 -1
  96. package/dist/routes/credentials.js +0 -1
  97. package/dist/routes/mcp.d.ts +0 -1
  98. package/dist/routes/mcp.js +0 -1
  99. package/dist/routes/oauth.d.ts +0 -1
  100. package/dist/routes/oauth.js +0 -1
  101. package/dist/routes/shared.d.ts +2 -1
  102. package/dist/routes/shared.js +0 -1
  103. package/dist/routes/ui.d.ts +0 -1
  104. package/dist/routes/ui.js +0 -1
  105. package/dist/server.d.ts +0 -1
  106. package/dist/server.js +11 -1
  107. package/dist/skills.d.ts +0 -1
  108. package/dist/skills.js +0 -1
  109. package/dist/storage/file.d.ts +0 -1
  110. package/dist/storage/file.js +0 -1
  111. package/dist/storage/memory.d.ts +0 -1
  112. package/dist/storage/memory.js +0 -1
  113. package/dist/timeout.d.ts +0 -1
  114. package/dist/timeout.js +0 -1
  115. package/dist/tool-safety.d.ts +0 -1
  116. package/dist/tool-safety.js +0 -1
  117. package/dist/types.d.ts +45 -1
  118. package/dist/types.js +0 -1
  119. package/dist/ui.d.ts +0 -1
  120. package/dist/ui.js +13 -137
  121. package/dist/validate.d.ts +13 -15
  122. package/dist/validate.js +24 -11
  123. package/dist/version.d.ts +1 -2
  124. package/dist/version.js +1 -2
  125. package/documentation/architecture.md +186 -5
  126. package/documentation/auth.md +11 -0
  127. package/documentation/call-admission.md +142 -6
  128. package/documentation/cloudflare.md +92 -10
  129. package/documentation/code-first-exploration.md +2 -2
  130. package/documentation/code-mode.md +14 -14
  131. package/documentation/connector-guides.md +172 -5
  132. package/documentation/connectors.md +89 -0
  133. package/documentation/linear.md +20 -2
  134. package/documentation/mcp-ui-design.md +2 -2
  135. package/documentation/meta-tools.md +7 -1
  136. package/documentation/mixpanel.md +42 -9
  137. package/documentation/notion.md +23 -2
  138. package/documentation/operations.md +325 -5
  139. package/documentation/operator-ui.md +120 -5
  140. package/documentation/provider-audit.md +160 -0
  141. package/documentation/provider-conventions.md +667 -0
  142. package/documentation/request-admission.md +167 -5
  143. package/documentation/rich-output-design.md +1 -2
  144. package/documentation/stripe.md +15 -0
  145. package/documentation/upgrading.md +454 -0
  146. package/ethos.md +14 -9
  147. package/examples/worker/README.md +102 -12
  148. package/examples/worker/src/index.ts +46 -1
  149. package/examples/worker/wrangler.jsonc +14 -1
  150. package/package.json +10 -6
  151. package/templates/node/.dockerignore +10 -0
  152. package/templates/node/.env.example +40 -3
  153. package/templates/node/AGENTS.md +16 -1
  154. package/templates/node/Dockerfile +49 -0
  155. package/templates/node/README.md +117 -2
  156. package/templates/node/docker-compose.yml +39 -0
  157. package/templates/node/package.json +1 -1
  158. package/templates/node/src/file-activity.ts +182 -0
  159. package/templates/node/src/index.ts +80 -4
  160. package/assets/connecta-clay-hero.png +0 -0
  161. package/dist/access-tokens.d.ts.map +0 -1
  162. package/dist/access-tokens.js.map +0 -1
  163. package/dist/activity.d.ts.map +0 -1
  164. package/dist/activity.js.map +0 -1
  165. package/dist/apps-shell.d.ts.map +0 -1
  166. package/dist/apps-shell.js.map +0 -1
  167. package/dist/auth/bearer.d.ts.map +0 -1
  168. package/dist/auth/bearer.js.map +0 -1
  169. package/dist/auth/clerk.d.ts.map +0 -1
  170. package/dist/auth/clerk.js.map +0 -1
  171. package/dist/auth/downstream-oauth.d.ts.map +0 -1
  172. package/dist/auth/downstream-oauth.js.map +0 -1
  173. package/dist/call-admission.d.ts.map +0 -1
  174. package/dist/call-admission.js.map +0 -1
  175. package/dist/catalog-fingerprint.d.ts.map +0 -1
  176. package/dist/catalog-fingerprint.js.map +0 -1
  177. package/dist/catalog-limits.d.ts.map +0 -1
  178. package/dist/catalog-limits.js.map +0 -1
  179. package/dist/catalog-service.d.ts.map +0 -1
  180. package/dist/catalog-service.js.map +0 -1
  181. package/dist/catalog.d.ts.map +0 -1
  182. package/dist/catalog.js.map +0 -1
  183. package/dist/concurrency.d.ts.map +0 -1
  184. package/dist/concurrency.js.map +0 -1
  185. package/dist/connector-scope.d.ts.map +0 -1
  186. package/dist/connector-scope.js.map +0 -1
  187. package/dist/connectors/api.d.ts.map +0 -1
  188. package/dist/connectors/api.js.map +0 -1
  189. package/dist/connectors/remote-mcp.d.ts.map +0 -1
  190. package/dist/connectors/remote-mcp.js.map +0 -1
  191. package/dist/credentials.d.ts.map +0 -1
  192. package/dist/credentials.js.map +0 -1
  193. package/dist/errors.d.ts.map +0 -1
  194. package/dist/errors.js.map +0 -1
  195. package/dist/execute.d.ts.map +0 -1
  196. package/dist/execute.js.map +0 -1
  197. package/dist/executor-admission.d.ts.map +0 -1
  198. package/dist/executor-admission.js.map +0 -1
  199. package/dist/executor-result.d.ts.map +0 -1
  200. package/dist/executor-result.js.map +0 -1
  201. package/dist/executors/quickjs-child.d.ts.map +0 -1
  202. package/dist/executors/quickjs-child.js.map +0 -1
  203. package/dist/executors/quickjs-protocol.d.ts.map +0 -1
  204. package/dist/executors/quickjs-protocol.js.map +0 -1
  205. package/dist/executors/quickjs-runtime.d.ts.map +0 -1
  206. package/dist/executors/quickjs-runtime.js.map +0 -1
  207. package/dist/executors/quickjs.d.ts.map +0 -1
  208. package/dist/executors/quickjs.js.map +0 -1
  209. package/dist/favicon.d.ts.map +0 -1
  210. package/dist/favicon.js.map +0 -1
  211. package/dist/index.d.ts.map +0 -1
  212. package/dist/index.js.map +0 -1
  213. package/dist/invocation.d.ts.map +0 -1
  214. package/dist/invocation.js.map +0 -1
  215. package/dist/json-schema.d.ts.map +0 -1
  216. package/dist/json-schema.js.map +0 -1
  217. package/dist/mcp-result.d.ts.map +0 -1
  218. package/dist/mcp-result.js.map +0 -1
  219. package/dist/meta-tools.d.ts.map +0 -1
  220. package/dist/meta-tools.js.map +0 -1
  221. package/dist/node.d.ts.map +0 -1
  222. package/dist/node.js.map +0 -1
  223. package/dist/operator-ui/generated.d.ts.map +0 -1
  224. package/dist/operator-ui/generated.js.map +0 -1
  225. package/dist/operator-ui/model.d.ts.map +0 -1
  226. package/dist/operator-ui/model.js.map +0 -1
  227. package/dist/providers/cloudflare.d.ts.map +0 -1
  228. package/dist/providers/cloudflare.js.map +0 -1
  229. package/dist/providers/linear.d.ts.map +0 -1
  230. package/dist/providers/linear.js.map +0 -1
  231. package/dist/providers/mixpanel.d.ts.map +0 -1
  232. package/dist/providers/mixpanel.js.map +0 -1
  233. package/dist/providers/notion.d.ts.map +0 -1
  234. package/dist/providers/notion.js.map +0 -1
  235. package/dist/providers/stripe.d.ts.map +0 -1
  236. package/dist/providers/stripe.js.map +0 -1
  237. package/dist/registry.d.ts.map +0 -1
  238. package/dist/registry.js.map +0 -1
  239. package/dist/retired-toolkits.d.ts.map +0 -1
  240. package/dist/retired-toolkits.js.map +0 -1
  241. package/dist/routes/access-tokens.d.ts.map +0 -1
  242. package/dist/routes/access-tokens.js.map +0 -1
  243. package/dist/routes/activity.d.ts.map +0 -1
  244. package/dist/routes/activity.js.map +0 -1
  245. package/dist/routes/credentials.d.ts.map +0 -1
  246. package/dist/routes/credentials.js.map +0 -1
  247. package/dist/routes/mcp.d.ts.map +0 -1
  248. package/dist/routes/mcp.js.map +0 -1
  249. package/dist/routes/oauth.d.ts.map +0 -1
  250. package/dist/routes/oauth.js.map +0 -1
  251. package/dist/routes/shared.d.ts.map +0 -1
  252. package/dist/routes/shared.js.map +0 -1
  253. package/dist/routes/ui.d.ts.map +0 -1
  254. package/dist/routes/ui.js.map +0 -1
  255. package/dist/server.d.ts.map +0 -1
  256. package/dist/server.js.map +0 -1
  257. package/dist/skills.d.ts.map +0 -1
  258. package/dist/skills.js.map +0 -1
  259. package/dist/storage/file.d.ts.map +0 -1
  260. package/dist/storage/file.js.map +0 -1
  261. package/dist/storage/memory.d.ts.map +0 -1
  262. package/dist/storage/memory.js.map +0 -1
  263. package/dist/timeout.d.ts.map +0 -1
  264. package/dist/timeout.js.map +0 -1
  265. package/dist/tool-safety.d.ts.map +0 -1
  266. package/dist/tool-safety.js.map +0 -1
  267. package/dist/types.d.ts.map +0 -1
  268. package/dist/types.js.map +0 -1
  269. package/dist/ui.d.ts.map +0 -1
  270. package/dist/ui.js.map +0 -1
  271. package/dist/validate.d.ts.map +0 -1
  272. package/dist/validate.js.map +0 -1
  273. package/dist/version.d.ts.map +0 -1
  274. package/dist/version.js.map +0 -1
  275. package/examples/node/README.md +0 -52
  276. package/examples/node/src/index.ts +0 -71
  277. package/src/access-tokens.ts +0 -289
  278. package/src/activity.ts +0 -234
  279. package/src/apps-shell.ts +0 -370
  280. package/src/auth/bearer.ts +0 -61
  281. package/src/auth/clerk.ts +0 -553
  282. package/src/auth/downstream-oauth.ts +0 -575
  283. package/src/call-admission.ts +0 -521
  284. package/src/catalog-fingerprint.ts +0 -52
  285. package/src/catalog-limits.ts +0 -11
  286. package/src/catalog-service.ts +0 -1153
  287. package/src/catalog.ts +0 -790
  288. package/src/concurrency.ts +0 -39
  289. package/src/connector-scope.ts +0 -66
  290. package/src/connectors/api.ts +0 -159
  291. package/src/connectors/remote-mcp.ts +0 -1090
  292. package/src/credentials.ts +0 -428
  293. package/src/errors.ts +0 -364
  294. package/src/execute.ts +0 -1406
  295. package/src/executor-admission.ts +0 -328
  296. package/src/executor-result.ts +0 -107
  297. package/src/executors/quickjs-child.ts +0 -172
  298. package/src/executors/quickjs-protocol.ts +0 -99
  299. package/src/executors/quickjs-runtime.ts +0 -470
  300. package/src/executors/quickjs.ts +0 -776
  301. package/src/favicon.ts +0 -53
  302. package/src/index.ts +0 -666
  303. package/src/invocation.ts +0 -655
  304. package/src/json-schema.ts +0 -11
  305. package/src/mcp-result.ts +0 -43
  306. package/src/meta-tools.ts +0 -1607
  307. package/src/node.ts +0 -206
  308. package/src/operator-ui/browser.css +0 -423
  309. package/src/operator-ui/browser.ts +0 -1281
  310. package/src/operator-ui/generated.ts +0 -4
  311. package/src/operator-ui/model.ts +0 -111
  312. package/src/providers/cloudflare.ts +0 -3999
  313. package/src/providers/linear.ts +0 -302
  314. package/src/providers/mixpanel.ts +0 -229
  315. package/src/providers/notion.ts +0 -1879
  316. package/src/providers/stripe.ts +0 -307
  317. package/src/registry.ts +0 -1085
  318. package/src/retired-toolkits.ts +0 -14
  319. package/src/routes/access-tokens.ts +0 -115
  320. package/src/routes/activity.ts +0 -215
  321. package/src/routes/credentials.ts +0 -254
  322. package/src/routes/mcp.ts +0 -429
  323. package/src/routes/oauth.ts +0 -322
  324. package/src/routes/shared.ts +0 -247
  325. package/src/routes/ui.ts +0 -152
  326. package/src/server.ts +0 -195
  327. package/src/skills.ts +0 -271
  328. package/src/storage/file.ts +0 -123
  329. package/src/storage/memory.ts +0 -44
  330. package/src/timeout.ts +0 -84
  331. package/src/tool-safety.ts +0 -15
  332. package/src/types.ts +0 -523
  333. package/src/ui.ts +0 -744
  334. package/src/validate.ts +0 -387
  335. 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
- }