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
@@ -6,7 +6,8 @@
6
6
  * into the local workspace in standard authoring form. The local **declaration is synthesized
7
7
  * locally** in the configured `runtime.workspace.declarationFormat` — declarations are never read
8
8
  * from the registry (they are workspace-only). Identity is matched by stable `id`, so a checked-out
9
- * entity maps to the correct logical component/pattern and can be re-authored and re-published.
9
+ * entity maps to the correct logical component/pattern and can be re-authored and re-published. A
10
+ * new entity goes to the directory it was published from (`sourceDir`).
10
11
  *
11
12
  * Available only from a connected workspace (`runtime.mode: workspace` + a configured
12
13
  * `registryConnection`). Overwriting existing local files is explicit: a `--force` flag or an
@@ -30,14 +31,14 @@ import {
30
31
  type RendererKind,
31
32
  type SourceFormat,
32
33
  } from '../../catalog/renderers';
33
- import { addToCatalog, isIncluded } from '../../config/catalog-include';
34
34
  import { isComponentDirectory, resolveComponentDeclaration } from '../../config/runtime';
35
35
  import Handoff from '../../index';
36
36
  import type { DeclarationFormat } from '../../types/config';
37
37
  import { Logger } from '../../utils/logger';
38
+ import { arePathsEqual } from '../../utils/path';
38
39
  import { createRegistryClient, type RegistryClient, RegistryClientError } from '../client';
39
40
  import { loginCommandHint, resolveAuthenticatedRegistryConnection, unusedLoginNote } from '../connection';
40
- import { isSafePathSegment, isSafeRelativePath, resolvePathWithin } from '../path';
41
+ import { isSafePathSegment, isSafeRelativePath, isWorkspaceRelativePath, resolvePathWithin } from '../path';
41
42
  import { quoteIds, selectIds, splitCatalogIds } from '../selection';
42
43
  import type { CheckoutPayload, TransferEntityKind, TransferFile } from '../transfer';
43
44
 
@@ -52,12 +53,7 @@ export class CheckoutError extends Error {
52
53
  /** Default authoring format synthesized when no existing file or configured preference applies. */
53
54
  const DEFAULT_DECLARATION_FORMAT: DeclarationFormat = 'ts';
54
55
 
55
- /** Default workspace subdirectory an entity is written into when none is configured. */
56
- const DEFAULT_ENTITY_DIR: Record<TransferEntityKind, string> = {
57
- component: 'components',
58
- pattern: 'patterns',
59
- page: 'pages',
60
- };
56
+ const PAGES_DIR = 'pages';
61
57
 
62
58
  /** Source-file extensions stripped to form a module specifier for a React component import. */
63
59
  const COMPONENT_EXTENSION = /\.(tsx|jsx|ts|js|cjs|mjs)$/i;
@@ -108,88 +104,48 @@ const describeFetchFailure = (error: RegistryClientError, kind: TransferEntityKi
108
104
  }
109
105
  };
110
106
 
111
- /**
112
- * Ask the user which collection directory a new entity should land in when the config is ambiguous.
113
- * Under `--force` (or any non-interactive run) there's no one to ask, so we fail with an actionable
114
- * message rather than guessing a root.
115
- */
116
- const promptForCollectionRoot = async (handoff: Handoff, kind: TransferEntityKind, roots: string[]): Promise<string> => {
117
- const relative = (root: string) => path.relative(handoff.workingPath, root) || '.';
118
- if (handoff.force) {
119
- throw new CheckoutError(
120
- `Cannot determine where to checkout the ${kind}: catalog.include declares directories under different ` +
121
- `parents (${roots.map(relative).join(', ')}), and none is named "${DEFAULT_ENTITY_DIR[kind]}". Declare a single ` +
122
- `collection directory in handoff.config, or run checkout without --force to choose interactively.`
123
- );
124
- }
125
- const choice = await p.select({
126
- message: `Where should the new ${kind} be checked out?`,
127
- options: roots.map((root) => ({ value: root, label: relative(root) })),
128
- initialValue: roots[0],
107
+ /** `dir` is a `catalog.include` entry, or a direct child of a collection entry. */
108
+ const isInCatalog = (handoff: Handoff, dir: string): boolean =>
109
+ (handoff.config?.catalog?.include ?? []).some((entry) => {
110
+ const included = path.resolve(handoff.workingPath, entry);
111
+ return arePathsEqual(included, dir) || (!isComponentDirectory(included) && arePathsEqual(included, path.dirname(dir)));
129
112
  });
130
- if (p.isCancel(choice)) {
131
- throw new CheckoutError('Checkout cancelled; no target directory chosen.');
132
- }
133
- return choice as string;
134
- };
135
-
136
- /**
137
- * Resolve the collection directory a new entity is cloned into from the configured
138
- * `catalog.include`. Each entry is either a collection directory (used as-is) or a
139
- * single declared entity directory, in which case its parent is the collection root so the new
140
- * entity lands as a sibling rather than nested inside. Different parents are settled by the
141
- * conventional directory name for the kind, and only then by asking the user. With nothing
142
- * configured we fall back to the default subdir.
143
- */
144
- const resolveCollectionRoot = async (handoff: Handoff, kind: TransferEntityKind): Promise<string> => {
145
- const configuredRoots = handoff.config?.catalog?.include;
146
- if (!configuredRoots?.length) {
147
- return path.resolve(handoff.workingPath, DEFAULT_ENTITY_DIR[kind]);
148
- }
149
-
150
- const roots = [
151
- ...new Set(
152
- configuredRoots.map((entry) => {
153
- const resolved = path.resolve(handoff.workingPath, entry);
154
- return isComponentDirectory(resolved) ? path.dirname(resolved) : resolved;
155
- })
156
- ),
157
- ];
158
-
159
- if (roots.length === 1) {
160
- return roots[0];
161
- }
162
113
 
163
- // `catalog.include` is one list for both lanes, so a project that registers `components` and
164
- // `patterns` offers two roots for every checkout. Without this, every such project prompts, and a
165
- // non-interactive `--force` run fails.
166
- const conventional = roots.filter((root) => path.basename(root) === DEFAULT_ENTITY_DIR[kind]);
167
- if (conventional.length === 1) {
168
- return conventional[0];
169
- }
170
-
171
- return promptForCollectionRoot(handoff, kind, roots);
172
- };
173
-
174
- /**
175
- * Resolve the local directory the entity is written into. An already-checked-out or locally-declared
176
- * entity keeps its existing source directory (matched by stable `id`); otherwise it lands as a
177
- * sibling under its configured collection root (see {@link resolveCollectionRoot}).
178
- */
179
- const resolveTargetDir = async (handoff: Handoff, kind: TransferEntityKind, id: string): Promise<string> => {
114
+ /** A locally declared entity keeps its directory. A new one goes to the directory it was published from. */
115
+ const resolveTargetDir = async (
116
+ handoff: Handoff,
117
+ kind: TransferEntityKind,
118
+ id: string,
119
+ sourceDir: string | undefined
120
+ ): Promise<{ dir: string; isInCatalog: boolean }> => {
180
121
  const store = kind === 'component' ? handoff.store.components : handoff.store.patterns;
181
122
  const existing = await store.get(id);
182
123
  const existingDir = (existing as { path?: string } | null)?.path;
183
124
  if (existingDir) {
184
- return existingDir;
125
+ return { dir: existingDir, isInCatalog: true };
185
126
  }
186
127
 
187
- const root = await resolveCollectionRoot(handoff, kind);
188
- const targetDir = resolvePathWithin(root, id);
189
- if (!targetDir) {
190
- throw new CheckoutError(`Cannot checkout ${kind} with unsafe id "${id}".`);
128
+ if (!isWorkspaceRelativePath(sourceDir)) {
129
+ throw new CheckoutError(`The registry has no valid source directory for ${kind} "${id}". Republish it, then checkout again.`);
130
+ }
131
+ const dir = path.resolve(handoff.workingPath, sourceDir);
132
+ return { dir, isInCatalog: isInCatalog(handoff, dir) };
133
+ };
134
+
135
+ /** `--force` fails here: it skips overwrite prompts, not approval of a location the registry chose. */
136
+ const confirmLocation = async (handoff: Handoff, kind: TransferEntityKind, id: string, dir: string): Promise<boolean> => {
137
+ const relative = path.relative(handoff.workingPath, dir) || '.';
138
+ if (handoff.force) {
139
+ throw new CheckoutError(
140
+ `The registry places ${kind} "${id}" at ${relative}, which is outside catalog.include. ` +
141
+ `Add "${relative}" or its parent directory to catalog.include, or run checkout without --force to accept the location.`
142
+ );
191
143
  }
192
- return targetDir;
144
+ const proceed = await p.confirm({
145
+ message: `The registry places ${kind} "${id}" at ${relative}, which is outside catalog.include. Write it there?`,
146
+ initialValue: false,
147
+ });
148
+ return !p.isCancel(proceed) && proceed === true;
193
149
  };
194
150
 
195
151
  /** Read the extension of an existing local declaration in `dir`, if any maps to a known format. */
@@ -761,7 +717,7 @@ export const reportPlannedWrites = (handoff: Handoff, label: string, targets: st
761
717
  * markdown file is itself the authored source — so the page round-trips byte-for-byte.
762
718
  */
763
719
  const checkoutPage = async (handoff: Handoff, id: string, payload: CheckoutPayload): Promise<void> => {
764
- const pagesRoot = path.resolve(handoff.workingPath, DEFAULT_ENTITY_DIR.page);
720
+ const pagesRoot = path.resolve(handoff.workingPath, PAGES_DIR);
765
721
  const targets = payload.files.map((file) => {
766
722
  const target = resolvePathWithin(pagesRoot, file.path);
767
723
  if (!target) {
@@ -799,7 +755,7 @@ const checkoutSingle = async (
799
755
  id: string,
800
756
  client: RegistryClient,
801
757
  registryUrl: string
802
- ): Promise<string | null> => {
758
+ ): Promise<void> => {
803
759
  const validId = kind === 'page' ? isSafeRelativePath(id) : isSafePathSegment(id);
804
760
  if (!validId) {
805
761
  throw new CheckoutError(`Cannot checkout ${kind} with unsafe id "${id}".`);
@@ -816,14 +772,15 @@ const checkoutSingle = async (
816
772
  throw error;
817
773
  }
818
774
 
819
- // Pages round-trip as a single verbatim `.md` with no declaration synthesis, and aren't
820
- // listed in `catalog.include`, so there's nothing to register.
821
775
  if (kind === 'page') {
822
776
  await checkoutPage(handoff, id, payload);
823
- return null;
777
+ return;
824
778
  }
825
779
 
826
- const targetDir = await resolveTargetDir(handoff, kind, id);
780
+ const target = await resolveTargetDir(handoff, kind, id, payload.sourceDir);
781
+ const targetDir = target.dir;
782
+ const relativeDir = path.relative(handoff.workingPath, targetDir) || '.';
783
+ const outsideCatalogWarning = `Add "${relativeDir}" to catalog.include, or the build will not load ${kind} "${id}".`;
827
784
  const format = resolveDeclarationFormat(handoff, targetDir);
828
785
  const declarationFileName = resolveDeclarationFileName(targetDir, id, format);
829
786
  const declarationPath = path.join(targetDir, declarationFileName);
@@ -840,13 +797,19 @@ const checkoutSingle = async (
840
797
  const plannedWrites = [...sourceTargets.map((entry) => entry.absolutePath), declarationPath];
841
798
  const conflicts = plannedWrites.filter((file) => fs.existsSync(file));
842
799
  if (handoff.dryRun) {
843
- // Returning null also skips `registerCheckedOut`, so a dry run never edits handoff.config either.
844
800
  reportPlannedWrites(handoff, `${kind} "${id}"`, plannedWrites, conflicts);
845
- return null;
801
+ if (!target.isInCatalog) {
802
+ Logger.warn(`${relativeDir} is outside catalog.include. ${outsideCatalogWarning}`);
803
+ }
804
+ return;
805
+ }
806
+ if (!target.isInCatalog && !(await confirmLocation(handoff, kind, id, targetDir))) {
807
+ Logger.warn(`Checkout of ${kind} "${id}" cancelled; no files were written.`);
808
+ return;
846
809
  }
847
810
  if (!(await confirmOverwrite(handoff, conflicts))) {
848
811
  Logger.warn(`Checkout of ${kind} "${id}" cancelled; no files were written.`);
849
- return null;
812
+ return;
850
813
  }
851
814
 
852
815
  await fs.ensureDir(targetDir);
@@ -858,41 +821,10 @@ const checkoutSingle = async (
858
821
  await fs.writeFile(declarationPath, declaration, 'utf8');
859
822
  written.push(declarationPath);
860
823
 
861
- Logger.success(
862
- `Checked out ${kind} "${id}" into ${path.relative(handoff.workingPath, targetDir) || '.'} ` +
863
- `(${payload.files.length} source file(s) + ${declarationFileName}).`
864
- );
865
- return targetDir;
866
- };
867
-
868
- /**
869
- * Declare freshly checked-out entities in `catalog.include` so the workspace build
870
- * picks them up. Ones already covered by a collection directory load on their own and are left
871
- * alone; the rest are added to the config automatically. If the config can't be edited (a computed
872
- * or unusual `include` array), we print the paths for the user to add so nothing is silently orphaned.
873
- */
874
- const registerCheckedOut = async (handoff: Handoff, kind: TransferEntityKind, targetDirs: string[]): Promise<void> => {
875
- if (kind === 'page') {
876
- return;
877
- }
878
- const kindLabel = kind === 'component' ? 'components' : 'patterns';
879
- const unlisted = targetDirs.filter((dir) => !isIncluded(handoff, dir));
880
- if (unlisted.length === 0) {
881
- return;
882
- }
883
-
884
- const result = await addToCatalog(handoff, unlisted);
885
- if (result.status === 'added') {
886
- const where = result.configPath
887
- ? path.relative(handoff.workingPath, result.configPath) || path.basename(result.configPath)
888
- : 'handoff.config';
889
- Logger.success(`Updated ${where} with ${result.added.length} ${kindLabel} path(s).`);
890
- return;
824
+ Logger.success(`Checked out ${kind} "${id}" into ${relativeDir} (${payload.files.length} source file(s) + ${declarationFileName}).`);
825
+ if (!target.isInCatalog) {
826
+ Logger.warn(outsideCatalogWarning);
891
827
  }
892
-
893
- const where = result.configPath ? path.relative(handoff.workingPath, result.configPath) : 'handoff.config';
894
- Logger.warn(`Could not update ${where} automatically. Add these to catalog.include manually:`);
895
- result.pending.forEach((rel) => Logger.warn(` - ${rel}`));
896
828
  };
897
829
 
898
830
  /**
@@ -902,10 +834,7 @@ const registerCheckedOut = async (handoff: Handoff, kind: TransferEntityKind, ta
902
834
  export const checkoutEntity = async (handoff: Handoff, kind: TransferEntityKind, id: string): Promise<void> => {
903
835
  const connection = await resolveConnectionOrThrow(handoff);
904
836
  const client = createRegistryClient({ baseUrl: connection.url, accessToken: connection.accessToken });
905
- const targetDir = await checkoutSingle(handoff, kind, id, client, connection.url);
906
- if (targetDir) {
907
- await registerCheckedOut(handoff, kind, [targetDir]);
908
- }
837
+ await checkoutSingle(handoff, kind, id, client, connection.url);
909
838
  };
910
839
 
911
840
  /** List the ids the registry publishes for one kind, naming the registry in a listing failure. */
@@ -922,7 +851,7 @@ const listPublishedIds = async (kind: TransferEntityKind, client: RegistryClient
922
851
  };
923
852
 
924
853
  /**
925
- * Check out the given ids of one kind and register what landed. The caller selects the ids, because a
854
+ * Check out the given ids of one kind. The caller selects the ids, because a
926
855
  * catalog run resolves them across both entity lanes before any of them is written.
927
856
  *
928
857
  * A per-entity failure is collected and never aborts the rest. Failures are reported here, and the
@@ -937,22 +866,15 @@ const checkoutSelected = async (
937
866
  ): Promise<number> => {
938
867
  let checkedOut = 0;
939
868
  const failed: { id: string; message: string }[] = [];
940
- const targetDirs: string[] = [];
941
869
  for (const id of targets) {
942
870
  try {
943
- const targetDir = await checkoutSingle(handoff, kind, id, client, registryUrl);
944
- if (targetDir) {
945
- targetDirs.push(targetDir);
946
- }
871
+ await checkoutSingle(handoff, kind, id, client, registryUrl);
947
872
  checkedOut += 1;
948
873
  } catch (error) {
949
874
  failed.push({ id, message: error instanceof Error ? error.message : String(error) });
950
875
  }
951
876
  }
952
877
 
953
- // Register everything checked out in one pass, so the config is edited (and confirmed) once.
954
- await registerCheckedOut(handoff, kind, targetDirs);
955
-
956
878
  Logger.success(
957
879
  `${kind[0].toUpperCase()}${kind.slice(1)}s checkout complete — ${checkedOut} ${handoff.dryRun ? 'would be checked out' : 'checked out'}` +
958
880
  `${failed.length ? `, ${failed.length} failed` : ''}.`
@@ -11,6 +11,7 @@
11
11
  import type { CheckoutPayload, EntitySummary, TransferEntityKind, TransferPackage } from './transfer';
12
12
  import type { TokenSetCheckoutPayload, TokenSetSummary, TokenSetTransferPackage } from './tokens/transfer';
13
13
  import type { AssetCollectionCheckoutPayload, AssetCollectionSummary, AssetCollectionTransferPackage } from './assets/transfer';
14
+ import type { AssetUpload } from './asset-storage/types';
14
15
 
15
16
  /** Encode a token set id for the catch-all transfer route, preserving `/` between segments. */
16
17
  const encodeSetIdPath = (id: string): string =>
@@ -139,6 +140,30 @@ export const createRegistryClient = ({ baseUrl, accessToken }: RegistryClientOpt
139
140
  return envelope;
140
141
  };
141
142
 
143
+ const uploadAssetBlobThroughRegistry = async (blobPath: string, contentType: string, bytes: Buffer): Promise<void> => {
144
+ const url = `${root}${blobPath}`;
145
+ // Send a Blob, not a Buffer/Uint8Array: under Next `trailingSlash: true` this PUT is
146
+ // 308-redirected and undici must re-send the body; a typed-array body's ArrayBuffer is detached
147
+ // after the first send ("slice on a detached ArrayBuffer"), but a Blob is re-readable.
148
+ const body = new Blob([new Uint8Array(bytes)] as unknown as BlobPart[]);
149
+ const response = await fetchRegistry(url, {
150
+ method: 'PUT',
151
+ headers: {
152
+ 'Content-Type': contentType || 'application/octet-stream',
153
+ ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
154
+ },
155
+ body,
156
+ });
157
+ if (!response.ok) {
158
+ let code: string | undefined;
159
+ let message = `Blob upload failed (${response.status} ${response.statusText}).`;
160
+ const envelope = await readErrorEnvelope(response);
161
+ code = envelope?.error?.code;
162
+ message = envelope?.error?.message || message;
163
+ throw new RegistryClientError(message, { status: response.status, code });
164
+ }
165
+ };
166
+
142
167
  return {
143
168
  /** Publish (`PUT`) an entity's package to the registry transfer endpoint. */
144
169
  publish(kind: TransferEntityKind, id: string, pkg: TransferPackage): Promise<RegistryEnvelope> {
@@ -187,29 +212,44 @@ export const createRegistryClient = ({ baseUrl, accessToken }: RegistryClientOpt
187
212
  const envelope = await request<{ missing: string[] }>('POST', '/api/registry/transfer/assets/blobs/have', { hashes });
188
213
  return envelope.data?.missing ?? [];
189
214
  },
190
- /** Upload one content-addressed blob (binary-safe body). Idempotent: re-uploading an existing hash no-ops. */
215
+ /**
216
+ * Upload one content-addressed blob. Sends it directly to storage when the registry returns a
217
+ * signed URL, otherwise through the registry. Idempotent: re-uploading an existing hash no-ops.
218
+ */
191
219
  async uploadAssetBlob(hash: string, contentType: string, bytes: Buffer): Promise<void> {
192
- const url = `${root}/api/registry/transfer/assets/blobs/${encodeURIComponent(hash)}`;
193
- // Send a Blob, not a Buffer/Uint8Array: under Next `trailingSlash: true` this PUT is
194
- // 308-redirected and undici must re-send the body; a typed-array body's ArrayBuffer is detached
195
- // after the first send ("slice on a detached ArrayBuffer"), but a Blob is re-readable.
196
- const body = new Blob([new Uint8Array(bytes)] as unknown as BlobPart[]);
197
- const response = await fetchRegistry(url, {
198
- method: 'PUT',
199
- headers: {
200
- 'Content-Type': contentType || 'application/octet-stream',
201
- ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
202
- },
203
- body,
204
- });
220
+ const blobPath = `/api/registry/transfer/assets/blobs/${encodeURIComponent(hash)}`;
221
+ const size = bytes.length;
222
+ const envelope = await request<{ upload: AssetUpload | null; stored?: boolean }>('POST', `${blobPath}/upload`, { size, contentType });
223
+ if (envelope.data?.stored) {
224
+ return;
225
+ }
226
+ const upload = envelope.data?.upload;
227
+ if (!upload) {
228
+ await uploadAssetBlobThroughRegistry(blobPath, contentType, bytes);
229
+ return;
230
+ }
231
+
232
+ // Name only the origin in errors: the signed query string must not reach logs.
233
+ const storage = new URL(upload.url).origin;
234
+ let response: Response;
235
+ try {
236
+ response = await fetch(upload.url, {
237
+ method: 'PUT',
238
+ headers: upload.headers,
239
+ body: new Blob([new Uint8Array(bytes)] as unknown as BlobPart[]),
240
+ });
241
+ } catch (error) {
242
+ const cause = (error as { cause?: { code?: string; message?: string } } | undefined)?.cause;
243
+ throw new RegistryClientError(`Could not reach asset storage at ${storage}: ${cause?.code || cause?.message || 'network error'}.`);
244
+ }
205
245
  if (!response.ok) {
206
- let code: string | undefined;
207
- let message = `Blob upload failed (${response.status} ${response.statusText}).`;
208
- const envelope = await readErrorEnvelope(response);
209
- code = envelope?.error?.code;
210
- message = envelope?.error?.message || message;
211
- throw new RegistryClientError(message, { status: response.status, code });
246
+ const detail = (await response.text().catch(() => '')).slice(0, 300);
247
+ throw new RegistryClientError(
248
+ `Asset storage at ${storage} rejected the upload of blob "${hash}" (${response.status} ${response.statusText})${detail ? `: ${detail}` : ''}.`,
249
+ { status: response.status }
250
+ );
212
251
  }
252
+ await request('POST', `${blobPath}/complete`, { storageRef: upload.storageRef, size, contentType });
213
253
  },
214
254
  /** Download one blob's raw bytes by hash (follows any provider redirect). */
215
255
  async downloadAssetBlob(hash: string): Promise<Buffer> {
@@ -36,7 +36,7 @@ export interface ResolvedRegistryDatabase {
36
36
 
37
37
  /** Resolve the env-var name holding the database URL (defaults to `DATABASE_URL`). */
38
38
  export const resolveDatabaseUrlEnv = (config: ResolvedConfig | null | undefined): string => {
39
- const configured = config?.runtime?.registry?.databaseUrl?.$env?.trim();
39
+ const configured = config?.runtime?.registry?.database?.url?.$env?.trim();
40
40
  return configured || DEFAULT_DATABASE_URL_ENV;
41
41
  };
42
42
 
@@ -58,7 +58,7 @@ export const resolveRegistryDatabase = (config: ResolvedConfig | null | undefine
58
58
  if (!connectionString) {
59
59
  throw new Error(
60
60
  `Registry database URL is not configured. Set the "${databaseUrlEnv}" environment variable to a ` +
61
- `PostgreSQL connection string (or change "runtime.registry.databaseUrl" to point at the env var you use).`
61
+ `PostgreSQL connection string (or change "runtime.registry.database.url" to point at the env var you use).`
62
62
  );
63
63
  }
64
64
 
@@ -30,7 +30,7 @@ import type { TokenSetKind } from '../tokens/sets';
30
30
 
31
31
  /**
32
32
  * PostgreSQL `bytea` column. Drizzle pg-core has no built-in binary type, so declare it once here.
33
- * Reads/writes are Node `Buffer`s. The default asset storage adapter keeps small/ordinary asset
33
+ * Reads/writes are Node `Buffer`s. The default asset storage provider keeps small/ordinary asset
34
34
  * bytes inline in this column; larger assets are offloaded to an object-storage `storageRef` instead.
35
35
  */
36
36
  const bytea = customType<{ data: Buffer; driverData: Buffer }>({
@@ -39,8 +39,8 @@ const bytea = customType<{ data: Buffer; driverData: Buffer }>({
39
39
  },
40
40
  });
41
41
 
42
- /** Where an asset blob's bytes physically live: the default DB adapter, Vercel Blob, or a custom id. */
43
- export type AssetStorageProvider = 'database' | 'vercel-blob' | (string & {});
42
+ /** Where an asset blob's bytes live: inline in the database (`database`) or in a custom provider (`custom`). */
43
+ export type AssetStorageProvider = 'database' | 'custom';
44
44
  import type { ComponentListObject, PageListObject, PatternComponentEntry, PatternListObject } from '../../transformers/preview/types';
45
45
 
46
46
  /** Registry account role. Administrators may issue write-scoped credentials. */
@@ -112,6 +112,8 @@ export const components = pgTable(
112
112
  sourceFormat: text('source_format'),
113
113
  tags: jsonb('tags').$type<string[]>(),
114
114
  categories: jsonb('categories').$type<string[]>(),
115
+ /** Workspace-relative directory the component was published from. */
116
+ sourceDir: text('source_dir'),
115
117
  /** Full normalized component record. */
116
118
  record: jsonb('record').$type<ComponentListObject>().notNull(),
117
119
  /** Registry-only review/catalog metadata (management-API allowlist; never a render input). */
@@ -165,6 +167,8 @@ export const patterns = pgTable(
165
167
  tags: jsonb('tags').$type<string[]>(),
166
168
  /** Pattern component references. */
167
169
  components: jsonb('components').$type<PatternComponentEntry[]>(),
170
+ /** Workspace-relative directory the pattern was published from. */
171
+ sourceDir: text('source_dir'),
168
172
  /** Full normalized pattern record. */
169
173
  record: jsonb('record').$type<PatternListObject>().notNull(),
170
174
  /** Registry-only review/catalog metadata (management-API allowlist; never a render input). */
@@ -378,23 +382,20 @@ export const tokenArtifacts = pgTable(
378
382
  * collection is its current manifest. Publish replaces those rows atomically, so readers always see a
379
383
  * complete manifest.
380
384
  */
381
- export const assetCollections = pgTable(
382
- 'asset_collections',
383
- {
384
- /** Stable collection id (`icons`|`logos`|`fonts`). Join key across stores. */
385
- collection: text('collection').primaryKey(),
386
- /** Deterministic content hash over the collection's `(path, contentHash)` manifest (skip-unchanged). */
387
- sourceHash: text('source_hash'),
388
- /** Build/publish status for the collection. */
389
- status: text('status').$type<RegistryBuildStatus>(),
390
- builtAt: timestamp('built_at', { withTimezone: true }),
391
- builderVersion: text('builder_version'),
392
- /** Registry-only review/catalog metadata (parity with other entities; management-API only). */
393
- metadata: jsonb('metadata').$type<RegistryReviewMetadata>(),
394
- createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
395
- updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
396
- }
397
- );
385
+ export const assetCollections = pgTable('asset_collections', {
386
+ /** Stable collection id (`icons`|`logos`|`fonts`). Join key across stores. */
387
+ collection: text('collection').primaryKey(),
388
+ /** Deterministic content hash over the collection's `(path, contentHash)` manifest (skip-unchanged). */
389
+ sourceHash: text('source_hash'),
390
+ /** Build/publish status for the collection. */
391
+ status: text('status').$type<RegistryBuildStatus>(),
392
+ builtAt: timestamp('built_at', { withTimezone: true }),
393
+ builderVersion: text('builder_version'),
394
+ /** Registry-only review/catalog metadata (parity with other entities; management-API only). */
395
+ metadata: jsonb('metadata').$type<RegistryReviewMetadata>(),
396
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
397
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
398
+ });
398
399
 
399
400
  /**
400
401
  * Logical asset metadata - the collection manifest. One row per asset (icon/logo SVG, sprite,
@@ -431,13 +432,13 @@ export const assets = pgTable(
431
432
  /**
432
433
  * Content-addressed binary blob store, shared across assets/collections (the `hash` is the blob
433
434
  * identity, so an unchanged file re-references the same row). Exactly one location is active per row:
434
- * inline `content` (bytea, default DB adapter) or an external `storageRef` resolved through the
435
+ * inline `content` (bytea, default `database` provider) or an external `storageRef` resolved through the
435
436
  * recorded `storageProvider`. Both nullable so DB- and object-backed blobs coexist.
436
437
  */
437
438
  export const assetBlobs = pgTable('asset_blobs', {
438
439
  /** SHA-256 of the blob bytes (hex). */
439
440
  hash: text('hash').primaryKey(),
440
- /** Which storage backed this blob when it was written (`database`|`vercel-blob`|custom id). */
441
+ /** Which storage backed this blob when it was written (`database`|custom id). */
441
442
  storageProvider: text('storage_provider').$type<AssetStorageProvider>().notNull(),
442
443
  /** Inline bytes; null when stored externally via `storageRef`. */
443
444
  content: bytea('content'),
@@ -575,6 +576,31 @@ export const registryDeviceAuthorizations = pgTable(
575
576
  ]
576
577
  );
577
578
 
579
+ /**
580
+ * One reader's key for one declared AI connection. Unlike every other credential here the value
581
+ * must be recoverable, because it is sent to the provider on that reader's behalf, so it is
582
+ * encrypted rather than hashed (see `../auth/crypto`). A row whose `connectionId` no longer appears
583
+ * in the config is ignored, not deleted: the connection may come back on the next deploy.
584
+ */
585
+ export const registryAiKeys = pgTable(
586
+ 'registry_ai_keys',
587
+ {
588
+ userId: text('user_id')
589
+ .notNull()
590
+ .references(() => registryUsers.id, { onDelete: 'cascade' }),
591
+ /** Id of the connection the config declares. Never a reader-supplied endpoint. */
592
+ connectionId: text('connection_id').notNull(),
593
+ /** Self-describing AES-256-GCM payload; the deployment secret it is keyed from is never stored. */
594
+ encryptedKey: text('encrypted_key').notNull(),
595
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
596
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
597
+ },
598
+ (table) => [
599
+ primaryKey({ columns: [table.userId, table.connectionId] }),
600
+ check('registry_ai_keys_connection_not_blank', sql`length(btrim(${table.connectionId})) > 0`),
601
+ ]
602
+ );
603
+
578
604
  /**
579
605
  * Fixed-window throttling counters. Callers pass a hash of the identifying value so raw emails and
580
606
  * IP addresses are not retained.
@@ -616,6 +642,7 @@ export const registrySchema = {
616
642
  registryInstallations,
617
643
  registryAuthActionTokens,
618
644
  registryAccessTokens,
645
+ registryAiKeys,
619
646
  registryDeviceAuthorizations,
620
647
  registryAuthRateLimits,
621
648
  };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Typed identity helper for custom email providers (mirrors `defineStorageProvider`): a consumer
3
+ * default-exports its result from a **server-only** module and points `runtime.registry.email.module`
4
+ * at it. It must be its own module (not an inline `defineConfig` function) so the build can trace it
5
+ * into the deployed registry. The default export may be the provider object, or a factory
6
+ * `(context) => EmailProvider` when it needs its options or env.
7
+ */
8
+
9
+ import type { EmailProvider, EmailProviderFactory } from './types';
10
+
11
+ export function defineEmailProvider(provider: EmailProvider): EmailProvider;
12
+ export function defineEmailProvider(factory: EmailProviderFactory): EmailProviderFactory;
13
+ export function defineEmailProvider(input: EmailProvider | EmailProviderFactory): EmailProvider | EmailProviderFactory {
14
+ return input;
15
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Built-in SMTP provider, selected with `runtime.registry.email.provider: "smtp"`.
3
+ *
4
+ * `nodemailer` is loaded lazily so it is only pulled into memory (and traced into the registry
5
+ * bundle) when this provider is selected. Options may come from environment variables, so numbers
6
+ * and booleans can arrive as strings.
7
+ */
8
+
9
+ import type { EmailProvider } from '../types';
10
+
11
+ const toNumber = (value: unknown): number | undefined => {
12
+ const number = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : NaN;
13
+ return Number.isFinite(number) ? number : undefined;
14
+ };
15
+
16
+ const toBoolean = (value: unknown): boolean | undefined =>
17
+ typeof value === 'boolean' ? value : typeof value === 'string' && value.trim() ? value.trim() === 'true' : undefined;
18
+
19
+ /** Build an SMTP provider from options whose references are already resolved. */
20
+ export const createSmtpProvider = (options: Record<string, unknown>): EmailProvider => {
21
+ const host = typeof options.host === 'string' ? options.host : '';
22
+ const configuredPort = toNumber(options.port);
23
+ const secure = toBoolean(options.secure) ?? (configuredPort === undefined || configuredPort === 465);
24
+ const port = configuredPort ?? (secure ? 465 : 587);
25
+ const user = typeof options.user === 'string' ? options.user : undefined;
26
+ const password = typeof options.password === 'string' ? options.password : undefined;
27
+
28
+ return {
29
+ async send({ from, to, subject, html, text }) {
30
+ if (!host) throw new Error('The SMTP host is not set.');
31
+ const { createTransport } = await import('nodemailer');
32
+ const transport = createTransport({ host, port, secure, auth: user ? { user, pass: password } : undefined });
33
+ const info = await transport.sendMail({ from, to, subject, html, text });
34
+ if (info.rejected?.length) {
35
+ throw new Error(`The SMTP server rejected the recipient: ${info.response}`);
36
+ }
37
+ },
38
+ };
39
+ };