@zackbart/connecta 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (332) hide show
  1. package/AGENTS.md +28 -15
  2. package/CHANGELOG.md +493 -0
  3. package/README.md +19 -4
  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 +0 -1
  27. package/dist/catalog.d.ts +0 -1
  28. package/dist/catalog.js +0 -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 +0 -1
  42. package/dist/errors.js +0 -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 +5 -2
  78. package/dist/providers/cloudflare.js +435 -328
  79. package/dist/providers/linear.d.ts +26 -5
  80. package/dist/providers/linear.js +32 -60
  81. package/dist/providers/mixpanel.d.ts +35 -4
  82. package/dist/providers/mixpanel.js +53 -78
  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 -59
  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 +95 -27
  129. package/documentation/code-mode.md +12 -12
  130. package/documentation/connector-guides.md +172 -5
  131. package/documentation/connectors.md +98 -7
  132. package/documentation/linear.md +26 -9
  133. package/documentation/mcp-ui-design.md +2 -2
  134. package/documentation/mixpanel.md +48 -16
  135. package/documentation/notion.md +16 -0
  136. package/documentation/operations.md +283 -5
  137. package/documentation/operator-ui.md +120 -5
  138. package/documentation/provider-audit.md +160 -0
  139. package/documentation/provider-conventions.md +631 -0
  140. package/documentation/request-admission.md +167 -5
  141. package/documentation/rich-output-design.md +1 -2
  142. package/documentation/stripe.md +27 -12
  143. package/ethos.md +14 -9
  144. package/examples/worker/README.md +96 -12
  145. package/examples/worker/src/index.ts +46 -1
  146. package/examples/worker/wrangler.jsonc +14 -1
  147. package/package.json +4 -5
  148. package/templates/node/.dockerignore +10 -0
  149. package/templates/node/.env.example +40 -3
  150. package/templates/node/AGENTS.md +11 -1
  151. package/templates/node/Dockerfile +49 -0
  152. package/templates/node/README.md +117 -2
  153. package/templates/node/docker-compose.yml +39 -0
  154. package/templates/node/package.json +1 -1
  155. package/templates/node/src/file-activity.ts +182 -0
  156. package/templates/node/src/index.ts +80 -4
  157. package/assets/connecta-clay-hero.png +0 -0
  158. package/dist/access-tokens.d.ts.map +0 -1
  159. package/dist/access-tokens.js.map +0 -1
  160. package/dist/activity.d.ts.map +0 -1
  161. package/dist/activity.js.map +0 -1
  162. package/dist/apps-shell.d.ts.map +0 -1
  163. package/dist/apps-shell.js.map +0 -1
  164. package/dist/auth/bearer.d.ts.map +0 -1
  165. package/dist/auth/bearer.js.map +0 -1
  166. package/dist/auth/clerk.d.ts.map +0 -1
  167. package/dist/auth/clerk.js.map +0 -1
  168. package/dist/auth/downstream-oauth.d.ts.map +0 -1
  169. package/dist/auth/downstream-oauth.js.map +0 -1
  170. package/dist/call-admission.d.ts.map +0 -1
  171. package/dist/call-admission.js.map +0 -1
  172. package/dist/catalog-fingerprint.d.ts.map +0 -1
  173. package/dist/catalog-fingerprint.js.map +0 -1
  174. package/dist/catalog-limits.d.ts.map +0 -1
  175. package/dist/catalog-limits.js.map +0 -1
  176. package/dist/catalog-service.d.ts.map +0 -1
  177. package/dist/catalog-service.js.map +0 -1
  178. package/dist/catalog.d.ts.map +0 -1
  179. package/dist/catalog.js.map +0 -1
  180. package/dist/concurrency.d.ts.map +0 -1
  181. package/dist/concurrency.js.map +0 -1
  182. package/dist/connector-scope.d.ts.map +0 -1
  183. package/dist/connector-scope.js.map +0 -1
  184. package/dist/connectors/api.d.ts.map +0 -1
  185. package/dist/connectors/api.js.map +0 -1
  186. package/dist/connectors/remote-mcp.d.ts.map +0 -1
  187. package/dist/connectors/remote-mcp.js.map +0 -1
  188. package/dist/credentials.d.ts.map +0 -1
  189. package/dist/credentials.js.map +0 -1
  190. package/dist/errors.d.ts.map +0 -1
  191. package/dist/errors.js.map +0 -1
  192. package/dist/execute.d.ts.map +0 -1
  193. package/dist/execute.js.map +0 -1
  194. package/dist/executor-admission.d.ts.map +0 -1
  195. package/dist/executor-admission.js.map +0 -1
  196. package/dist/executor-result.d.ts.map +0 -1
  197. package/dist/executor-result.js.map +0 -1
  198. package/dist/executors/quickjs-child.d.ts.map +0 -1
  199. package/dist/executors/quickjs-child.js.map +0 -1
  200. package/dist/executors/quickjs-protocol.d.ts.map +0 -1
  201. package/dist/executors/quickjs-protocol.js.map +0 -1
  202. package/dist/executors/quickjs-runtime.d.ts.map +0 -1
  203. package/dist/executors/quickjs-runtime.js.map +0 -1
  204. package/dist/executors/quickjs.d.ts.map +0 -1
  205. package/dist/executors/quickjs.js.map +0 -1
  206. package/dist/favicon.d.ts.map +0 -1
  207. package/dist/favicon.js.map +0 -1
  208. package/dist/index.d.ts.map +0 -1
  209. package/dist/index.js.map +0 -1
  210. package/dist/invocation.d.ts.map +0 -1
  211. package/dist/invocation.js.map +0 -1
  212. package/dist/json-schema.d.ts.map +0 -1
  213. package/dist/json-schema.js.map +0 -1
  214. package/dist/mcp-result.d.ts.map +0 -1
  215. package/dist/mcp-result.js.map +0 -1
  216. package/dist/meta-tools.d.ts.map +0 -1
  217. package/dist/meta-tools.js.map +0 -1
  218. package/dist/node.d.ts.map +0 -1
  219. package/dist/node.js.map +0 -1
  220. package/dist/operator-ui/generated.d.ts.map +0 -1
  221. package/dist/operator-ui/generated.js.map +0 -1
  222. package/dist/operator-ui/model.d.ts.map +0 -1
  223. package/dist/operator-ui/model.js.map +0 -1
  224. package/dist/providers/cloudflare.d.ts.map +0 -1
  225. package/dist/providers/cloudflare.js.map +0 -1
  226. package/dist/providers/linear.d.ts.map +0 -1
  227. package/dist/providers/linear.js.map +0 -1
  228. package/dist/providers/mixpanel.d.ts.map +0 -1
  229. package/dist/providers/mixpanel.js.map +0 -1
  230. package/dist/providers/notion.d.ts.map +0 -1
  231. package/dist/providers/notion.js.map +0 -1
  232. package/dist/providers/stripe.d.ts.map +0 -1
  233. package/dist/providers/stripe.js.map +0 -1
  234. package/dist/registry.d.ts.map +0 -1
  235. package/dist/registry.js.map +0 -1
  236. package/dist/retired-toolkits.d.ts.map +0 -1
  237. package/dist/retired-toolkits.js.map +0 -1
  238. package/dist/routes/access-tokens.d.ts.map +0 -1
  239. package/dist/routes/access-tokens.js.map +0 -1
  240. package/dist/routes/activity.d.ts.map +0 -1
  241. package/dist/routes/activity.js.map +0 -1
  242. package/dist/routes/credentials.d.ts.map +0 -1
  243. package/dist/routes/credentials.js.map +0 -1
  244. package/dist/routes/mcp.d.ts.map +0 -1
  245. package/dist/routes/mcp.js.map +0 -1
  246. package/dist/routes/oauth.d.ts.map +0 -1
  247. package/dist/routes/oauth.js.map +0 -1
  248. package/dist/routes/shared.d.ts.map +0 -1
  249. package/dist/routes/shared.js.map +0 -1
  250. package/dist/routes/ui.d.ts.map +0 -1
  251. package/dist/routes/ui.js.map +0 -1
  252. package/dist/server.d.ts.map +0 -1
  253. package/dist/server.js.map +0 -1
  254. package/dist/skills.d.ts.map +0 -1
  255. package/dist/skills.js.map +0 -1
  256. package/dist/storage/file.d.ts.map +0 -1
  257. package/dist/storage/file.js.map +0 -1
  258. package/dist/storage/memory.d.ts.map +0 -1
  259. package/dist/storage/memory.js.map +0 -1
  260. package/dist/timeout.d.ts.map +0 -1
  261. package/dist/timeout.js.map +0 -1
  262. package/dist/tool-safety.d.ts.map +0 -1
  263. package/dist/tool-safety.js.map +0 -1
  264. package/dist/types.d.ts.map +0 -1
  265. package/dist/types.js.map +0 -1
  266. package/dist/ui.d.ts.map +0 -1
  267. package/dist/ui.js.map +0 -1
  268. package/dist/validate.d.ts.map +0 -1
  269. package/dist/validate.js.map +0 -1
  270. package/dist/version.d.ts.map +0 -1
  271. package/dist/version.js.map +0 -1
  272. package/examples/node/README.md +0 -52
  273. package/examples/node/src/index.ts +0 -71
  274. package/src/access-tokens.ts +0 -289
  275. package/src/activity.ts +0 -234
  276. package/src/apps-shell.ts +0 -370
  277. package/src/auth/bearer.ts +0 -61
  278. package/src/auth/clerk.ts +0 -553
  279. package/src/auth/downstream-oauth.ts +0 -575
  280. package/src/call-admission.ts +0 -521
  281. package/src/catalog-fingerprint.ts +0 -52
  282. package/src/catalog-limits.ts +0 -11
  283. package/src/catalog-service.ts +0 -1153
  284. package/src/catalog.ts +0 -790
  285. package/src/concurrency.ts +0 -39
  286. package/src/connector-scope.ts +0 -66
  287. package/src/connectors/api.ts +0 -159
  288. package/src/connectors/remote-mcp.ts +0 -1090
  289. package/src/credentials.ts +0 -428
  290. package/src/errors.ts +0 -364
  291. package/src/execute.ts +0 -1406
  292. package/src/executor-admission.ts +0 -328
  293. package/src/executor-result.ts +0 -107
  294. package/src/executors/quickjs-child.ts +0 -172
  295. package/src/executors/quickjs-protocol.ts +0 -99
  296. package/src/executors/quickjs-runtime.ts +0 -470
  297. package/src/executors/quickjs.ts +0 -776
  298. package/src/favicon.ts +0 -53
  299. package/src/index.ts +0 -666
  300. package/src/invocation.ts +0 -655
  301. package/src/json-schema.ts +0 -11
  302. package/src/mcp-result.ts +0 -43
  303. package/src/meta-tools.ts +0 -1607
  304. package/src/node.ts +0 -206
  305. package/src/operator-ui/browser.css +0 -423
  306. package/src/operator-ui/browser.ts +0 -1281
  307. package/src/operator-ui/generated.ts +0 -4
  308. package/src/operator-ui/model.ts +0 -111
  309. package/src/providers/cloudflare.ts +0 -3803
  310. package/src/providers/linear.ts +0 -301
  311. package/src/providers/mixpanel.ts +0 -228
  312. package/src/providers/notion.ts +0 -1879
  313. package/src/providers/stripe.ts +0 -306
  314. package/src/registry.ts +0 -1085
  315. package/src/retired-toolkits.ts +0 -14
  316. package/src/routes/access-tokens.ts +0 -115
  317. package/src/routes/activity.ts +0 -215
  318. package/src/routes/credentials.ts +0 -254
  319. package/src/routes/mcp.ts +0 -429
  320. package/src/routes/oauth.ts +0 -322
  321. package/src/routes/shared.ts +0 -247
  322. package/src/routes/ui.ts +0 -152
  323. package/src/server.ts +0 -195
  324. package/src/skills.ts +0 -271
  325. package/src/storage/file.ts +0 -123
  326. package/src/storage/memory.ts +0 -44
  327. package/src/timeout.ts +0 -84
  328. package/src/tool-safety.ts +0 -15
  329. package/src/types.ts +0 -523
  330. package/src/ui.ts +0 -744
  331. package/src/validate.ts +0 -387
  332. 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
- }