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
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Server-only asset storage contract for object-backed providers (Vercel Blob + custom
3
- * {@link defineAssetStorage} modules), so callers need no provider-specific logic. The built-in
4
- * `database` provider is the inline default: bytes live in the `asset_blobs.content` bytea column,
5
- * read/written directly by the registry, so it needs no adapter. Object providers record a
6
- * `storageRef` + provider id so reads resolve back through whichever provider stored the blob.
2
+ * Server-only asset storage contract for custom {@link defineStorageProvider} object storage modules.
3
+ * The built-in `database` provider is the inline default: bytes live in the `asset_blobs.content`
4
+ * bytea column, read/written directly by the registry, so it needs no module. Object providers
5
+ * record a `storageRef` + provider id so reads resolve back through whichever provider stored the
6
+ * blob.
7
7
  */
8
8
 
9
9
  /** Input for storing one content-addressed blob. */
10
- export interface AssetStorageInput {
10
+ export interface StorageInput {
11
11
  /** SHA-256 of the bytes (hex), the content identity, usable as a stable object key. */
12
12
  hash: string;
13
13
  bytes: Buffer;
@@ -19,28 +19,50 @@ export interface AssetStorageInput {
19
19
  * How a provider returns stored content: raw bytes, a readable stream, or a signed/redirect URL the
20
20
  * client can fetch directly (used to avoid routing large payloads through the serverless function).
21
21
  */
22
- export type AssetStorageReadResult =
22
+ export type StorageReadResult =
23
23
  | { kind: 'bytes'; bytes: Buffer; contentType?: string }
24
24
  | { kind: 'stream'; stream: NodeJS.ReadableStream; contentType?: string }
25
25
  | { kind: 'redirect'; url: string };
26
26
 
27
- /** The pluggable storage adapter contract for object-backed asset providers. */
28
- export interface AssetStorage {
27
+ /** Input for a direct upload of one blob from the CLI to storage. */
28
+ export interface AssetUploadInput {
29
+ /** SHA-256 of the bytes (hex). */
30
+ hash: string;
31
+ size: number;
32
+ contentType: string;
33
+ }
34
+
35
+ /** A signed URL the CLI sends the blob to with `PUT` and the given headers. */
36
+ export interface AssetUpload {
37
+ url: string;
38
+ headers?: Record<string, string>;
39
+ /** The reference to persist on the blob's `asset_blobs` row after the upload. */
40
+ storageRef: string;
41
+ }
42
+
43
+ /** The pluggable storage contract for object-backed asset providers. */
44
+ export interface StorageProvider {
29
45
  /** Store one blob and return the reference to persist on its `asset_blobs` row. */
30
- put(input: AssetStorageInput): Promise<{ storageRef: string }>;
46
+ put(input: StorageInput): Promise<{ storageRef: string }>;
47
+ /**
48
+ * Return a signed URL so the CLI uploads the blob directly to storage instead of through the
49
+ * registry, which avoids serverless request size limits. The URL must make storage reject bytes
50
+ * that do not match `hash`, for example with a signed SHA-256 checksum header.
51
+ */
52
+ createUpload?(input: AssetUploadInput): Promise<AssetUpload>;
31
53
  /** Resolve stored content by its `storageRef`. */
32
- get(storageRef: string): Promise<AssetStorageReadResult>;
54
+ get(storageRef: string): Promise<StorageReadResult>;
33
55
  /** Delete stored content by its `storageRef`. Should be retryable/idempotent. */
34
56
  delete(storageRef: string): Promise<void>;
35
57
  }
36
58
 
37
- /** Context passed to a custom adapter factory: its non-secret config options + the process env. */
38
- export interface AssetStorageContext {
39
- /** Non-secret options from `runtime.registry.assetStorage.options` (bucket env-var names, region, …). */
59
+ /** Context passed to a custom provider factory: its config options and the process env. */
60
+ export interface StorageProviderContext {
61
+ /** `runtime.registry.assetStorage.options`, with each `fromEnv()` reference replaced by its value. */
40
62
  options: Record<string, unknown>;
41
- /** The process environment. Read secret *values* here by the env-var *names* carried in options. */
63
+ /** The process environment. */
42
64
  env: NodeJS.ProcessEnv;
43
65
  }
44
66
 
45
- /** A custom adapter may export the adapter directly or a factory that builds it from its context. */
46
- export type AssetStorageFactory = (context: AssetStorageContext) => AssetStorage | Promise<AssetStorage>;
67
+ /** A custom provider module may export the provider directly or a factory that builds it from its context. */
68
+ export type StorageProviderFactory = (context: StorageProviderContext) => StorageProvider | Promise<StorageProvider>;
@@ -0,0 +1,62 @@
1
+ import { and, eq } from 'drizzle-orm';
2
+ import type { RegistryDatabase } from '../db/client';
3
+ import { registryAiKeys } from '../db/schema';
4
+ import { decryptSecret, encryptSecret } from './crypto';
5
+
6
+ /**
7
+ * Reader-supplied keys for connections the config declares with `credential: 'user'`.
8
+ *
9
+ * The key is write-only across the API boundary: once saved, a read reports only that a key exists.
10
+ * Only the chat route ever decrypts one, and only to send it to the connection's own endpoint.
11
+ */
12
+
13
+ /**
14
+ * Ids of the connections this reader has a *usable* key for. A row that no longer decrypts — written
15
+ * under a rotated `HANDOFF_AI_KEY_SECRET` — is left out, so the account page and the assistant agree
16
+ * that no key is in place instead of offering a key the provider would never receive.
17
+ */
18
+ export const listRegistryAiKeyConnections = async (db: RegistryDatabase, userId: string): Promise<string[]> => {
19
+ const rows = await db
20
+ .select({ connectionId: registryAiKeys.connectionId, encryptedKey: registryAiKeys.encryptedKey })
21
+ .from(registryAiKeys)
22
+ .where(eq(registryAiKeys.userId, userId))
23
+ .orderBy(registryAiKeys.connectionId);
24
+ return rows.filter((row) => decryptSecret(row.encryptedKey) !== null).map((row) => row.connectionId);
25
+ };
26
+
27
+ /** Save or replace this reader's key for one connection. */
28
+ export const saveRegistryAiKey = async (
29
+ db: RegistryDatabase,
30
+ input: { userId: string; connectionId: string; key: string }
31
+ ): Promise<void> => {
32
+ const encryptedKey = encryptSecret(input.key);
33
+ await db
34
+ .insert(registryAiKeys)
35
+ .values({ userId: input.userId, connectionId: input.connectionId, encryptedKey })
36
+ .onConflictDoUpdate({
37
+ target: [registryAiKeys.userId, registryAiKeys.connectionId],
38
+ set: { encryptedKey, updatedAt: new Date() },
39
+ });
40
+ };
41
+
42
+ /** Remove this reader's key for one connection. Returns whether a row was there to remove. */
43
+ export const deleteRegistryAiKey = async (db: RegistryDatabase, input: { userId: string; connectionId: string }): Promise<boolean> => {
44
+ const rows = await db
45
+ .delete(registryAiKeys)
46
+ .where(and(eq(registryAiKeys.userId, input.userId), eq(registryAiKeys.connectionId, input.connectionId)))
47
+ .returning({ connectionId: registryAiKeys.connectionId });
48
+ return rows.length > 0;
49
+ };
50
+
51
+ /**
52
+ * Read back one reader's key. A row that no longer decrypts reads as absent, so the reader is told
53
+ * to add a key rather than shown a provider error.
54
+ */
55
+ export const readRegistryAiKey = async (db: RegistryDatabase, input: { userId: string; connectionId: string }): Promise<string | null> => {
56
+ const [row] = await db
57
+ .select({ encryptedKey: registryAiKeys.encryptedKey })
58
+ .from(registryAiKeys)
59
+ .where(and(eq(registryAiKeys.userId, input.userId), eq(registryAiKeys.connectionId, input.connectionId)))
60
+ .limit(1);
61
+ return row ? decryptSecret(row.encryptedKey) : null;
62
+ };
@@ -1,4 +1,4 @@
1
- import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
1
+ import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, timingSafeEqual } from 'node:crypto';
2
2
 
3
3
  export const hashSecret = (secret: string): string => createHash('sha256').update(secret, 'utf8').digest('hex');
4
4
 
@@ -11,3 +11,65 @@ export const secretHashMatches = (secret: string, expectedHash: string): boolean
11
11
  };
12
12
 
13
13
  export const normalizeEmail = (email: string): string => email.trim().toLowerCase();
14
+
15
+ /** Env var holding the deployment secret that reader-supplied AI keys are encrypted with. */
16
+ export const AI_KEY_SECRET_ENV = 'HANDOFF_AI_KEY_SECRET';
17
+
18
+ /** Shortest secret worth accepting. Matches the guidance the installer gives for `AUTH_SECRET`. */
19
+ const MIN_KEY_SECRET_LENGTH = 32;
20
+
21
+ /** Format marker, so a later algorithm is distinguishable from this one rather than guessed at. */
22
+ const ENCRYPTED_SECRET_VERSION = 'v1';
23
+
24
+ /** Thrown when a key must be encrypted but the deployment secret is missing or too short. */
25
+ export class MissingEncryptionSecretError extends Error {
26
+ constructor() {
27
+ super(
28
+ `Reader-supplied AI keys cannot be stored because ${AI_KEY_SECRET_ENV} is not set. Set it to a ` +
29
+ `random value of at least ${MIN_KEY_SECRET_LENGTH} characters, or declare no connection with ` +
30
+ `"credential: 'user'".`
31
+ );
32
+ this.name = 'MissingEncryptionSecretError';
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Derive the record key. A per-record salt means two rows holding the same provider key produce
38
+ * different ciphertexts, and the fixed `info` label keeps this key distinct from anything else the
39
+ * same secret is ever used for.
40
+ */
41
+ const deriveKey = (salt: Buffer): Buffer => {
42
+ const secret = process.env[AI_KEY_SECRET_ENV]?.trim();
43
+ if (!secret || secret.length < MIN_KEY_SECRET_LENGTH) throw new MissingEncryptionSecretError();
44
+ return Buffer.from(hkdfSync('sha256', Buffer.from(secret, 'utf8'), salt, 'handoff-ai-key', 32));
45
+ };
46
+
47
+ /**
48
+ * Encrypt a value that has to be read back, such as a reader's provider key. AES-256-GCM, with the
49
+ * salt, nonce and authentication tag carried in the payload so a row is self-describing.
50
+ */
51
+ export const encryptSecret = (plaintext: string): string => {
52
+ const salt = randomBytes(16);
53
+ const iv = randomBytes(12);
54
+ const cipher = createCipheriv('aes-256-gcm', deriveKey(salt), iv);
55
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
56
+ const parts = [salt, iv, cipher.getAuthTag(), encrypted].map((part) => part.toString('base64url'));
57
+ return [ENCRYPTED_SECRET_VERSION, ...parts].join('.');
58
+ };
59
+
60
+ /**
61
+ * Decrypt a value written by {@link encryptSecret}. Returns `null` for anything that does not
62
+ * authenticate — a payload in another format, or one written under a secret that has since been
63
+ * rotated — so a stale row reads as "no key saved" instead of failing the request.
64
+ */
65
+ export const decryptSecret = (payload: string): string | null => {
66
+ const [version, salt, iv, tag, encrypted] = payload.split('.');
67
+ if (version !== ENCRYPTED_SECRET_VERSION || !salt || !iv || !tag || !encrypted) return null;
68
+ try {
69
+ const decipher = createDecipheriv('aes-256-gcm', deriveKey(Buffer.from(salt, 'base64url')), Buffer.from(iv, 'base64url'));
70
+ decipher.setAuthTag(Buffer.from(tag, 'base64url'));
71
+ return Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64url')), decipher.final()]).toString('utf8');
72
+ } catch {
73
+ return null;
74
+ }
75
+ };
@@ -25,7 +25,17 @@ export {
25
25
  type ResendUserInvitationResult,
26
26
  type ConsumeAuthActionResult,
27
27
  } from './action-tokens';
28
- export { createOpaqueSecret, hashSecret, normalizeEmail, secretHashMatches } from './crypto';
28
+ export {
29
+ AI_KEY_SECRET_ENV,
30
+ MissingEncryptionSecretError,
31
+ createOpaqueSecret,
32
+ decryptSecret,
33
+ encryptSecret,
34
+ hashSecret,
35
+ normalizeEmail,
36
+ secretHashMatches,
37
+ } from './crypto';
38
+ export { deleteRegistryAiKey, listRegistryAiKeyConnections, readRegistryAiKey, saveRegistryAiKey } from './ai-keys';
29
39
  export { buildGravatarUrl } from './gravatar';
30
40
  export { authenticateRegistrySyncSecret, resolveRegistrySyncSecret, SYNC_SECRET_ENV } from './sync-secret';
31
41
  export {
@@ -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> {