handoff-app 2.0.0-9 → 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 (303) hide show
  1. package/{Changelog.md → CHANGELOG.md} +85 -1
  2. package/{License.md → LICENSE.md} +1 -1
  3. package/README.md +813 -406
  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/cli/create.js +1 -2
  55. package/dist/commands/build/index.d.ts +1 -1
  56. package/dist/commands/build/index.js +1 -1
  57. package/dist/commands/utils.d.ts +3 -3
  58. package/dist/config/defaults.js +7 -4
  59. package/dist/config/from-env.d.ts +4 -0
  60. package/dist/config/from-env.js +38 -1
  61. package/dist/config/helpers.js +2 -2
  62. package/dist/config/index.d.ts +0 -1
  63. package/dist/config/index.js +1 -4
  64. package/dist/config/loader.js +26 -2
  65. package/dist/config/resolve-env.d.ts +2 -1
  66. package/dist/config/resolve-env.js +28 -12
  67. package/dist/config/runtime.js +1 -1
  68. package/dist/index.d.ts +12 -7
  69. package/dist/index.js +18 -22
  70. package/dist/nav/index.d.ts +2 -2
  71. package/dist/nav/registry.d.ts +3 -3
  72. package/dist/nav/registry.js +1 -1
  73. package/dist/nav/workspace.d.ts +3 -3
  74. package/dist/nav/workspace.js +1 -1
  75. package/dist/pipeline/figma.d.ts +13 -2
  76. package/dist/pipeline/figma.js +86 -46
  77. package/dist/pipeline/styles.js +1 -1
  78. package/dist/pipeline/validation.d.ts +1 -1
  79. package/dist/pipeline/validation.js +3 -3
  80. package/dist/providers.d.ts +10 -0
  81. package/dist/providers.js +13 -0
  82. package/dist/registry/asset-storage/define.d.ts +5 -5
  83. package/dist/registry/asset-storage/define.js +4 -4
  84. package/dist/registry/asset-storage/resolve.d.ts +9 -19
  85. package/dist/registry/asset-storage/resolve.js +33 -21
  86. package/dist/registry/asset-storage/types.d.ts +37 -17
  87. package/dist/registry/asset-storage/types.js +5 -5
  88. package/dist/registry/auth/ai-keys.d.ts +32 -0
  89. package/dist/registry/auth/ai-keys.js +69 -0
  90. package/dist/registry/auth/crypto.d.ts +17 -0
  91. package/dist/registry/auth/crypto.js +61 -1
  92. package/dist/registry/auth/index.d.ts +2 -1
  93. package/dist/registry/auth/index.js +11 -2
  94. package/dist/registry/checkout/index.d.ts +2 -1
  95. package/dist/registry/checkout/index.js +57 -127
  96. package/dist/registry/client.js +53 -17
  97. package/dist/registry/db/driver.js +3 -3
  98. package/dist/registry/db/schema.d.ts +261 -3
  99. package/dist/registry/db/schema.js +29 -4
  100. package/dist/registry/email/define.d.ts +10 -0
  101. package/dist/registry/email/define.js +13 -0
  102. package/dist/registry/email/providers/smtp.d.ts +10 -0
  103. package/dist/registry/email/providers/smtp.js +73 -0
  104. package/dist/registry/email/resolve.d.ts +28 -0
  105. package/dist/registry/email/resolve.js +74 -0
  106. package/dist/registry/email/types.d.ts +24 -0
  107. package/dist/registry/email/types.js +6 -0
  108. package/dist/registry/path.d.ts +2 -0
  109. package/dist/registry/path.js +11 -1
  110. package/dist/registry/publish/package.js +8 -0
  111. package/dist/registry/transfer.d.ts +4 -0
  112. package/dist/store/registry.d.ts +3 -3
  113. package/dist/store/registry.js +4 -4
  114. package/dist/transformers/preview/component/builder.d.ts +1 -9
  115. package/dist/transformers/preview/component/builder.js +17 -17
  116. package/dist/transformers/preview/component/css.d.ts +2 -1
  117. package/dist/transformers/preview/component/css.js +8 -5
  118. package/dist/transformers/preview/component/html.d.ts +3 -1
  119. package/dist/transformers/preview/component/html.js +6 -2
  120. package/dist/transformers/preview/component/javascript.d.ts +2 -1
  121. package/dist/transformers/preview/component/javascript.js +10 -4
  122. package/dist/transformers/utils/build.d.ts +5 -0
  123. package/dist/transformers/utils/build.js +21 -4
  124. package/dist/types/config.d.ts +129 -18
  125. package/dist/utils/path.d.ts +1 -5
  126. package/dist/utils/path.js +6 -23
  127. package/docs/api.md +6 -4
  128. package/docs/cli.md +275 -54
  129. package/drizzle/0007_add_ai_keys.sql +11 -0
  130. package/drizzle/0008_add_source_dir.sql +2 -0
  131. package/drizzle/meta/0007_snapshot.json +2013 -0
  132. package/drizzle/meta/0008_snapshot.json +2025 -0
  133. package/drizzle/meta/_journal.json +14 -0
  134. package/package.json +19 -8
  135. package/src/ai/README.md +42 -0
  136. package/src/ai/connections.ts +189 -0
  137. package/src/ai/define.ts +15 -0
  138. package/src/ai/types.ts +30 -0
  139. package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
  140. package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
  141. package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
  142. package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
  143. package/src/app/components/AiAssistant/dockState.ts +86 -0
  144. package/src/app/components/AiAssistant/toolLabel.ts +24 -0
  145. package/src/app/components/AiAssistant/useAiChat.ts +147 -0
  146. package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
  147. package/src/app/components/AiAssistant/usePageContext.ts +63 -0
  148. package/src/app/components/Auth/AccountLayout.tsx +58 -19
  149. package/src/app/components/Auth/AuthShell.tsx +27 -33
  150. package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
  151. package/src/app/components/Auth/installStatus.ts +29 -0
  152. package/src/app/components/Component/Preview.tsx +5 -2
  153. package/src/app/components/Foundations/ColorGrid.tsx +13 -14
  154. package/src/app/components/Layout/Header.tsx +21 -15
  155. package/src/app/components/Layout/Main.tsx +2 -2
  156. package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
  157. package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
  158. package/src/app/components/Navigation/MobileNav.tsx +2 -2
  159. package/src/app/components/Navigation/SideNav.tsx +1 -15
  160. package/src/app/components/context/NavProvider.tsx +7 -3
  161. package/src/app/components/ui/attachment.tsx +50 -0
  162. package/src/app/components/ui/bubble.tsx +57 -0
  163. package/src/app/components/ui/button.tsx +3 -1
  164. package/src/app/components/ui/confirm-dialog.tsx +87 -0
  165. package/src/app/components/ui/dialog.tsx +7 -2
  166. package/src/app/components/ui/marker.tsx +56 -0
  167. package/src/app/components/ui/sidebar.tsx +3 -1
  168. package/src/app/components/util/index.ts +3 -12
  169. package/src/app/css/index.css +64 -0
  170. package/src/app/lib/ai/auth.ts +33 -0
  171. package/src/app/lib/ai/context.ts +99 -0
  172. package/src/app/lib/ai/model.ts +79 -0
  173. package/src/app/lib/ai/prompt.ts +36 -0
  174. package/src/app/lib/ai/resolve.ts +97 -0
  175. package/src/app/lib/ai/tools.ts +120 -0
  176. package/src/app/lib/api/methods.ts +13 -0
  177. package/src/app/lib/asset-storage.ts +35 -36
  178. package/src/app/lib/auth/api.ts +4 -12
  179. package/src/app/lib/auth/email.ts +96 -23
  180. package/src/app/lib/docs-api/page-rendering.ts +1 -1
  181. package/src/app/lib/docs-api/registry.ts +2 -3
  182. package/src/app/lib/docs-api/runtime-config.ts +47 -21
  183. package/src/app/lib/mcp/components.ts +523 -0
  184. package/src/app/lib/mcp/page-url.ts +24 -0
  185. package/src/app/lib/mcp/pages.ts +84 -0
  186. package/src/app/lib/mcp/result.ts +33 -0
  187. package/src/app/lib/mcp/server.ts +39 -318
  188. package/src/app/lib/mcp/tokens.ts +334 -0
  189. package/src/app/lib/mcp/tool.ts +19 -0
  190. package/src/app/lib/registry-api/asset-transfer.ts +119 -11
  191. package/src/app/lib/registry-api/errors.ts +2 -0
  192. package/src/app/lib/registry-api/handler.ts +7 -1
  193. package/src/app/lib/registry-api/transfer.ts +28 -6
  194. package/src/app/lib/server-module.ts +34 -0
  195. package/src/app/lib/utils.ts +20 -0
  196. package/src/app/next.config.mjs +26 -24
  197. package/src/app/pages/[...slug]/index.tsx +2 -1
  198. package/src/app/pages/_app.tsx +6 -2
  199. package/src/app/pages/_document.tsx +6 -0
  200. package/src/app/pages/account/ai.tsx +183 -0
  201. package/src/app/pages/account/index.tsx +68 -68
  202. package/src/app/pages/account/tokens.tsx +37 -7
  203. package/src/app/pages/account/users.tsx +75 -15
  204. package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
  205. package/src/app/pages/api/account/ai/keys/index.ts +20 -0
  206. package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
  207. package/src/app/pages/api/admin/users/index.ts +2 -2
  208. package/src/app/pages/api/ai/chat.ts +115 -0
  209. package/src/app/pages/api/ai/connections.ts +23 -0
  210. package/src/app/pages/api/auth/request-reset.ts +14 -16
  211. package/src/app/pages/api/docs/nav.json.ts +1 -1
  212. package/src/app/pages/api/install.ts +3 -3
  213. package/src/app/pages/api/mcp.ts +6 -0
  214. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
  215. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
  216. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
  217. package/src/app/pages/cli/device.tsx +10 -5
  218. package/src/app/pages/foundations/colors.tsx +9 -8
  219. package/src/app/pages/foundations/index.tsx +3 -3
  220. package/src/app/pages/index.tsx +2 -2
  221. package/src/app/pages/install.tsx +7 -2
  222. package/src/app/pages/login.tsx +32 -27
  223. package/src/app/pages/reset-password.tsx +21 -20
  224. package/src/app/pages/system/component/[component]/index.tsx +2 -5
  225. package/src/app/pages/system/index.tsx +11 -11
  226. package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
  227. package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
  228. package/src/app-builder/README.md +2 -1
  229. package/src/app-builder/build.ts +218 -73
  230. package/src/app-builder/client-config.ts +3 -2
  231. package/src/app-builder/paths.ts +3 -22
  232. package/src/app-builder/static-export.ts +1 -1
  233. package/src/app-builder/vercel-output.ts +5 -4
  234. package/src/cache/README.md +4 -1
  235. package/src/cache/build-cache.ts +128 -43
  236. package/src/cache/build-inputs.ts +28 -0
  237. package/src/cache/index.ts +4 -0
  238. package/src/cli/create.ts +1 -2
  239. package/src/commands/build/index.ts +1 -1
  240. package/src/config/README.md +10 -9
  241. package/src/config/defaults.ts +7 -4
  242. package/src/config/from-env.ts +33 -0
  243. package/src/config/helpers.ts +1 -3
  244. package/src/config/index.ts +0 -3
  245. package/src/config/loader.ts +25 -3
  246. package/src/config/resolve-env.ts +28 -15
  247. package/src/config/runtime.ts +1 -1
  248. package/src/index.ts +33 -29
  249. package/src/nav/index.ts +2 -2
  250. package/src/nav/registry.ts +3 -3
  251. package/src/nav/workspace.ts +3 -3
  252. package/src/pipeline/README.md +1 -1
  253. package/src/pipeline/figma.ts +98 -49
  254. package/src/pipeline/styles.ts +1 -1
  255. package/src/pipeline/validation.ts +3 -3
  256. package/src/providers.ts +18 -0
  257. package/src/registry/asset-storage/define.ts +6 -6
  258. package/src/registry/asset-storage/resolve.ts +38 -37
  259. package/src/registry/asset-storage/types.ts +39 -17
  260. package/src/registry/auth/ai-keys.ts +62 -0
  261. package/src/registry/auth/crypto.ts +63 -1
  262. package/src/registry/auth/index.ts +11 -1
  263. package/src/registry/checkout/index.ts +60 -138
  264. package/src/registry/client.ts +60 -20
  265. package/src/registry/db/driver.ts +2 -2
  266. package/src/registry/db/schema.ts +49 -22
  267. package/src/registry/email/define.ts +15 -0
  268. package/src/registry/email/providers/smtp.ts +39 -0
  269. package/src/registry/email/resolve.ts +94 -0
  270. package/src/registry/email/types.ts +28 -0
  271. package/src/registry/path.ts +11 -0
  272. package/src/registry/publish/package.ts +9 -0
  273. package/src/registry/transfer.ts +4 -0
  274. package/src/store/registry.ts +7 -7
  275. package/src/transformers/preview/component/builder.ts +19 -25
  276. package/src/transformers/preview/component/css.ts +9 -4
  277. package/src/transformers/preview/component/html.ts +6 -2
  278. package/src/transformers/preview/component/javascript.ts +14 -4
  279. package/src/transformers/utils/build.ts +16 -3
  280. package/src/types/config.ts +142 -20
  281. package/src/utils/README.md +1 -1
  282. package/src/utils/path.ts +6 -29
  283. package/templates/default/handoff.config.js.tpl +10 -2
  284. package/templates/default/handoff.config.ts.tpl +10 -2
  285. package/templates/default/handoff.config.with-examples.js.tpl +10 -2
  286. package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
  287. package/templates/default/package.json.tpl +2 -2
  288. package/.editorconfig +0 -19
  289. package/.eslintrc.json +0 -4
  290. package/.prettierignore +0 -0
  291. package/.prettierrc.json +0 -8
  292. package/.vscode/settings.json +0 -17
  293. package/dist/config/validator.d.ts +0 -9
  294. package/dist/config/validator.js +0 -24
  295. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
  296. package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
  297. package/src/app/components/old/Header.tsx +0 -89
  298. package/src/app/lib/mcp/shape.ts +0 -596
  299. package/src/app/pages/assets/fonts.tsx +0 -129
  300. package/src/app/pages/assets/index.tsx +0 -91
  301. package/src/app/pages/assets/logos.tsx +0 -75
  302. package/src/config/validator.ts +0 -22
  303. package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
@@ -0,0 +1,97 @@
1
+ import { aiCredentialKind, parseAiModelId, type AiConnectionSettings } from '@handoff/ai/connections';
2
+ import type { AiCredentialKind } from '@handoff/types/config';
3
+ import { getServerRuntimeConfig } from '../docs-api/runtime-config';
4
+
5
+ /**
6
+ * Per-request AI connection resolution.
7
+ *
8
+ * Reports what the config declared and whether a usable key exists for the current reader. The key
9
+ * value itself never leaves the server: the browser only ever learns `configured`.
10
+ */
11
+
12
+ /** One connection as the browser sees it. */
13
+ export interface ResolvedAiConnection {
14
+ id: string;
15
+ label: string;
16
+ /** Absent for a connection served by a `defineAiProvider()` module rather than a base URL. */
17
+ baseUrl?: string;
18
+ models?: string[];
19
+ credential: AiCredentialKind;
20
+ /**
21
+ * Whether this reader can use the connection right now: the deployment's key is set, the endpoint
22
+ * needs none, or the reader has saved their own.
23
+ */
24
+ configured: boolean;
25
+ }
26
+
27
+ /** Find a declared connection by id. Ids only ever come from the resolved list, never from a reader. */
28
+ export const findAiConnection = (connectionId: string): AiConnectionSettings | undefined =>
29
+ getServerRuntimeConfig().ai.connections.find((connection) => connection.id === connectionId);
30
+
31
+ /**
32
+ * Read this reader's saved connection ids. Registry mode only — a workspace has one user, whose
33
+ * config file and `.env` are their user layer, so it has no per-user key store.
34
+ *
35
+ * The registry data layer is imported lazily so the Drizzle/Postgres dependencies stay out of the
36
+ * workspace path, the same way the rest of the registry runtime is reached.
37
+ */
38
+ const readSavedConnections = async (userId: string | null): Promise<Set<string>> => {
39
+ if (!userId || getServerRuntimeConfig().mode !== 'registry') return new Set();
40
+ const [{ getRegistryConnection }, { listRegistryAiKeyConnections }] = await Promise.all([
41
+ import('../registry-connection'),
42
+ import('@handoff/registry/auth'),
43
+ ]);
44
+ const { db } = await getRegistryConnection();
45
+ return new Set(await listRegistryAiKeyConnections(db, userId));
46
+ };
47
+
48
+ /** Whether a connection is usable for a reader, given the keys that reader has saved. */
49
+ const isConfigured = (connection: AiConnectionSettings, saved: ReadonlySet<string>): boolean => {
50
+ switch (aiCredentialKind(connection)) {
51
+ case 'service':
52
+ return Boolean(connection.apiKeyEnv && process.env[connection.apiKeyEnv]?.trim());
53
+ case 'user':
54
+ return saved.has(connection.id);
55
+ default:
56
+ return true;
57
+ }
58
+ };
59
+
60
+ /** Every declared connection, each marked with whether this reader can use it. */
61
+ export const describeAiConnections = async (userId: string | null): Promise<ResolvedAiConnection[]> => {
62
+ const { ai } = getServerRuntimeConfig();
63
+ const saved = await readSavedConnections(userId);
64
+ return ai.connections.map((connection) => ({
65
+ id: connection.id,
66
+ label: connection.label,
67
+ ...(connection.baseUrl ? { baseUrl: connection.baseUrl } : {}),
68
+ ...(connection.models?.length ? { models: connection.models } : {}),
69
+ credential: aiCredentialKind(connection),
70
+ configured: isConfigured(connection, saved),
71
+ }));
72
+ };
73
+
74
+ /** The connections a reader may add a key for. Empty when the config declares none. */
75
+ export const describeAiUserConnections = async (userId: string | null): Promise<ResolvedAiConnection[]> =>
76
+ (await describeAiConnections(userId)).filter((connection) => connection.credential === 'user');
77
+
78
+ /**
79
+ * The model to run, given what the reader asked for. A requested model must name a declared
80
+ * connection and one of that connection's declared models, so a reader can never reach a model the
81
+ * deployment did not list. Falls back to `defaultModel`, then to the first usable model.
82
+ */
83
+ export const selectAiModel = (
84
+ connections: readonly ResolvedAiConnection[],
85
+ requested: string | undefined
86
+ ): { connection: ResolvedAiConnection; model: string } | null => {
87
+ const usable = connections.filter((connection) => connection.configured && connection.models?.length);
88
+ const pick = (value: string | undefined) => {
89
+ const parsed = value ? parseAiModelId(value) : null;
90
+ if (!parsed) return null;
91
+ const connection = usable.find((candidate) => candidate.id === parsed.connectionId);
92
+ return connection?.models?.includes(parsed.model) ? { connection, model: parsed.model } : null;
93
+ };
94
+ const { ai } = getServerRuntimeConfig();
95
+ const first = usable[0];
96
+ return pick(requested) ?? pick(ai.defaultModel) ?? (first?.models?.[0] ? { connection: first, model: first.models[0] } : null);
97
+ };
@@ -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() {