handoff-app 2.0.0-10 → 2.0.0-12

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 (189) hide show
  1. package/README.md +421 -149
  2. package/UPGRADE.md +57 -9
  3. package/dist/ai/connections.d.ts +2 -0
  4. package/dist/ai/connections.js +14 -1
  5. package/dist/ai/types.d.ts +2 -3
  6. package/dist/app-builder/build.d.ts +3 -8
  7. package/dist/app-builder/build.js +97 -53
  8. package/dist/app-builder/client-config.js +2 -1
  9. package/dist/app-builder/paths.d.ts +0 -12
  10. package/dist/app-builder/paths.js +3 -20
  11. package/dist/app-builder/static-export.js +1 -1
  12. package/dist/app-builder/vercel-output.d.ts +1 -1
  13. package/dist/app-builder/vercel-output.js +5 -4
  14. package/dist/cache/build-cache.d.ts +27 -4
  15. package/dist/cache/build-cache.js +114 -47
  16. package/dist/cache/build-inputs.d.ts +13 -0
  17. package/dist/cache/build-inputs.js +19 -0
  18. package/dist/cache/index.d.ts +2 -1
  19. package/dist/cache/index.js +5 -1
  20. package/dist/commands/build/index.d.ts +1 -1
  21. package/dist/commands/build/index.js +1 -1
  22. package/dist/commands/utils.d.ts +3 -3
  23. package/dist/config/defaults.js +7 -4
  24. package/dist/config/from-env.d.ts +4 -0
  25. package/dist/config/from-env.js +38 -1
  26. package/dist/config/helpers.js +2 -2
  27. package/dist/config/index.d.ts +0 -1
  28. package/dist/config/index.js +1 -4
  29. package/dist/config/loader.js +27 -2
  30. package/dist/config/resolve-env.js +14 -11
  31. package/dist/config/runtime.js +1 -1
  32. package/dist/define.d.ts +10 -0
  33. package/dist/define.js +13 -0
  34. package/dist/index.d.ts +9 -6
  35. package/dist/index.js +17 -23
  36. package/dist/pipeline/figma.d.ts +13 -2
  37. package/dist/pipeline/figma.js +86 -46
  38. package/dist/pipeline/styles.js +1 -1
  39. package/dist/pipeline/validation.d.ts +1 -1
  40. package/dist/pipeline/validation.js +3 -3
  41. package/dist/registry/asset-storage/resolve.d.ts +7 -14
  42. package/dist/registry/asset-storage/resolve.js +31 -18
  43. package/dist/registry/asset-storage/types.d.ts +28 -8
  44. package/dist/registry/asset-storage/types.js +5 -5
  45. package/dist/registry/checkout/index.d.ts +2 -1
  46. package/dist/registry/checkout/index.js +57 -127
  47. package/dist/registry/client.js +53 -17
  48. package/dist/registry/db/driver.js +3 -3
  49. package/dist/registry/db/schema.d.ts +70 -2
  50. package/dist/registry/db/schema.js +5 -1
  51. package/dist/registry/email/adapters/resend.d.ts +7 -0
  52. package/dist/registry/email/adapters/resend.js +35 -0
  53. package/dist/registry/email/adapters/smtp.d.ts +10 -0
  54. package/dist/registry/email/adapters/smtp.js +73 -0
  55. package/dist/registry/email/define.d.ts +10 -0
  56. package/dist/registry/email/define.js +13 -0
  57. package/dist/registry/email/resolve.d.ts +29 -0
  58. package/dist/registry/email/resolve.js +76 -0
  59. package/dist/registry/email/types.d.ts +24 -0
  60. package/dist/registry/email/types.js +6 -0
  61. package/dist/registry/path.d.ts +2 -0
  62. package/dist/registry/path.js +11 -1
  63. package/dist/registry/publish/package.js +8 -0
  64. package/dist/registry/transfer.d.ts +4 -0
  65. package/dist/transformers/preview/component/builder.d.ts +1 -9
  66. package/dist/transformers/preview/component/builder.js +17 -17
  67. package/dist/transformers/preview/component/css.d.ts +2 -1
  68. package/dist/transformers/preview/component/css.js +8 -5
  69. package/dist/transformers/preview/component/html.d.ts +3 -1
  70. package/dist/transformers/preview/component/html.js +6 -2
  71. package/dist/transformers/preview/component/javascript.d.ts +2 -1
  72. package/dist/transformers/preview/component/javascript.js +10 -4
  73. package/dist/transformers/utils/build.d.ts +5 -0
  74. package/dist/transformers/utils/build.js +21 -4
  75. package/dist/types/config.d.ts +84 -18
  76. package/dist/utils/path.d.ts +1 -5
  77. package/dist/utils/path.js +6 -23
  78. package/docs/api.md +6 -4
  79. package/docs/cli.md +1 -1
  80. package/drizzle/0008_add_source_dir.sql +2 -0
  81. package/drizzle/meta/0008_snapshot.json +2025 -0
  82. package/drizzle/meta/_journal.json +7 -0
  83. package/package.json +15 -7
  84. package/src/ai/README.md +2 -2
  85. package/src/ai/connections.ts +18 -1
  86. package/src/ai/types.ts +2 -3
  87. package/src/app/components/AiAssistant/AiConversation.tsx +93 -30
  88. package/src/app/components/AiAssistant/toolLabel.ts +2 -0
  89. package/src/app/components/AiAssistant/usePageContext.ts +8 -4
  90. package/src/app/components/Auth/AccountLayout.tsx +6 -4
  91. package/src/app/components/Auth/AuthShell.tsx +17 -29
  92. package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
  93. package/src/app/components/Auth/installStatus.ts +29 -0
  94. package/src/app/components/Layout/Header.tsx +18 -14
  95. package/src/app/components/context/NavProvider.tsx +7 -3
  96. package/src/app/components/ui/confirm-dialog.tsx +87 -0
  97. package/src/app/components/ui/dialog.tsx +6 -1
  98. package/src/app/lib/ai/context.ts +5 -2
  99. package/src/app/lib/ai/model.ts +3 -9
  100. package/src/app/lib/ai/prompt.ts +2 -2
  101. package/src/app/lib/ai/tools.ts +1 -1
  102. package/src/app/lib/asset-storage.ts +23 -24
  103. package/src/app/lib/auth/email.ts +97 -23
  104. package/src/app/lib/docs-api/runtime-config.ts +14 -4
  105. package/src/app/lib/mcp/{shape.ts → components.ts} +186 -291
  106. package/src/app/lib/mcp/page-url.ts +24 -0
  107. package/src/app/lib/mcp/pages.ts +84 -0
  108. package/src/app/lib/mcp/result.ts +33 -0
  109. package/src/app/lib/mcp/server.ts +38 -331
  110. package/src/app/lib/mcp/tokens.ts +334 -0
  111. package/src/app/lib/mcp/tool.ts +19 -0
  112. package/src/app/lib/registry-api/asset-transfer.ts +112 -4
  113. package/src/app/lib/registry-api/errors.ts +2 -0
  114. package/src/app/lib/registry-api/handler.ts +7 -1
  115. package/src/app/lib/registry-api/transfer.ts +28 -6
  116. package/src/app/lib/server-module.ts +34 -0
  117. package/src/app/next.config.mjs +11 -37
  118. package/src/app/pages/account/ai.tsx +27 -6
  119. package/src/app/pages/account/index.tsx +9 -4
  120. package/src/app/pages/account/tokens.tsx +36 -6
  121. package/src/app/pages/account/users.tsx +74 -14
  122. package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
  123. package/src/app/pages/api/admin/users/index.ts +2 -2
  124. package/src/app/pages/api/auth/request-reset.ts +14 -16
  125. package/src/app/pages/api/install.ts +3 -3
  126. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
  127. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
  128. package/src/app/pages/cli/device.tsx +9 -4
  129. package/src/app/pages/install.tsx +7 -2
  130. package/src/app/pages/login.tsx +24 -24
  131. package/src/app/pages/reset-password.tsx +15 -19
  132. package/src/app-builder/README.md +2 -1
  133. package/src/app-builder/build.ts +110 -55
  134. package/src/app-builder/client-config.ts +2 -1
  135. package/src/app-builder/paths.ts +3 -22
  136. package/src/app-builder/static-export.ts +1 -1
  137. package/src/app-builder/vercel-output.ts +5 -4
  138. package/src/cache/README.md +4 -1
  139. package/src/cache/build-cache.ts +128 -43
  140. package/src/cache/build-inputs.ts +28 -0
  141. package/src/cache/index.ts +4 -0
  142. package/src/commands/build/index.ts +1 -1
  143. package/src/config/README.md +9 -8
  144. package/src/config/defaults.ts +7 -4
  145. package/src/config/from-env.ts +33 -0
  146. package/src/config/helpers.ts +1 -3
  147. package/src/config/index.ts +0 -3
  148. package/src/config/loader.ts +26 -3
  149. package/src/config/resolve-env.ts +14 -14
  150. package/src/config/runtime.ts +1 -1
  151. package/src/define.ts +18 -0
  152. package/src/index.ts +17 -21
  153. package/src/pipeline/README.md +1 -1
  154. package/src/pipeline/figma.ts +98 -49
  155. package/src/pipeline/styles.ts +1 -1
  156. package/src/pipeline/validation.ts +3 -3
  157. package/src/registry/asset-storage/resolve.ts +36 -31
  158. package/src/registry/asset-storage/types.ts +30 -8
  159. package/src/registry/checkout/index.ts +60 -138
  160. package/src/registry/client.ts +60 -20
  161. package/src/registry/db/driver.ts +2 -2
  162. package/src/registry/db/schema.ts +7 -3
  163. package/src/registry/email/adapters/resend.ts +22 -0
  164. package/src/registry/email/adapters/smtp.ts +39 -0
  165. package/src/registry/email/define.ts +15 -0
  166. package/src/registry/email/resolve.ts +94 -0
  167. package/src/registry/email/types.ts +28 -0
  168. package/src/registry/path.ts +11 -0
  169. package/src/registry/publish/package.ts +9 -0
  170. package/src/registry/transfer.ts +4 -0
  171. package/src/transformers/preview/component/builder.ts +19 -25
  172. package/src/transformers/preview/component/css.ts +9 -4
  173. package/src/transformers/preview/component/html.ts +6 -2
  174. package/src/transformers/preview/component/javascript.ts +14 -4
  175. package/src/transformers/utils/build.ts +16 -3
  176. package/src/types/config.ts +86 -21
  177. package/src/utils/README.md +1 -1
  178. package/src/utils/path.ts +6 -29
  179. package/templates/default/handoff.config.js.tpl +10 -2
  180. package/templates/default/handoff.config.ts.tpl +10 -2
  181. package/templates/default/handoff.config.with-examples.js.tpl +10 -2
  182. package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
  183. package/templates/default/package.json.tpl +2 -2
  184. package/dist/config/validator.d.ts +0 -9
  185. package/dist/config/validator.js +0 -24
  186. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
  187. package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
  188. package/src/config/validator.ts +0 -22
  189. package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
package/README.md CHANGED
@@ -24,8 +24,8 @@ registry-mode application is created automatically by a registry build.
24
24
 
25
25
  ## Requirements
26
26
 
27
- - Node.js 18.18 or newer; Node.js 20 LTS or newer is recommended
28
- - npm 8 or newer
27
+ - Node.js 22 or newer; Node.js 24 LTS is recommended
28
+ - npm 10 or newer
29
29
  - A paid Figma account when fetching a Figma library
30
30
  - PostgreSQL when running a registry
31
31
 
@@ -53,6 +53,11 @@ npm run db:migrate # registry database migrations
53
53
  npm run validate
54
54
  ```
55
55
 
56
+ ## Upgrade from version 1.x.x to version 2.x.x
57
+
58
+ See the [version 1.x.x to version 2.x.x migration guide](UPGRADE.md#version-1xx-to-version-2xx)
59
+ for breaking changes, migration steps, and upgrade verification.
60
+
56
61
  ## Project layout
57
62
 
58
63
  Every command runs against a working directory: the directory holding
@@ -69,13 +74,13 @@ Every command runs against a working directory: the directory holding
69
74
  ├─ public/api/ # generated docs API; gitignored
70
75
  ├─ out/ # build output; gitignored
71
76
  ├─ .vercel/ # `--package vercel` output; gitignored
72
- └─ .handoff/ # saved CLI login; gitignored
77
+ └─ .handoff/ # local Handoff state; gitignored
73
78
  ```
74
79
 
75
80
  In a repository of its own, which is what `init` scaffolds, the working
76
81
  directory is the repository root.
77
82
 
78
- ### Adding Handoff to an existing application
83
+ ## Adding Handoff to an existing application
79
84
 
80
85
  Declarations sit beside the components they document, so the catalog points
81
86
  into the application source:
@@ -313,8 +318,6 @@ both.
313
318
 
314
319
  If two declarations claim one id, Handoff keeps the first and skips the second. The warning names
315
320
  both files. `publish` refuses to run until each id is unique.
316
-
317
-
318
321
  ### Registration
319
322
 
320
323
  Catalog directories are registered in one place. A path is either an item directory or a collection
@@ -336,10 +339,7 @@ export default defineConfig({
336
339
  });
337
340
  ```
338
341
 
339
- ### Upgrade from version 1.x.x to version 2.x.x
340
-
341
- See the [version 1.x.x to version 2.x.x migration guide](UPGRADE.md#version-1xx-to-version-2xx)
342
- for breaking changes, migration steps, and upgrade verification.
342
+ ### Pages
343
343
 
344
344
  Custom documentation pages are Markdown files under `pages/`. Their relative
345
345
  paths become their routes and registry IDs.
@@ -358,6 +358,23 @@ HANDOFF_FIGMA_PROJECT_ID=figma-file-id
358
358
  HANDOFF_DEV_ACCESS_TOKEN=figma-personal-access-token
359
359
  ```
360
360
 
361
+ These are the defaults of `integrations.figma` in `handoff.config`. Set the block
362
+ only to use other variable names, to commit the file ID, or to change the file
363
+ for a profile. The access token accepts only an environment reference:
364
+
365
+ ```ts
366
+ integrations: {
367
+ figma: {
368
+ projectId: fromEnv("HANDOFF_FIGMA_PROJECT_ID", { default: null }),
369
+ accessToken: fromEnv("HANDOFF_DEV_ACCESS_TOKEN", { default: null }),
370
+ },
371
+ },
372
+ ```
373
+
374
+ If a value is empty, `fetch` asks for it in a terminal and can save it to `.env`,
375
+ or to `.env.<profile>` when a profile is selected. Without a terminal, such as
376
+ in CI, `fetch` stops with an error that names the missing variable.
377
+
361
378
  The `file_content:read` and `library_content:read` scopes must be granted to the
362
379
  personal access token. The configured library is fetched with:
363
380
 
@@ -369,9 +386,9 @@ After design changes, the Figma library should be republished and the command
369
386
  should be run again so the latest foundations are pulled.
370
387
 
371
388
  Generated tokens, CSS, Sass, and available assets are written below
372
- `exported/<projectId>`. The `exported/` directory should be committed so the
389
+ `exported/`. The `exported/` directory should be committed so the
373
390
  same inputs are used by local and CI builds. A successful fetch can be verified
374
- by checking for generated token data such as `exported/<projectId>/tokens.json`;
391
+ by checking for generated token data such as `exported/tokens.json`;
375
392
  an empty foundation route does not prove that the fetch succeeded.
376
393
 
377
394
  ## Workspace
@@ -404,9 +421,20 @@ A static documentation site is built with:
404
421
  npm run build
405
422
  ```
406
423
 
407
- Static output is written below `out/<projectId>` and can be served by any
424
+ Static output is written below `out/static` and can be served by any
408
425
  static file server or CDN.
409
426
 
427
+ Components whose files have not changed since the last build are not built
428
+ again. The build cache in `.handoff/.cache/` records the files of each
429
+ component and every file that its last build read, Sass partials included.
430
+ Files under `node_modules` are covered by the lockfile instead. Assets that a
431
+ stylesheet loads through `url()` are not tracked. Every component is built
432
+ again with:
433
+
434
+ ```bash
435
+ npm run build -- --force
436
+ ```
437
+
410
438
  A standalone registry application is built with:
411
439
 
412
440
  ```bash
@@ -418,6 +446,83 @@ command. The database-backed Next.js bundle is written to `out/registry`, and
418
446
  workspace source is not compiled or served. A Vercel Build Output bundle can be
419
447
  produced for either target by adding `--package vercel`.
420
448
 
449
+ ## Configuration
450
+
451
+ Configuration is read from `handoff.config.ts`, `.js`, `.cjs`, or `.json`, in
452
+ that order. `defineConfig` provides typed authoring. Values merge onto the
453
+ defaults: plain objects merge recursively, and arrays, scalars, `null`, and
454
+ functions replace.
455
+
456
+ Useful environment variables:
457
+
458
+ | Variable | Purpose |
459
+ | --- | --- |
460
+ | `HANDOFF_PROFILE` | Config profile merged onto the base config |
461
+ | `HANDOFF_FIGMA_PROJECT_ID` | Figma file ID used by `fetch` |
462
+ | `HANDOFF_DEV_ACCESS_TOKEN` | Figma personal access token used by `fetch` |
463
+ | `HANDOFF_REGISTRY_URL` | Connected workspace registry URL |
464
+ | `HANDOFF_REGISTRY_ACCESS_TOKEN` | Registry access token used by a connected workspace |
465
+ | `HANDOFF_SYNC_SECRET` | Optional deployment-wide registry credential |
466
+ | `DATABASE_URL` | Registry PostgreSQL connection string |
467
+ | `AUTH_SECRET` | Registry session-signing secret, at least 32 characters |
468
+ | `AUTH_URL` | Canonical public registry URL |
469
+ | `PORT` | Standalone registry server port |
470
+ | `HOSTNAME` | Standalone registry bind hostname |
471
+ | `HANDOFF_AI_KEY_SECRET` | Encrypts reader-supplied AI provider keys, at least 32 characters |
472
+ | `HANDOFF_AI_CONNECTIONS` | JSON array of AI connections, merged over the baked list by `id` |
473
+ | `HANDOFF_OUTPUT_DIR` | Override the fetched output directory |
474
+ | `HANDOFF_CREATE_ASSETS_ZIP_FILES` | Set to `false` to skip the icon and logo zip files in `fetch` |
475
+ | `HANDOFF_SITES_DIR` | Override the build output directory |
476
+ | `HANDOFF_WORKING_PATH` | Directory holding `handoff.config.ts`; defaults to the current directory |
477
+ | `HANDOFF_APP_PORT` | Workspace documentation server port |
478
+ | `HANDOFF_WEBSOCKET_PORT` | Workspace live-reload server port |
479
+
480
+ ## Profiles
481
+
482
+ A profile is a sidecar config file that merges onto the base config. It holds
483
+ only what changes between environments, so a project keeps one shared config
484
+ instead of a second complete copy of it.
485
+
486
+ - The base config is `handoff.config.ts`, `.js`, `.cjs`, or `.json`. It is the
487
+ only config file loaded when no profile is selected.
488
+ - A profile file is `handoff.config.<profile>.*`, with the same four
489
+ extensions.
490
+ - Select a profile with `--profile <name>` or with `HANDOFF_PROFILE`.
491
+ `--profile` takes precedence over `HANDOFF_PROFILE`.
492
+ - Profile names are not predefined. A name can contain lowercase letters,
493
+ numbers, and hyphens.
494
+ - A selected profile must exist. If no `handoff.config.<name>.*` file is found,
495
+ the command stops with an error.
496
+ - `login`, `logout`, `publish`, and `checkout` also accept a profile that has
497
+ only a saved registry login, because there the profile selects the registry.
498
+ Every other command still needs the config file.
499
+ - Layers resolve as defaults, base config, profile, then programmatic config,
500
+ with the merge rules described under [Configuration](#configuration).
501
+ - A selected profile also reads `.env.<profile>` on top of `.env`, when that
502
+ file exists. Both are read from the directory the command runs in, and a
503
+ variable already set in the environment beats both files.
504
+
505
+ ```bash
506
+ npx handoff-app build --target registry --profile registry
507
+ ```
508
+
509
+ `build --target registry` always packages a registry-mode application, so a
510
+ profile does not need to set `runtime.mode`. This makes a profile a good place
511
+ for the registry build settings: the database driver, the database
512
+ environment-variable name, the asset storage, and whether MCP is enabled. A
513
+ hosting provider or a CI job selects the profile through `HANDOFF_PROFILE`.
514
+
515
+ `defineConfig` types a profile as well as a base config. The generated
516
+ `.gitignore` lists `handoff.config.local.*`, which makes `local` the usual name
517
+ for a profile that stays on one machine.
518
+
519
+ Arrays replace, so a profile that declares `catalog.include` also decides what
520
+ `make` and `checkout` register.
521
+
522
+ `.env.<profile>` is read once at startup rather than watched.
523
+ `HANDOFF_WORKING_PATH` cannot be set from it, because the working path is
524
+ resolved before any profile is known.
525
+
421
526
  ## Registry setup
422
527
 
423
528
  ### 1. Database migrations
@@ -450,57 +555,10 @@ server also reads `PORT` and `HOSTNAME`. The generated entrypoint is
450
555
  `out/registry/server.js`; how it is started and hosted depends on the deployment
451
556
  environment.
452
557
 
453
- ### Vercel and Neon
454
-
455
- Vercel packaging and the Neon PostgreSQL driver are supported independently.
456
- Either integration can be used without the other.
457
-
458
- #### Vercel packaging
459
-
460
- A registry bundle that follows the Vercel Build Output API is produced with:
461
-
462
- ```bash
463
- npm run build -- --target registry --package vercel
464
- ```
465
-
466
- The generated deployment output is written to `.vercel/output`. The registry
467
- runtime variables listed above are supplied through the deployment environment.
468
-
469
- #### Neon PostgreSQL
470
-
471
- Import `fromEnv` from `handoff-app`. Select the Neon connection driver in `handoff.config.ts`:
472
-
473
- ```ts
474
- runtime: {
475
- registry: {
476
- databaseUrl: fromEnv('DATABASE_URL'),
477
- database: {
478
- driver: 'neon',
479
- },
480
- },
481
- },
482
- ```
483
-
484
- A Neon PostgreSQL connection string is supplied through `DATABASE_URL`. The
485
- same explicit database migration step is required before deployment.
486
-
487
- #### Optional Vercel Blob storage
488
-
489
- Registry assets are stored in PostgreSQL by default. Vercel Blob can be selected
490
- when external asset storage is required:
491
-
492
- ```ts
493
- runtime: {
494
- registry: {
495
- assetStorage: {
496
- adapter: 'vercel-blob',
497
- },
498
- },
499
- },
500
- ```
501
-
502
- The associated `BLOB_READ_WRITE_TOKEN` is supplied through the deployment
503
- environment.
558
+ The defaults work without more configuration: asset blobs are stored in
559
+ PostgreSQL, and email is not sent. Other providers are baked into the build, so
560
+ they must be selected under [Registry providers](#registry-providers) before
561
+ the registry is built.
504
562
 
505
563
  ### 3. Installation
506
564
 
@@ -632,6 +690,298 @@ runtime: {
632
690
  },
633
691
  ```
634
692
 
693
+ ## Registry providers
694
+
695
+ ### Vercel and Neon
696
+
697
+ Vercel packaging and the Neon PostgreSQL driver are supported independently.
698
+ Either integration can be used without the other.
699
+
700
+ #### Vercel packaging
701
+
702
+ A registry bundle that follows the Vercel Build Output API is produced with:
703
+
704
+ ```bash
705
+ npm run build -- --target registry --package vercel
706
+ ```
707
+
708
+ The generated deployment output is written to `.vercel/output`. The registry
709
+ runtime variables listed above are supplied through the deployment environment.
710
+
711
+ #### Neon PostgreSQL
712
+
713
+ Import `fromEnv` from `handoff-app`. Select the Neon connection driver in `handoff.config.ts`:
714
+
715
+ ```ts
716
+ runtime: {
717
+ registry: {
718
+ database: {
719
+ url: fromEnv('DATABASE_URL'),
720
+ driver: 'neon',
721
+ },
722
+ },
723
+ },
724
+ ```
725
+
726
+ A Neon PostgreSQL connection string is supplied through `DATABASE_URL`. The
727
+ same explicit database migration step is required before deployment.
728
+
729
+ ### Asset storage
730
+
731
+ The registry stores asset blobs in PostgreSQL by default, with a limit of 4 MB
732
+ for each blob. `maxInlineBytes` changes this limit.
733
+
734
+ #### Custom adapter
735
+
736
+ A [custom server module](#custom-server-modules) can store blobs in a
737
+ different service. The module default-exports a `defineAssetStorage()` adapter
738
+ with these methods:
739
+
740
+ - `put` stores a blob and returns the `storageRef` that the registry records.
741
+ - `get` returns a blob by its `storageRef`, as `{ kind: 'stream', stream }`,
742
+ `{ kind: 'bytes', bytes }`, or `{ kind: 'redirect', url }`.
743
+ - `delete` removes a blob by its `storageRef`.
744
+ - `createUpload` is optional. It enables [direct uploads](#direct-uploads).
745
+
746
+ Literals in `options` are baked into the build. The deployed registry reads
747
+ each `fromEnv()` value at request time and gives the resolved `options` to the
748
+ module. Thus secrets must use `fromEnv()`.
749
+
750
+ The S3 and Vercel Blob examples that follow are complete adapters with direct
751
+ uploads.
752
+
753
+ ##### Direct uploads
754
+
755
+ By default, `publish` sends each blob to the registry, and the registry sends
756
+ it to storage. A serverless host limits the size of one request. Vercel permits
757
+ about 4.5 MB, so a larger blob fails.
758
+
759
+ With `createUpload`, the registry gives the CLI a signed URL, and the CLI sends
760
+ the blob directly to storage. The signed URL must make storage reject bytes
761
+ that do not match `hash`. The registry records the blob only after `get` finds
762
+ the stored object. The registry does not delete an uploaded object that it
763
+ never records.
764
+
765
+ The `database` provider always uploads through the registry, with its
766
+ `maxInlineBytes` limit.
767
+
768
+ ##### S3-compatible storage
769
+
770
+ This adapter works with Amazon S3, Cloudflare R2, MinIO, SeaweedFS, and other
771
+ services with an S3 API. It uses `@aws-sdk/client-s3` and
772
+ `@aws-sdk/s3-request-presigner`.
773
+
774
+ ```ts
775
+ runtime: {
776
+ registry: {
777
+ assetStorage: {
778
+ adapter: 'custom',
779
+ module: './server/storage/s3.mjs',
780
+ options: {
781
+ endpoint: 'http://127.0.0.1:8333',
782
+ bucket: 'handoff-assets',
783
+ accessKey: fromEnv('S3_ACCESS_KEY'),
784
+ secretKey: fromEnv('S3_SECRET_KEY'),
785
+ },
786
+ },
787
+ },
788
+ },
789
+ ```
790
+
791
+ ```js
792
+ // server/storage/s3.mjs
793
+ import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
794
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
795
+ import { defineAssetStorage } from 'handoff-app/define';
796
+
797
+ export default defineAssetStorage(({ options }) => {
798
+ const client = new S3Client({
799
+ endpoint: options.endpoint,
800
+ region: 'us-east-1',
801
+ forcePathStyle: true,
802
+ credentials: { accessKeyId: options.accessKey, secretAccessKey: options.secretKey },
803
+ });
804
+ const Bucket = options.bucket;
805
+ return {
806
+ async put({ hash, bytes, contentType }) {
807
+ await client.send(new PutObjectCommand({ Bucket, Key: hash, Body: bytes, ContentType: contentType }));
808
+ return { storageRef: hash };
809
+ },
810
+ async get(storageRef) {
811
+ const result = await client.send(new GetObjectCommand({ Bucket, Key: storageRef }));
812
+ return { kind: 'stream', stream: result.Body, contentType: result.ContentType };
813
+ },
814
+ async delete(storageRef) {
815
+ await client.send(new DeleteObjectCommand({ Bucket, Key: storageRef }));
816
+ },
817
+ async createUpload({ hash, contentType }) {
818
+ const checksum = Buffer.from(hash, 'hex').toString('base64');
819
+ const command = new PutObjectCommand({ Bucket, Key: hash, ContentType: contentType, ChecksumSHA256: checksum });
820
+ const url = await getSignedUrl(client, command, {
821
+ expiresIn: 900,
822
+ unhoistableHeaders: new Set(['x-amz-checksum-sha256']),
823
+ });
824
+ return { url, headers: { 'Content-Type': contentType, 'x-amz-checksum-sha256': checksum }, storageRef: hash };
825
+ },
826
+ };
827
+ });
828
+ ```
829
+
830
+ `createUpload` signs a SHA-256 checksum header. Thus storage rejects bytes
831
+ that do not match `hash`.
832
+
833
+ ##### Vercel Blob
834
+
835
+ This adapter stores blobs in a private Vercel Blob store. It uses
836
+ `@vercel/blob` 2.x. When the store is connected to the Vercel project, the SDK
837
+ reads the store credentials from the deployment environment. Thus the adapter
838
+ needs no `options`.
839
+
840
+ ```ts
841
+ runtime: {
842
+ registry: {
843
+ assetStorage: {
844
+ adapter: 'custom',
845
+ module: './server/storage/vercel-blob.mjs',
846
+ },
847
+ },
848
+ },
849
+ ```
850
+
851
+ ```js
852
+ // server/storage/vercel-blob.mjs
853
+ import { Readable } from 'node:stream';
854
+ import { del, get, issueSignedToken, parseStoreIdFromDelegationToken, presignUrl, put } from '@vercel/blob';
855
+ import { defineAssetStorage } from 'handoff-app/define';
856
+
857
+ const access = 'private';
858
+ const pathnameFor = (hash) => `assets/${hash}`;
859
+
860
+ export default defineAssetStorage({
861
+ async put({ hash, bytes, contentType }) {
862
+ const blob = await put(pathnameFor(hash), bytes, { access, contentType, addRandomSuffix: false, allowOverwrite: true });
863
+ return { storageRef: blob.pathname };
864
+ },
865
+ async get(storageRef) {
866
+ const result = await get(storageRef, { access });
867
+ if (!result?.stream) {
868
+ throw new Error(`Blob "${storageRef}" does not exist.`);
869
+ }
870
+ return { kind: 'stream', stream: Readable.fromWeb(result.stream), contentType: result.blob.contentType };
871
+ },
872
+ async delete(storageRef) {
873
+ await del(storageRef);
874
+ },
875
+ async createUpload({ hash, size, contentType }) {
876
+ const pathname = pathnameFor(hash);
877
+ const token = await issueSignedToken({
878
+ pathname,
879
+ operations: ['put'],
880
+ validUntil: Date.now() + 15 * 60 * 1000,
881
+ allowedContentTypes: [contentType],
882
+ maximumSizeInBytes: size,
883
+ });
884
+ const { presignedUrl } = await presignUrl(token, { operation: 'put', pathname, access, allowOverwrite: true });
885
+ return {
886
+ url: presignedUrl,
887
+ headers: {
888
+ 'x-api-version': '12',
889
+ 'x-vercel-blob-store-id': parseStoreIdFromDelegationToken(token.delegationToken),
890
+ 'x-vercel-blob-access': access,
891
+ 'x-content-type': contentType,
892
+ },
893
+ storageRef: pathname,
894
+ };
895
+ },
896
+ });
897
+ ```
898
+
899
+ This adapter is different from the S3 example in these ways:
900
+
901
+ - `get` converts the stream. The SDK returns a web stream, but the registry
902
+ needs a Node stream.
903
+ - A private blob has no public URL. Thus the registry sends each blob to the
904
+ reader itself.
905
+ - **Vercel Blob cannot reject bytes that do not match `hash`.** The signed URL
906
+ limits only the path, the size, and the content type. Thus the registry does
907
+ not verify a blob that the CLI uploads directly.
908
+ - Without `createUpload`, each blob goes through the registry, which verifies
909
+ its hash. The Vercel limit of about 4.5 MB then applies to each blob.
910
+ - The CLI sends a plain `PUT` request. Thus `createUpload` returns the headers
911
+ that the SDK sends for a presigned upload. These headers are not a documented
912
+ API, so a new version of `@vercel/blob` can change them.
913
+
914
+ #### Changing the provider
915
+
916
+ The registry records on each blob whether PostgreSQL or the custom adapter
917
+ stores it. Blobs in PostgreSQL stay readable after you select a custom adapter.
918
+ A publish does not move blobs. Thus, if you change the custom adapter or its
919
+ storage, copy the stored objects first. The new adapter must find each object
920
+ by the `storageRef` that the previous adapter returned. After a change back to
921
+ `database`, the registry cannot read the blobs of the custom adapter.
922
+
923
+ ### Email delivery
924
+
925
+ The registry sends invitation and password-reset emails when `email.from` is
926
+ set. Without it, an administrator sees each invitation link once and delivers
927
+ it manually, and password reset is not available.
928
+
929
+ Resend is the default provider. It reads its key from `RESEND_API_KEY`, unless
930
+ `options.apiKey` names a different variable:
931
+
932
+ ```ts
933
+ runtime: {
934
+ registry: {
935
+ email: { from: 'Handoff <no-reply@example.com>' },
936
+ },
937
+ },
938
+ ```
939
+
940
+ For an SMTP server, for example Amazon SES, SendGrid, or Microsoft 365:
941
+
942
+ ```ts
943
+ email: {
944
+ from: 'Handoff <no-reply@example.com>',
945
+ provider: 'smtp',
946
+ options: {
947
+ host: fromEnv('SMTP_HOST'),
948
+ port: 465, // default, uses TLS. 587 uses STARTTLS.
949
+ user: fromEnv('SMTP_USER'),
950
+ password: fromEnv('SMTP_PASSWORD'),
951
+ },
952
+ },
953
+ ```
954
+
955
+ For a different service, set `provider: 'custom'` and set `module` to a
956
+ [custom server module](#custom-server-modules). The factory gets `options`
957
+ with `fromEnv()` values resolved:
958
+
959
+ ```ts
960
+ import { defineEmailProvider } from 'handoff-app/define';
961
+
962
+ export default defineEmailProvider(({ options }) => ({
963
+ async send({ from, to, subject, html, text }) {
964
+ // Deliver the message. Throw an error if it is not accepted.
965
+ },
966
+ }));
967
+ ```
968
+
969
+ Literals in `options` are baked into the build. `fromEnv()` values are read at
970
+ request time, so they can change without a rebuild. `apiKey` and `password`
971
+ must use `fromEnv()`. A profile can set a different sender or provider. When a
972
+ profile changes the provider, it replaces `options`.
973
+
974
+ ### Custom server modules
975
+
976
+ Asset storage, email, and AI connections can name a custom server module. Each
977
+ module obeys these rules:
978
+
979
+ - The path is relative to the working directory.
980
+ - The file is `.js` or `.mjs`.
981
+ - The default export is the object, or a factory that returns it.
982
+ - The module imports its define helper from `handoff-app/define`. The registry build stops when a module imports `handoff-app`.
983
+ - The packages that the module imports are installed in the project. The registry build copies them into the bundle.
984
+
635
985
  ## MCP
636
986
 
637
987
  The documentation app serves a Model Context Protocol endpoint at `/api/mcp/`, so
@@ -727,8 +1077,10 @@ Every connection speaks the OpenAI-compatible `/chat/completions` API. This does
727
1077
  not limit which models a reader can use. Ollama serves an OpenAI-compatible API
728
1078
  at `/v1`, and LiteLLM, OpenRouter, vLLM, LM Studio and Azure OpenAI each front
729
1079
  Anthropic, Google and xAI models. A provider that fits nothing else names a
730
- server-only module that default-exports `defineAiProvider()`, the way
731
- `runtime.registry.assetStorage.module` does.
1080
+ [custom server module](#custom-server-modules) that default-exports
1081
+ `defineAiProvider()`, in place of `baseUrl`. The factory gets `options` with
1082
+ `fromEnv()` values resolved. Literals in `options` are baked into the build, so
1083
+ secrets must use `fromEnv()`.
732
1084
 
733
1085
  ### Reader keys
734
1086
 
@@ -753,93 +1105,13 @@ instead of holding its value:
753
1105
  "apiKeyEnv": "LITELLM_API_KEY", "models": ["gpt-4o"] }]
754
1106
  ```
755
1107
 
1108
+ A connection in this list can use a `module` only if a connection in the config
1109
+ names the same module. The build copies only those modules into the registry.
1110
+
756
1111
  Spend control belongs to the gateway. LiteLLM and OpenRouter both enforce
757
1112
  budgets and rate limits per key. The assistant caps only how many steps one
758
1113
  question can take.
759
1114
 
760
- ## Configuration
761
-
762
- Configuration is read from `handoff.config.ts`, `.js`, `.cjs`, or `.json`, in
763
- that order. `defineConfig` provides typed authoring. Values merge onto the
764
- defaults: plain objects merge recursively, and arrays, scalars, `null`, and
765
- functions replace.
766
-
767
- Useful environment variables:
768
-
769
- | Variable | Purpose |
770
- | --- | --- |
771
- | `HANDOFF_PROFILE` | Config profile merged onto the base config |
772
- | `HANDOFF_FIGMA_PROJECT_ID` | Figma file ID used by `fetch` |
773
- | `HANDOFF_DEV_ACCESS_TOKEN` | Figma personal access token used by `fetch` |
774
- | `HANDOFF_REGISTRY_URL` | Connected workspace registry URL |
775
- | `HANDOFF_REGISTRY_ACCESS_TOKEN` | Registry access token used by a connected workspace |
776
- | `HANDOFF_SYNC_SECRET` | Optional deployment-wide registry credential |
777
- | `DATABASE_URL` | Registry PostgreSQL connection string |
778
- | `AUTH_SECRET` | Registry session-signing secret, at least 32 characters |
779
- | `AUTH_URL` | Canonical public registry URL |
780
- | `PORT` | Standalone registry server port |
781
- | `HOSTNAME` | Standalone registry bind hostname |
782
- | `BLOB_READ_WRITE_TOKEN` | Credential for the Vercel Blob asset adapter |
783
- | `HANDOFF_AI_KEY_SECRET` | Encrypts reader-supplied AI provider keys, at least 32 characters |
784
- | `HANDOFF_AI_CONNECTIONS` | JSON array of AI connections, merged over the baked list by `id` |
785
- | `HANDOFF_OUTPUT_DIR` | Override the fetched output directory |
786
- | `HANDOFF_SITES_DIR` | Override the build output directory |
787
- | `HANDOFF_WORKING_PATH` | Directory holding `handoff.config.ts`; defaults to the current directory |
788
- | `HANDOFF_APP_PORT` | Workspace documentation server port |
789
- | `HANDOFF_WEBSOCKET_PORT` | Workspace live-reload server port |
790
-
791
- Registry assets are stored in PostgreSQL by default with a 4 MB per-blob limit.
792
- Vercel Blob or a custom adapter can be selected through
793
- `runtime.registry.assetStorage`. The `pg` or `neon` connection driver can be
794
- selected through `runtime.registry.database.driver`; PostgreSQL is used by
795
- both.
796
-
797
- ## Profiles
798
-
799
- A profile is a sidecar config file that merges onto the base config. It holds
800
- only what changes between environments, so a project keeps one shared config
801
- instead of a second complete copy of it.
802
-
803
- - The base config is `handoff.config.ts`, `.js`, `.cjs`, or `.json`. It is the
804
- only config file loaded when no profile is selected.
805
- - A profile file is `handoff.config.<profile>.*`, with the same four
806
- extensions.
807
- - Select a profile with `--profile <name>` or with `HANDOFF_PROFILE`.
808
- `--profile` takes precedence over `HANDOFF_PROFILE`.
809
- - Profile names are not predefined. A name can contain lowercase letters,
810
- numbers, and hyphens.
811
- - A selected profile must exist. If no `handoff.config.<name>.*` file is found,
812
- the command stops with an error.
813
- - `login`, `logout`, `publish`, and `checkout` also accept a profile that has
814
- only a saved registry login, because there the profile selects the registry.
815
- Every other command still needs the config file.
816
- - Layers resolve as defaults, base config, profile, then programmatic config,
817
- with the merge rules described under [Configuration](#configuration).
818
- - A selected profile also reads `.env.<profile>` on top of `.env`, when that
819
- file exists. Both are read from the directory the command runs in, and a
820
- variable already set in the environment beats both files.
821
-
822
- ```bash
823
- npx handoff-app build --target registry --profile registry
824
- ```
825
-
826
- `build --target registry` always packages a registry-mode application, so a
827
- profile does not need to set `runtime.mode`. This makes a profile a good place
828
- for the registry build settings: the database driver, the database
829
- environment-variable name, the asset storage, and whether MCP is enabled. A
830
- hosting provider or a CI job selects the profile through `HANDOFF_PROFILE`.
831
-
832
- `defineConfig` types a profile as well as a base config. The generated
833
- `.gitignore` lists `handoff.config.local.*`, which makes `local` the usual name
834
- for a profile that stays on one machine.
835
-
836
- Arrays replace, so a profile that declares `catalog.include` also decides what
837
- `make` and `checkout` register.
838
-
839
- `.env.<profile>` is read once at startup rather than watched.
840
- `HANDOFF_WORKING_PATH` cannot be set from it, because the working path is
841
- resolved before any profile is known.
842
-
843
1115
  ## CLI reference
844
1116
 
845
1117
  | Command | Description |