handoff-app 2.0.0-8 → 2.0.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 (322) hide show
  1. package/{Changelog.md → CHANGELOG.md} +85 -1
  2. package/{License.md → LICENSE.md} +1 -1
  3. package/README.md +816 -393
  4. package/RECIPES.md +975 -0
  5. package/UPGRADE.md +57 -9
  6. package/components.json +20 -20
  7. package/config/docs/foundations/colors/guidelines.md +0 -1
  8. package/config/docs/foundations/colors.md +0 -1
  9. package/config/docs/foundations/effects/guidelines.md +0 -1
  10. package/config/docs/foundations/effects/resources.md +0 -1
  11. package/config/docs/foundations/effects.md +0 -1
  12. package/config/docs/foundations/fonts.md +0 -1
  13. package/config/docs/foundations/grid.md +0 -1
  14. package/config/docs/foundations/icon.md +0 -1
  15. package/config/docs/foundations/icons/guidelines.md +0 -1
  16. package/config/docs/foundations/icons.md +0 -1
  17. package/config/docs/foundations/images/guidelines.md +0 -1
  18. package/config/docs/foundations/images/resources.md +0 -1
  19. package/config/docs/foundations/images.md +0 -1
  20. package/config/docs/foundations/logo/resources.md +0 -1
  21. package/config/docs/foundations/logo.md +0 -1
  22. package/config/docs/foundations/typography/guidelines.md +0 -1
  23. package/config/docs/foundations/typography/resources.md +0 -1
  24. package/config/docs/foundations/typography.md +0 -1
  25. package/config/docs/foundations.md +0 -1
  26. package/config/docs/guidelines.md +0 -1
  27. package/config/docs/index.md +0 -1
  28. package/config/docs/system/tokens/foundations/colors.md +0 -4
  29. package/config/docs/system/tokens/foundations/effects.md +0 -4
  30. package/config/docs/system/tokens/foundations/typography.md +0 -2
  31. package/config/docs/system.md +0 -2
  32. package/config/templates/page.md +2 -1
  33. package/config/templates/page.mdx +2 -1
  34. package/dist/ai/connections.d.ts +66 -0
  35. package/dist/ai/connections.js +149 -0
  36. package/dist/ai/define.d.ts +10 -0
  37. package/dist/ai/define.js +13 -0
  38. package/dist/ai/types.d.ts +26 -0
  39. package/dist/ai/types.js +10 -0
  40. package/dist/app-builder/build.d.ts +14 -7
  41. package/dist/app-builder/build.js +195 -66
  42. package/dist/app-builder/client-config.js +3 -2
  43. package/dist/app-builder/paths.d.ts +0 -12
  44. package/dist/app-builder/paths.js +3 -20
  45. package/dist/app-builder/static-export.js +1 -1
  46. package/dist/app-builder/vercel-output.d.ts +1 -1
  47. package/dist/app-builder/vercel-output.js +5 -4
  48. package/dist/cache/build-cache.d.ts +27 -4
  49. package/dist/cache/build-cache.js +114 -47
  50. package/dist/cache/build-inputs.d.ts +13 -0
  51. package/dist/cache/build-inputs.js +19 -0
  52. package/dist/cache/index.d.ts +2 -1
  53. package/dist/cache/index.js +5 -1
  54. package/dist/catalog/normalize.js +3 -0
  55. package/dist/catalog/previews.js +5 -4
  56. package/dist/catalog/types.d.ts +8 -0
  57. package/dist/cli/create.js +1 -2
  58. package/dist/commands/build/index.d.ts +1 -1
  59. package/dist/commands/build/index.js +1 -1
  60. package/dist/commands/utils.d.ts +3 -3
  61. package/dist/config/defaults.js +7 -4
  62. package/dist/config/from-env.d.ts +4 -0
  63. package/dist/config/from-env.js +38 -1
  64. package/dist/config/helpers.js +2 -2
  65. package/dist/config/index.d.ts +0 -1
  66. package/dist/config/index.js +1 -4
  67. package/dist/config/loader.js +28 -2
  68. package/dist/config/resolve-env.d.ts +2 -1
  69. package/dist/config/resolve-env.js +28 -12
  70. package/dist/config/runtime.js +9 -11
  71. package/dist/handlebars.d.ts +10 -2
  72. package/dist/index.d.ts +12 -7
  73. package/dist/index.js +18 -22
  74. package/dist/nav/index.d.ts +2 -2
  75. package/dist/nav/registry.d.ts +3 -3
  76. package/dist/nav/registry.js +1 -1
  77. package/dist/nav/workspace.d.ts +3 -3
  78. package/dist/nav/workspace.js +1 -1
  79. package/dist/pipeline/figma.d.ts +13 -2
  80. package/dist/pipeline/figma.js +86 -46
  81. package/dist/pipeline/styles.js +1 -1
  82. package/dist/pipeline/validation.d.ts +1 -1
  83. package/dist/pipeline/validation.js +3 -3
  84. package/dist/providers.d.ts +10 -0
  85. package/dist/providers.js +13 -0
  86. package/dist/registry/asset-storage/define.d.ts +5 -5
  87. package/dist/registry/asset-storage/define.js +4 -4
  88. package/dist/registry/asset-storage/resolve.d.ts +9 -19
  89. package/dist/registry/asset-storage/resolve.js +33 -21
  90. package/dist/registry/asset-storage/types.d.ts +37 -17
  91. package/dist/registry/asset-storage/types.js +5 -5
  92. package/dist/registry/auth/ai-keys.d.ts +32 -0
  93. package/dist/registry/auth/ai-keys.js +69 -0
  94. package/dist/registry/auth/crypto.d.ts +17 -0
  95. package/dist/registry/auth/crypto.js +61 -1
  96. package/dist/registry/auth/index.d.ts +2 -1
  97. package/dist/registry/auth/index.js +11 -2
  98. package/dist/registry/checkout/index.d.ts +2 -1
  99. package/dist/registry/checkout/index.js +57 -127
  100. package/dist/registry/client.js +53 -17
  101. package/dist/registry/db/driver.js +3 -3
  102. package/dist/registry/db/schema.d.ts +261 -3
  103. package/dist/registry/db/schema.js +29 -4
  104. package/dist/registry/email/define.d.ts +10 -0
  105. package/dist/registry/email/define.js +13 -0
  106. package/dist/registry/email/providers/smtp.d.ts +10 -0
  107. package/dist/registry/email/providers/smtp.js +73 -0
  108. package/dist/registry/email/resolve.d.ts +28 -0
  109. package/dist/registry/email/resolve.js +74 -0
  110. package/dist/registry/email/types.d.ts +24 -0
  111. package/dist/registry/email/types.js +6 -0
  112. package/dist/registry/path.d.ts +2 -0
  113. package/dist/registry/path.js +11 -1
  114. package/dist/registry/publish/package.js +8 -0
  115. package/dist/registry/transfer.d.ts +4 -0
  116. package/dist/store/registry.d.ts +3 -3
  117. package/dist/store/registry.js +4 -4
  118. package/dist/transformers/plugins/ssr-render.js +40 -15
  119. package/dist/transformers/preview/component/builder.d.ts +1 -9
  120. package/dist/transformers/preview/component/builder.js +17 -17
  121. package/dist/transformers/preview/component/css.d.ts +3 -1
  122. package/dist/transformers/preview/component/css.js +92 -51
  123. package/dist/transformers/preview/component/html.d.ts +3 -1
  124. package/dist/transformers/preview/component/html.js +6 -2
  125. package/dist/transformers/preview/component/javascript.d.ts +2 -1
  126. package/dist/transformers/preview/component/javascript.js +10 -4
  127. package/dist/transformers/preview/pattern/html.js +3 -1
  128. package/dist/transformers/preview/types.d.ts +13 -0
  129. package/dist/transformers/utils/build.d.ts +24 -0
  130. package/dist/transformers/utils/build.js +100 -1
  131. package/dist/transformers/utils/module.js +2 -1
  132. package/dist/types/config.d.ts +135 -18
  133. package/dist/utils/path.d.ts +1 -5
  134. package/dist/utils/path.js +6 -23
  135. package/docs/api.md +6 -4
  136. package/docs/cli.md +275 -54
  137. package/drizzle/0007_add_ai_keys.sql +11 -0
  138. package/drizzle/0008_add_source_dir.sql +2 -0
  139. package/drizzle/meta/0007_snapshot.json +2013 -0
  140. package/drizzle/meta/0008_snapshot.json +2025 -0
  141. package/drizzle/meta/_journal.json +14 -0
  142. package/package.json +19 -8
  143. package/src/ai/README.md +42 -0
  144. package/src/ai/connections.ts +189 -0
  145. package/src/ai/define.ts +15 -0
  146. package/src/ai/types.ts +30 -0
  147. package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
  148. package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
  149. package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
  150. package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
  151. package/src/app/components/AiAssistant/dockState.ts +86 -0
  152. package/src/app/components/AiAssistant/toolLabel.ts +24 -0
  153. package/src/app/components/AiAssistant/useAiChat.ts +147 -0
  154. package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
  155. package/src/app/components/AiAssistant/usePageContext.ts +63 -0
  156. package/src/app/components/Auth/AccountLayout.tsx +58 -19
  157. package/src/app/components/Auth/AuthShell.tsx +27 -33
  158. package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
  159. package/src/app/components/Auth/installStatus.ts +29 -0
  160. package/src/app/components/Component/PageSliceResolver.tsx +1 -0
  161. package/src/app/components/Component/Preview.tsx +383 -189
  162. package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
  163. package/src/app/components/Foundations/ColorGrid.tsx +13 -14
  164. package/src/app/components/Layout/Header.tsx +21 -15
  165. package/src/app/components/Layout/Main.tsx +2 -2
  166. package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
  167. package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
  168. package/src/app/components/Navigation/MobileNav.tsx +2 -2
  169. package/src/app/components/Navigation/SideNav.tsx +1 -15
  170. package/src/app/components/context/NavProvider.tsx +7 -3
  171. package/src/app/components/ui/attachment.tsx +50 -0
  172. package/src/app/components/ui/bubble.tsx +57 -0
  173. package/src/app/components/ui/button.tsx +3 -1
  174. package/src/app/components/ui/confirm-dialog.tsx +87 -0
  175. package/src/app/components/ui/dialog.tsx +7 -2
  176. package/src/app/components/ui/marker.tsx +56 -0
  177. package/src/app/components/ui/sidebar.tsx +3 -1
  178. package/src/app/components/util/index.ts +3 -12
  179. package/src/app/css/index.css +64 -0
  180. package/src/app/lib/ai/auth.ts +33 -0
  181. package/src/app/lib/ai/context.ts +99 -0
  182. package/src/app/lib/ai/model.ts +79 -0
  183. package/src/app/lib/ai/prompt.ts +36 -0
  184. package/src/app/lib/ai/resolve.ts +97 -0
  185. package/src/app/lib/ai/tools.ts +120 -0
  186. package/src/app/lib/api/methods.ts +13 -0
  187. package/src/app/lib/asset-storage.ts +35 -36
  188. package/src/app/lib/auth/api.ts +4 -12
  189. package/src/app/lib/auth/email.ts +96 -23
  190. package/src/app/lib/docs-api/page-rendering.ts +1 -1
  191. package/src/app/lib/docs-api/registry.ts +2 -3
  192. package/src/app/lib/docs-api/runtime-config.ts +47 -21
  193. package/src/app/lib/mcp/components.ts +523 -0
  194. package/src/app/lib/mcp/page-url.ts +24 -0
  195. package/src/app/lib/mcp/pages.ts +84 -0
  196. package/src/app/lib/mcp/result.ts +33 -0
  197. package/src/app/lib/mcp/server.ts +39 -318
  198. package/src/app/lib/mcp/tokens.ts +334 -0
  199. package/src/app/lib/mcp/tool.ts +19 -0
  200. package/src/app/lib/registry-api/asset-transfer.ts +119 -11
  201. package/src/app/lib/registry-api/errors.ts +2 -0
  202. package/src/app/lib/registry-api/handler.ts +7 -1
  203. package/src/app/lib/registry-api/transfer.ts +28 -6
  204. package/src/app/lib/server-module.ts +34 -0
  205. package/src/app/lib/utils.ts +20 -0
  206. package/src/app/next.config.mjs +26 -24
  207. package/src/app/pages/[...slug]/index.tsx +2 -1
  208. package/src/app/pages/_app.tsx +6 -2
  209. package/src/app/pages/_document.tsx +6 -0
  210. package/src/app/pages/account/ai.tsx +183 -0
  211. package/src/app/pages/account/index.tsx +68 -68
  212. package/src/app/pages/account/tokens.tsx +37 -7
  213. package/src/app/pages/account/users.tsx +75 -15
  214. package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
  215. package/src/app/pages/api/account/ai/keys/index.ts +20 -0
  216. package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
  217. package/src/app/pages/api/admin/users/index.ts +2 -2
  218. package/src/app/pages/api/ai/chat.ts +115 -0
  219. package/src/app/pages/api/ai/connections.ts +23 -0
  220. package/src/app/pages/api/auth/request-reset.ts +14 -16
  221. package/src/app/pages/api/docs/nav.json.ts +1 -1
  222. package/src/app/pages/api/install.ts +3 -3
  223. package/src/app/pages/api/mcp.ts +6 -0
  224. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
  225. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
  226. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
  227. package/src/app/pages/cli/device.tsx +10 -5
  228. package/src/app/pages/foundations/colors.tsx +9 -8
  229. package/src/app/pages/foundations/index.tsx +3 -3
  230. package/src/app/pages/index.tsx +2 -2
  231. package/src/app/pages/install.tsx +7 -2
  232. package/src/app/pages/login.tsx +32 -27
  233. package/src/app/pages/reset-password.tsx +21 -20
  234. package/src/app/pages/system/component/[component]/index.tsx +2 -5
  235. package/src/app/pages/system/index.tsx +11 -11
  236. package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
  237. package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
  238. package/src/app-builder/README.md +2 -1
  239. package/src/app-builder/build.ts +218 -73
  240. package/src/app-builder/client-config.ts +3 -2
  241. package/src/app-builder/paths.ts +3 -22
  242. package/src/app-builder/static-export.ts +1 -1
  243. package/src/app-builder/vercel-output.ts +5 -4
  244. package/src/cache/README.md +4 -1
  245. package/src/cache/build-cache.ts +128 -43
  246. package/src/cache/build-inputs.ts +28 -0
  247. package/src/cache/index.ts +4 -0
  248. package/src/catalog/README.md +3 -0
  249. package/src/catalog/normalize.ts +2 -0
  250. package/src/catalog/previews.ts +6 -3
  251. package/src/catalog/types.ts +8 -0
  252. package/src/cli/create.ts +1 -2
  253. package/src/commands/build/index.ts +1 -1
  254. package/src/config/README.md +10 -9
  255. package/src/config/defaults.ts +7 -4
  256. package/src/config/from-env.ts +33 -0
  257. package/src/config/helpers.ts +1 -3
  258. package/src/config/index.ts +0 -3
  259. package/src/config/loader.ts +27 -3
  260. package/src/config/resolve-env.ts +28 -15
  261. package/src/config/runtime.ts +10 -5
  262. package/src/handlebars.ts +8 -2
  263. package/src/index.ts +33 -29
  264. package/src/nav/index.ts +2 -2
  265. package/src/nav/registry.ts +3 -3
  266. package/src/nav/workspace.ts +3 -3
  267. package/src/pipeline/README.md +1 -1
  268. package/src/pipeline/figma.ts +98 -49
  269. package/src/pipeline/styles.ts +1 -1
  270. package/src/pipeline/validation.ts +3 -3
  271. package/src/providers.ts +18 -0
  272. package/src/registry/asset-storage/define.ts +6 -6
  273. package/src/registry/asset-storage/resolve.ts +38 -37
  274. package/src/registry/asset-storage/types.ts +39 -17
  275. package/src/registry/auth/ai-keys.ts +62 -0
  276. package/src/registry/auth/crypto.ts +63 -1
  277. package/src/registry/auth/index.ts +11 -1
  278. package/src/registry/checkout/index.ts +60 -138
  279. package/src/registry/client.ts +60 -20
  280. package/src/registry/db/driver.ts +2 -2
  281. package/src/registry/db/schema.ts +49 -22
  282. package/src/registry/email/define.ts +15 -0
  283. package/src/registry/email/providers/smtp.ts +39 -0
  284. package/src/registry/email/resolve.ts +94 -0
  285. package/src/registry/email/types.ts +28 -0
  286. package/src/registry/path.ts +11 -0
  287. package/src/registry/publish/package.ts +9 -0
  288. package/src/registry/transfer.ts +4 -0
  289. package/src/store/registry.ts +7 -7
  290. package/src/transformers/plugins/ssr-render.ts +53 -16
  291. package/src/transformers/preview/component/builder.ts +19 -25
  292. package/src/transformers/preview/component/css.ts +101 -53
  293. package/src/transformers/preview/component/html.ts +6 -2
  294. package/src/transformers/preview/component/javascript.ts +14 -4
  295. package/src/transformers/preview/pattern/html.ts +3 -1
  296. package/src/transformers/preview/types.ts +14 -0
  297. package/src/transformers/utils/build.ts +93 -0
  298. package/src/transformers/utils/module.ts +4 -2
  299. package/src/types/config.ts +148 -20
  300. package/src/utils/README.md +1 -1
  301. package/src/utils/path.ts +6 -29
  302. package/templates/default/handoff.config.js.tpl +10 -2
  303. package/templates/default/handoff.config.ts.tpl +10 -2
  304. package/templates/default/handoff.config.with-examples.js.tpl +10 -2
  305. package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
  306. package/templates/default/package.json.tpl +2 -2
  307. package/.editorconfig +0 -19
  308. package/.eslintrc.json +0 -4
  309. package/.prettierignore +0 -0
  310. package/.prettierrc.json +0 -8
  311. package/.vscode/settings.json +0 -17
  312. package/dist/config/validator.d.ts +0 -9
  313. package/dist/config/validator.js +0 -24
  314. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
  315. package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
  316. package/src/app/components/old/Header.tsx +0 -89
  317. package/src/app/lib/mcp/shape.ts +0 -596
  318. package/src/app/pages/assets/fonts.tsx +0 -129
  319. package/src/app/pages/assets/index.tsx +0 -91
  320. package/src/app/pages/assets/logos.tsx +0 -75
  321. package/src/config/validator.ts +0 -22
  322. package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
@@ -0,0 +1,120 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
3
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
4
+ import { dynamicTool, jsonSchema, type ToolSet } from 'ai';
5
+ import { createMcpServer } from '../mcp';
6
+
7
+ /**
8
+ * The assistant's tools: the MCP server's own tools, reached in process.
9
+ *
10
+ * The chat route connects a client to {@link createMcpServer} over a linked in-memory transport
11
+ * pair rather than calling `/api/mcp/` over HTTP. In registry mode that endpoint demands a
12
+ * `registry:read` bearer token while the browser holds a session cookie, so the server would have
13
+ * to mint a token to call itself, and the HTTP hop is needless inside one function either way.
14
+ *
15
+ * A tool added to the MCP server therefore reaches the assistant with no further work here.
16
+ */
17
+
18
+ /** A page or component a tool read, surfaced to the reader as a link under the answer. */
19
+ export interface AiSource {
20
+ url: string;
21
+ title: string;
22
+ }
23
+
24
+ export interface AiToolSession {
25
+ tools: ToolSet;
26
+ close: () => Promise<void>;
27
+ }
28
+
29
+ /** Docs route for one component id. Patterns and pages carry their own paths. */
30
+ const componentUrl = (id: string): string => `/system/component/${encodeURIComponent(id)}`;
31
+
32
+ const asRecord = (value: unknown): Record<string, unknown> | null =>
33
+ value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
34
+
35
+ const text = (value: unknown): string | undefined => (typeof value === 'string' && value.trim() ? value.trim() : undefined);
36
+
37
+ /**
38
+ * The pages and components behind one tool result, so the answer can link to what it was read from.
39
+ * Only the shapes `./shape` produces are recognized; anything else contributes no source, which
40
+ * costs a link rather than breaking the answer.
41
+ */
42
+ const sourcesOf = (toolName: string, payload: unknown): AiSource[] => {
43
+ const record = asRecord(payload);
44
+ if (!record) return [];
45
+
46
+ if (toolName === 'handoff_get_component' || toolName === 'handoff_get_component_source') {
47
+ const id = text(record.id);
48
+ return id ? [{ url: componentUrl(id), title: text(record.title) ?? id }] : [];
49
+ }
50
+ // A preview links to the component's docs page, which is where a reader sees it rendered.
51
+ if (toolName === 'handoff_get_component_preview') {
52
+ const component = asRecord(record.component);
53
+ const id = component && text(component.id);
54
+ return id ? [{ url: componentUrl(id), title: text(component.title) ?? id }] : [];
55
+ }
56
+ if (toolName === 'handoff_get_page') {
57
+ const url = text(record.url);
58
+ return url ? [{ url, title: text(record.title) ?? url }] : [];
59
+ }
60
+ if (toolName === 'handoff_search_pages') {
61
+ const results = Array.isArray(record.results) ? record.results : [];
62
+ return results.flatMap((entry) => {
63
+ const page = asRecord(entry);
64
+ const url = page && text(page.url);
65
+ return url ? [{ url, title: text(page.title) ?? url }] : [];
66
+ });
67
+ }
68
+ return [];
69
+ };
70
+
71
+ /** Parse the single JSON text block an MCP tool returns. */
72
+ const parseToolPayload = (result: CallToolResult): unknown => {
73
+ const block = result.content?.find((entry) => entry.type === 'text');
74
+ if (!block || block.type !== 'text') return null;
75
+ try {
76
+ return JSON.parse(block.text);
77
+ } catch {
78
+ return null;
79
+ }
80
+ };
81
+
82
+ /**
83
+ * Build the tool set for one request. Tool descriptions and input schemas come from the MCP server
84
+ * itself, so the agent sees what any other MCP client would.
85
+ *
86
+ * @param onSources - Called with the pages and components each tool read, as they are read.
87
+ */
88
+ export const createAiToolSession = async (onSources: (sources: AiSource[]) => void): Promise<AiToolSession> => {
89
+ const server = createMcpServer();
90
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
91
+ const client = new Client({ name: 'handoff-docs-assistant', version: '1.0.0' });
92
+ await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
93
+
94
+ const { tools: definitions } = await client.listTools();
95
+ const tools: ToolSet = Object.fromEntries(
96
+ definitions.map((definition) => [
97
+ definition.name,
98
+ dynamicTool({
99
+ description: definition.description,
100
+ inputSchema: jsonSchema(definition.inputSchema as Parameters<typeof jsonSchema>[0]),
101
+ execute: async (input) => {
102
+ const result = (await client.callTool({
103
+ name: definition.name,
104
+ arguments: (asRecord(input) ?? {}) as Record<string, unknown>,
105
+ })) as CallToolResult;
106
+ if (!result.isError) onSources(sourcesOf(definition.name, parseToolPayload(result)));
107
+ return result.content;
108
+ },
109
+ }),
110
+ ])
111
+ );
112
+
113
+ return {
114
+ tools,
115
+ close: async () => {
116
+ await client.close();
117
+ await server.close();
118
+ },
119
+ };
120
+ };
@@ -0,0 +1,13 @@
1
+ import type { NextApiRequest, NextApiResponse } from 'next';
2
+
3
+ /**
4
+ * Method guard shared by every API route. It carries no registry dependency, so routes that also
5
+ * serve workspace mode can use it without pulling the registry auth/database layer into that path.
6
+ */
7
+ export const allowApiMethods = (req: NextApiRequest, res: NextApiResponse, methods: string[]): string | null => {
8
+ const method = (req.method ?? 'GET').toUpperCase();
9
+ if (methods.includes(method)) return method;
10
+ res.setHeader('Allow', methods.join(', '));
11
+ res.status(405).json({ error: `Method ${method} is not allowed.` });
12
+ return null;
13
+ };
@@ -1,21 +1,20 @@
1
1
  /**
2
2
  * Server-only asset storage runtime. Resolves the active provider from the baked server runtime
3
- * config and lazily builds/memoizes adapter instances. Writes use the active adapter; reads resolve
4
- * by each blob's recorded provider id, so DB-backed (`database`, inline bytea) and object-backed
5
- * blobs coexist and old blobs stay readable after the active provider changes. `database` is the
6
- * inline default with no adapter (bytes live directly in `asset_blobs.content`).
3
+ * config and lazily builds and memoizes it. Writes use the active provider. Reads resolve by each
4
+ * blob's recorded provider, so `database` blobs (inline bytea) stay readable when a `custom` provider
5
+ * is active. `database` has no module: its bytes live directly in `asset_blobs.content`.
7
6
  *
8
7
  * Imported exclusively by registry API route handlers and the registry store.
9
8
  */
10
9
 
11
- import { createVercelBlobStorage } from '@handoff/registry/asset-storage/adapters/vercel-blob';
12
10
  import { resolveAssetStorageSettings, type ResolvedAssetStorage } from '@handoff/registry/asset-storage/resolve';
13
- import type { AssetStorage, AssetStorageFactory } from '@handoff/registry/asset-storage/types';
11
+ import type { StorageProvider } from '@handoff/registry/asset-storage/types';
14
12
  import type { AssetStorageProvider } from '@handoff/registry/db/schema';
15
13
  import { getServerRuntimeConfig } from './docs-api/runtime-config';
14
+ import { loadServerModule } from './server-module';
16
15
 
17
16
  let activeCache: ResolvedAssetStorage | null = null;
18
- const adapterCache = new Map<string, AssetStorage | null>();
17
+ const providerCache = new Map<string, StorageProvider | null>();
19
18
 
20
19
  /** The active, fully-resolved asset storage settings for new uploads. */
21
20
  export const getActiveAssetStorage = (): ResolvedAssetStorage => {
@@ -25,57 +24,57 @@ export const getActiveAssetStorage = (): ResolvedAssetStorage => {
25
24
  return activeCache;
26
25
  };
27
26
 
28
- /** Load a custom adapter module and coerce its default export (adapter object or factory) to an adapter. */
29
- const loadCustomAdapter = async (active: ResolvedAssetStorage): Promise<AssetStorage> => {
27
+ /** A storage configuration error whose message is safe to show to a client. */
28
+ export class AssetStorageError extends Error {
29
+ constructor(message: string) {
30
+ super(message);
31
+ this.name = 'AssetStorageError';
32
+ }
33
+ }
34
+
35
+ /** Load a custom provider module and coerce its default export (provider object or factory) to a provider. */
36
+ const loadCustomProvider = async (active: ResolvedAssetStorage): Promise<StorageProvider> => {
30
37
  if (!active.module) {
31
- throw new Error('A custom asset storage adapter is selected but no module path is configured.');
38
+ throw new AssetStorageError('A custom asset storage provider is selected but no module path is configured.');
32
39
  }
33
- // Traced into the registry bundle at build time; resolved by Node at runtime (see build tracing).
34
- const mod: any = await import(/* webpackIgnore: true */ active.module);
35
- const exported = mod?.default ?? mod;
36
- const adapter: unknown = typeof exported === 'function' ? await (exported as AssetStorageFactory)({ options: active.options, env: process.env }) : exported;
37
- const candidate = adapter as Partial<AssetStorage> | null;
40
+ const candidate = (await loadServerModule(active.module, { options: active.options })) as Partial<StorageProvider> | null;
38
41
  if (!candidate || typeof candidate.put !== 'function' || typeof candidate.get !== 'function' || typeof candidate.delete !== 'function') {
39
- throw new Error(`Custom asset storage module "${active.module}" must default-export a defineAssetStorage adapter.`);
42
+ throw new AssetStorageError(`Custom asset storage module "${active.module}" must default-export a defineStorageProvider() result.`);
40
43
  }
41
- return candidate as AssetStorage;
44
+ return candidate as StorageProvider;
42
45
  };
43
46
 
44
47
  /**
45
- * Resolve the {@link AssetStorage} adapter for a provider id, or `null` when the provider is the
46
- * inline `database` default (there is no adapter; content lives in the DB row). Reading a blob whose
48
+ * Resolve the {@link StorageProvider} for a recorded provider, or `null` when the provider is the
49
+ * inline `database` default (there is no module; content lives in the DB row). Reading a blob whose
47
50
  * provider is no longer configured throws an actionable error rather than silently failing.
48
51
  */
49
- export const getAssetStorageAdapter = async (provider: AssetStorageProvider): Promise<AssetStorage | null> => {
52
+ export const getStorageProvider = async (provider: AssetStorageProvider): Promise<StorageProvider | null> => {
50
53
  if (provider === 'database') {
51
54
  return null;
52
55
  }
53
- if (adapterCache.has(provider)) {
54
- return adapterCache.get(provider) ?? null;
56
+ if (providerCache.has(provider)) {
57
+ return providerCache.get(provider) ?? null;
55
58
  }
56
59
 
57
60
  const active = getActiveAssetStorage();
58
- let adapter: AssetStorage;
59
- if (provider === 'vercel-blob') {
60
- adapter = createVercelBlobStorage({ tokenEnv: active.tokenEnv, options: active.options });
61
- } else if (active.adapterKind === 'custom' && active.provider === provider) {
62
- adapter = await loadCustomAdapter(active);
63
- } else {
64
- throw new Error(
65
- `No asset storage adapter is configured for provider "${provider}". A blob was stored by that ` +
66
- 'provider but it is no longer selected. Restore its configuration (or migrate its objects) to read it.'
61
+ if (active.provider !== provider) {
62
+ throw new AssetStorageError(
63
+ `No asset storage module is configured for provider "${provider}". A blob was stored by that ` +
64
+ 'provider but it is no longer selected. Restore its configuration to read it.'
67
65
  );
68
66
  }
67
+ const storage = await loadCustomProvider(active);
69
68
 
70
- adapterCache.set(provider, adapter);
71
- return adapter;
69
+ providerCache.set(provider, storage);
70
+ return storage;
72
71
  };
73
72
 
74
- /** The adapter for the active provider (used for new uploads), or `null` for the inline database default. */
75
- export const getActiveAssetStorageAdapter = (): Promise<AssetStorage | null> => getAssetStorageAdapter(getActiveAssetStorage().provider);
73
+ /** The active provider (used for new uploads), or `null` for the inline database default. */
74
+ export const getActiveStorageProvider = (): Promise<StorageProvider | null> => getStorageProvider(getActiveAssetStorage().provider);
76
75
 
77
76
  /** Reset memoized state (test seam only). */
78
77
  export const __resetAssetStorageCache = (): void => {
79
78
  activeCache = null;
80
- adapterCache.clear();
79
+ providerCache.clear();
81
80
  };
@@ -3,21 +3,13 @@ import type { RegistryDatabase } from '@handoff/registry/db/client';
3
3
  import { getRegistryInstallationState, type RegistryUser } from '@handoff/registry/auth';
4
4
  import { getServerRuntimeConfig } from '../docs-api/runtime-config';
5
5
  import { getRegistryConnection, RegistryConnectionError } from '../registry-connection';
6
+ import { allowApiMethods } from '../api/methods';
6
7
  import { canonicalRegistryUrl, getRegistrySessionUser } from './config';
7
8
 
8
- export const allowApiMethods = (req: NextApiRequest, res: NextApiResponse, methods: string[]): string | null => {
9
- const method = (req.method ?? 'GET').toUpperCase();
10
- if (methods.includes(method)) return method;
11
- res.setHeader('Allow', methods.join(', '));
12
- res.status(405).json({ error: `Method ${method} is not allowed.` });
13
- return null;
14
- };
9
+ // Re-exported so the registry routes keep reaching it here, next to the authorization they pair it with.
10
+ export { allowApiMethods };
15
11
 
16
- export const registryPageUrl = (
17
- path: string,
18
- query?: Record<string, string>,
19
- fragment?: Record<string, string>
20
- ): string | null => {
12
+ export const registryPageUrl = (path: string, query?: Record<string, string>, fragment?: Record<string, string>): string | null => {
21
13
  const canonical = canonicalRegistryUrl();
22
14
  if (!canonical) return null;
23
15
  const base = canonical.pathname.replace(/\/+$/, '');
@@ -1,34 +1,107 @@
1
- export const registryEmailIsConfigured = (): boolean => Boolean(process.env.RESEND_API_KEY?.trim() && process.env.AUTH_FROM_EMAIL?.trim());
1
+ import { isEnvReference, resolveEnvReferences } from '@handoff/config/from-env';
2
+ import { createSmtpProvider } from '@handoff/registry/email/providers/smtp';
3
+ import type { EmailProviderKind, EmailSettings } from '@handoff/registry/email/resolve';
4
+ import type { EmailProvider } from '@handoff/registry/email/types';
5
+ import { getServerRuntimeConfig } from '../docs-api/runtime-config';
6
+ import { loadServerModule } from '../server-module';
7
+
8
+ /** Options a provider needs before email counts as configured. */
9
+ const REQUIRED_OPTIONS: Record<EmailProviderKind, readonly string[]> = { smtp: ['host'], custom: [] };
10
+
11
+ /**
12
+ * The settings that still need a value, named as the administrator sets them: the config key, or the
13
+ * env var that an option references.
14
+ */
15
+ export const missingRegistryEmailSettings = (): string[] => {
16
+ const settings = getServerRuntimeConfig().email;
17
+ const options = resolveEnvReferences(settings.options, process.env);
18
+ const missing = settings.from ? [] : ['runtime.registry.email.from'];
19
+ if (!settings.provider) return [...missing, 'runtime.registry.email.provider'];
20
+ if (settings.provider === 'custom' && !settings.module) missing.push('runtime.registry.email.module');
21
+ for (const key of REQUIRED_OPTIONS[settings.provider]) {
22
+ if (options[key] !== undefined) continue;
23
+ const authored = settings.options[key];
24
+ missing.push(isEnvReference(authored) ? authored.$env : `runtime.registry.email.options.${key}`);
25
+ }
26
+ return missing;
27
+ };
28
+
29
+ export const registryEmailIsConfigured = (): boolean => missingRegistryEmailSettings().length === 0;
30
+
31
+ /** The provider name for messages shown to an administrator. */
32
+ export const registryEmailProviderName = (): string => {
33
+ const { provider } = getServerRuntimeConfig().email;
34
+ return provider === 'smtp' ? 'SMTP' : 'the custom email provider';
35
+ };
36
+
37
+ let customProvider: Promise<EmailProvider> | null = null;
38
+
39
+ /** Load the custom module once and coerce its default export (provider object or factory) to a provider. */
40
+ const loadCustomProvider = async (settings: EmailSettings, options: Record<string, unknown>): Promise<EmailProvider> => {
41
+ const provider = await loadServerModule(settings.module!, { options });
42
+ if (typeof (provider as Partial<EmailProvider> | null)?.send !== 'function') {
43
+ throw new Error(`Custom email provider module "${settings.module}" must default-export a defineEmailProvider() provider.`);
44
+ }
45
+ return provider as EmailProvider;
46
+ };
47
+
48
+ const getEmailProvider = (settings: EmailSettings, options: Record<string, unknown>): Promise<EmailProvider> => {
49
+ if (settings.provider === 'smtp') return Promise.resolve(createSmtpProvider(options));
50
+ customProvider ??= loadCustomProvider(settings, options).catch((error) => {
51
+ customProvider = null;
52
+ throw error;
53
+ });
54
+ return customProvider;
55
+ };
2
56
 
3
57
  const escapeHtml = (value: string): string =>
4
58
  value.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!);
5
59
 
6
- export const sendRegistryAuthEmail = async (input: {
60
+ const FOOTNOTE = 'If you did not expect this email, you can ignore it.';
61
+
62
+ interface AuthEmail {
7
63
  to: string;
8
64
  subject: string;
9
65
  heading: string;
10
66
  message: string;
11
67
  actionLabel: string;
12
68
  actionUrl: string;
13
- }): Promise<boolean> => {
14
- const apiKey = process.env.RESEND_API_KEY?.trim();
15
- const from = process.env.AUTH_FROM_EMAIL?.trim();
16
- if (!apiKey || !from) return false;
17
-
18
- const response = await fetch('https://api.resend.com/emails', {
19
- method: 'POST',
20
- headers: {
21
- Authorization: `Bearer ${apiKey}`,
22
- 'Content-Type': 'application/json',
23
- },
24
- body: JSON.stringify({
25
- from,
26
- to: [input.to],
27
- subject: input.subject,
28
- html:
29
- `<h1>${escapeHtml(input.heading)}</h1><p>${escapeHtml(input.message)}</p>` +
30
- `<p><a href="${escapeHtml(input.actionUrl)}">${escapeHtml(input.actionLabel)}</a></p>`,
31
- }),
32
- });
33
- return response.ok;
69
+ }
70
+
71
+ const renderHtml = ({ heading, message, actionLabel, actionUrl }: AuthEmail): string => {
72
+ const url = escapeHtml(actionUrl);
73
+ return `<!DOCTYPE html>
74
+ <html lang="en">
75
+ <body style="margin:0;padding:32px 16px;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#18181b;">
76
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center">
77
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;background:#ffffff;border-radius:8px;">
78
+ <tr><td style="padding:32px;">
79
+ <h1 style="margin:0 0 16px;font-size:20px;">${escapeHtml(heading)}</h1>
80
+ <p style="margin:0 0 24px;font-size:15px;line-height:1.5;">${escapeHtml(message)}</p>
81
+ <a href="${url}" style="display:inline-block;padding:12px 20px;border-radius:6px;background:#18181b;color:#ffffff;font-size:15px;text-decoration:none;">${escapeHtml(actionLabel)}</a>
82
+ <p style="margin:24px 0 0;font-size:13px;line-height:1.5;color:#71717a;">If the button does not work, open this link:<br><a href="${url}" style="color:#71717a;word-break:break-all;">${url}</a></p>
83
+ <p style="margin:16px 0 0;font-size:13px;color:#71717a;">${FOOTNOTE}</p>
84
+ </td></tr></table>
85
+ </td></tr></table>
86
+ </body>
87
+ </html>`;
88
+ };
89
+
90
+ const renderText = ({ heading, message, actionLabel, actionUrl }: AuthEmail): string =>
91
+ `${heading}\n\n${message}\n\n${actionLabel}: ${actionUrl}\n\n${FOOTNOTE}\n`;
92
+
93
+ /** Send through the configured provider. Returns false, and logs the failure, when email is not configured or delivery fails. */
94
+ export const sendRegistryAuthEmail = async (email: AuthEmail): Promise<boolean> => {
95
+ if (!registryEmailIsConfigured()) return false;
96
+ const settings = getServerRuntimeConfig().email;
97
+ const options = resolveEnvReferences(settings.options, process.env);
98
+
99
+ try {
100
+ const provider = await getEmailProvider(settings, options);
101
+ await provider.send({ from: settings.from!, to: email.to, subject: email.subject, html: renderHtml(email), text: renderText(email) });
102
+ return true;
103
+ } catch (error) {
104
+ console.error(`Email delivery through ${registryEmailProviderName()} failed.`, error);
105
+ return false;
106
+ }
34
107
  };
@@ -31,7 +31,7 @@ export const documentationMetadata = (source: Record<string, unknown>) => {
31
31
  return {
32
32
  title,
33
33
  description,
34
- metaTitle: value('metaTitle') || title,
34
+ metaTitle: value('metaTitle'),
35
35
  metaDescription: value('metaDescription') || description,
36
36
  };
37
37
  };
@@ -21,7 +21,7 @@ import {
21
21
  import { defaultPageDetail, type BakedDefaultPage } from './page-rendering';
22
22
  import { MAX_SEARCH_BODY_LENGTH, MAX_SEARCH_CANDIDATES, truncateSearchBody, type SearchResponse } from './search';
23
23
  import { getRegistryConnection } from '../registry-connection';
24
- import { getAssetStorageAdapter } from '../asset-storage';
24
+ import { getStorageProvider } from '../asset-storage';
25
25
  // A static import includes package defaults in each registry serverless bundle. A deployed function
26
26
  // cannot read the build machine's `config/docs` directory.
27
27
  import defaultPages from '../../generated/default-pages.json';
@@ -148,8 +148,7 @@ const searchRegistryPages = async (db: RegistryDatabase, request: PageSearchRequ
148
148
  export const createRegistryDocsBackend = async (): Promise<DocsBackend> => {
149
149
  const connection = await getRegistryConnection();
150
150
  const { db } = connection;
151
- // Inject the storage-adapter resolver so object-backed asset content resolves by provider id.
152
- const store = createRegistryStore({ db, resolveAssetAdapter: getAssetStorageAdapter });
151
+ const store = createRegistryStore({ db, resolveStorageProvider: getStorageProvider });
153
152
 
154
153
  return {
155
154
  async listComponents() {
@@ -1,12 +1,10 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import type { RuntimeMode } from '@handoff/types/config';
4
- import {
5
- DEFAULT_DATABASE_URL_ENV,
6
- DEFAULT_REGISTRY_DRIVER,
7
- type RegistryDatabaseDriver,
8
- } from '@handoff/registry/db/driver';
9
- import { DEFAULT_ASSET_STORAGE_ADAPTER, type AssetStorageSettings } from '@handoff/registry/asset-storage/resolve';
4
+ import { DEFAULT_DATABASE_URL_ENV, DEFAULT_REGISTRY_DRIVER, type RegistryDatabaseDriver } from '@handoff/registry/db/driver';
5
+ import { DEFAULT_ASSET_STORAGE_PROVIDER, type AssetStorageSettings } from '@handoff/registry/asset-storage/resolve';
6
+ import { mergeAiConnections, parseAiConnections, type AiSettings } from '@handoff/ai/connections';
7
+ import { parseEmailSettings, type EmailSettings } from '@handoff/registry/email/resolve';
10
8
 
11
9
  /**
12
10
  * Server-side runtime resolution for the docs read API.
@@ -35,12 +33,20 @@ export interface ServerRuntimeConfig {
35
33
  mode: RuntimeMode;
36
34
  /** Whether this build serves `/api/mcp/`. Config-only, baked at build time. */
37
35
  mcp: boolean;
36
+ /**
37
+ * The AI assistant: whether this build serves it, and the connections it can reach. `enabled` is
38
+ * baked; the connection list is resolved per request so a deployment can change it without a
39
+ * rebuild.
40
+ */
41
+ ai: AiSettings;
38
42
  registry: ServerRegistryRuntimeConfig;
39
43
  /**
40
- * Asset storage selection for registry mode (provider + non-secret options + env-var names). Secret
41
- * values are resolved from `process.env` at request time, never persisted here.
44
+ * Asset storage provider, module, and options for registry mode. Options hold `{ $env }` references,
45
+ * never secret values.
42
46
  */
43
47
  assetStorage: AssetStorageSettings;
48
+ /** Registry email provider, sender, and options. Options hold `{ $env }` references, never secret values. */
49
+ email: EmailSettings;
44
50
  }
45
51
 
46
52
  let cached: ServerRuntimeConfig | null = null;
@@ -49,16 +55,18 @@ let cached: ServerRuntimeConfig | null = null;
49
55
  const defaults = (): ServerRuntimeConfig => ({
50
56
  mode: 'workspace',
51
57
  mcp: true,
58
+ ai: { enabled: false, connections: [] },
52
59
  registry: {
53
60
  driver: DEFAULT_REGISTRY_DRIVER,
54
61
  databaseUrlEnv: DEFAULT_DATABASE_URL_ENV,
55
62
  },
56
- assetStorage: { adapter: DEFAULT_ASSET_STORAGE_ADAPTER },
63
+ assetStorage: { provider: DEFAULT_ASSET_STORAGE_PROVIDER },
64
+ email: { options: {} },
57
65
  });
58
66
 
59
67
  /** Parse the baked asset-storage selection from env (names/selectors only; JSON options tolerated). */
60
68
  const assetStorageFromEnv = (): AssetStorageSettings => {
61
- const adapter = process.env.HANDOFF_ASSET_STORAGE_ADAPTER?.trim();
69
+ const provider = process.env.HANDOFF_ASSET_STORAGE_PROVIDER?.trim();
62
70
  let options: Record<string, unknown> | undefined;
63
71
  const rawOptions = process.env.HANDOFF_ASSET_STORAGE_OPTIONS?.trim();
64
72
  if (rawOptions) {
@@ -71,22 +79,33 @@ const assetStorageFromEnv = (): AssetStorageSettings => {
71
79
  }
72
80
  const maxInline = Number(process.env.HANDOFF_ASSET_STORAGE_MAX_INLINE_BYTES);
73
81
  return {
74
- adapter: adapter === 'vercel-blob' || adapter === 'custom' ? adapter : DEFAULT_ASSET_STORAGE_ADAPTER,
82
+ provider: provider === 'custom' ? provider : DEFAULT_ASSET_STORAGE_PROVIDER,
75
83
  module: process.env.HANDOFF_ASSET_STORAGE_MODULE?.trim() || undefined,
76
- tokenEnv: process.env.HANDOFF_ASSET_STORAGE_TOKEN_ENV?.trim() || undefined,
77
84
  maxInlineBytes: Number.isFinite(maxInline) && maxInline > 0 ? maxInline : undefined,
78
85
  options,
79
86
  };
80
87
  };
81
88
 
89
+ /**
90
+ * Resolve the AI settings from the baked flag and connection list, then merge the
91
+ * deployment-supplied `HANDOFF_AI_CONNECTIONS` over it by id.
92
+ *
93
+ * `HANDOFF_AI_CONNECTIONS` is read dynamically rather than as a literal `process.env` reference: the
94
+ * baked values in `next.config.mjs` are inlined at build time, so a name in that block can never
95
+ * carry a deploy-time value. That is why the baked list uses its own `HANDOFF_AI_BAKED_CONNECTIONS`.
96
+ */
97
+ const aiFromEnv = (): AiSettings => ({
98
+ enabled: process.env.HANDOFF_AI_ENABLED?.trim() === 'true',
99
+ connections: mergeAiConnections(
100
+ parseAiConnections(process.env.HANDOFF_AI_BAKED_CONNECTIONS),
101
+ parseAiConnections(process.env['HANDOFF_AI_CONNECTIONS'])
102
+ ),
103
+ defaultModel: process.env.HANDOFF_AI_DEFAULT_MODEL?.trim() || undefined,
104
+ });
105
+
82
106
  /** Absolute path of the server-only runtime config persisted next to `client.config.json`. */
83
107
  const serverRuntimeConfigPath = (): string =>
84
- path.resolve(
85
- process.env.HANDOFF_MODULE_PATH ?? '',
86
- '.handoff',
87
- process.env.HANDOFF_PROJECT_ID ?? '',
88
- 'runtime.server.json'
89
- );
108
+ path.resolve(process.env.HANDOFF_MODULE_PATH ?? '', '.handoff', process.env.HANDOFF_PROJECT_ID ?? '', 'runtime.server.json');
90
109
 
91
110
  /**
92
111
  * Resolve the server-side runtime config from the values baked into the bundle at build time
@@ -107,11 +126,18 @@ const fromEnv = (): ServerRuntimeConfig | null => {
107
126
  return {
108
127
  mode: mode === 'registry' ? 'registry' : 'workspace',
109
128
  mcp: process.env.HANDOFF_MCP_ENABLED?.trim() !== 'false',
129
+ ai: aiFromEnv(),
110
130
  registry: {
111
131
  driver: process.env.HANDOFF_REGISTRY_DRIVER?.trim() === 'neon' ? 'neon' : 'pg',
112
132
  databaseUrlEnv,
113
133
  },
114
134
  assetStorage: assetStorageFromEnv(),
135
+ email: parseEmailSettings({
136
+ from: process.env.HANDOFF_EMAIL_FROM,
137
+ provider: process.env.HANDOFF_EMAIL_PROVIDER?.trim(),
138
+ module: process.env.HANDOFF_EMAIL_MODULE,
139
+ options: process.env.HANDOFF_EMAIL_OPTIONS,
140
+ }),
115
141
  };
116
142
  };
117
143
 
@@ -141,17 +167,17 @@ export const getServerRuntimeConfig = (): ServerRuntimeConfig => {
141
167
  ? parsed.registry.databaseUrlEnv.trim()
142
168
  : DEFAULT_DATABASE_URL_ENV;
143
169
  const assetStorage: AssetStorageSettings =
144
- parsed?.assetStorage && typeof parsed.assetStorage === 'object'
145
- ? parsed.assetStorage
146
- : { adapter: DEFAULT_ASSET_STORAGE_ADAPTER };
170
+ parsed?.assetStorage && typeof parsed.assetStorage === 'object' ? parsed.assetStorage : { provider: DEFAULT_ASSET_STORAGE_PROVIDER };
147
171
  cached = {
148
172
  mode: parsed?.mode === 'registry' ? 'registry' : 'workspace',
149
173
  mcp: parsed?.mcp !== false,
174
+ ai: aiFromEnv(),
150
175
  registry: {
151
176
  driver: parsed?.registry?.driver === 'neon' ? 'neon' : 'pg',
152
177
  databaseUrlEnv,
153
178
  },
154
179
  assetStorage,
180
+ email: parseEmailSettings(parsed?.email ?? {}),
155
181
  };
156
182
  return cached;
157
183
  }