canopycms 0.0.67 → 0.0.68-int.101

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 (281) hide show
  1. package/README.md +24 -17
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +118 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +19 -1
  23. package/dist/api/github-sync.js +57 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
package/README.md CHANGED
@@ -45,7 +45,7 @@ you misunderstood, which is why it is worth resolving the ref rather than guessi
45
45
 
46
46
  ## Branch roots
47
47
 
48
- - Workspaces resolve per mode: `prod` uses `$CANOPYCMS_WORKSPACE_ROOT/content-branches` (default: `/mnt/efs/workspace/content-branches`), `dev` uses `.canopy-dev/content-branches/<branch>`.
48
+ - Workspaces resolve per mode: `prod` uses `$CANOPYCMS_WORKSPACE_ROOT/content-branches` (default: `/mnt/efs/content-branches`), `dev` uses `.canopy-dev/content-branches/<branch>`.
49
49
  - For `prod` mode, you must set `defaultRemoteUrl`. For `dev`, `defaultRemoteUrl` is **optional** - if omitted, a local remote is auto-created at `.canopy-dev/remote.git`.
50
50
  - Optionally configure `defaultRemoteName` (default: `origin`) and `defaultBaseBranch` (default: `main`).
51
51
  - Git author identity is required for `prod` mode: set `gitBotAuthorName` and `gitBotAuthorEmail` so bot commits can be created reliably.
@@ -104,6 +104,7 @@ export default defineCanopyConfig({
104
104
  colors: { brand: '#4f46e5' },
105
105
  },
106
106
  // previewBase: { 'content/posts': '/blog' }, // optional overrides
107
+ // previewPrefix: '/preview', // optional, in front of every preview URL
107
108
  },
108
109
  // For prod mode, defaultRemoteUrl is required.
109
110
  // For dev, it's optional - if omitted, uses auto-initialized local remote at .canopy-dev/remote.git
@@ -323,7 +324,7 @@ name) works, but leaves the entry's real `urlPath` as
323
324
  URL-derived surface then has to be told about one at a time. The example app in this repo used to
324
325
  do that and no longer does.
325
326
 
326
- Both methods return `{ data, path }`. `read` throws if the content is missing; `readByUrlPath` returns `null` instead. Pass a `branch` option when you want branch-specific data (e.g., for preview); otherwise it defaults to your configured base branch. Both enforce the same branch/path access rules as the API handlers.
327
+ Both methods return `{ data, path }`. `read` throws if the content is missing; `readByUrlPath` returns `null` instead. Pass a `branch` option when you want branch-specific data (e.g., for preview); otherwise it defaults to the active branch: `defaultActiveBranch`, or when that is unset, git HEAD in `dev` and your base branch in `prod`. Any other branch must already exist and be readable by the current user, or it reads as not found. Both enforce the same branch/path access rules as the API handlers.
327
328
 
328
329
  **Index entries and URL resolution**
329
330
 
@@ -387,7 +388,7 @@ import config from '../canopycms.config'
387
388
  export default CanopyEditorPage(config)
388
389
  ```
389
390
 
390
- The editor loads entries on the client from `/api/canopycms/[branch]/entries`, so server prefetch is optional. Use `previewBaseByCollection` to control preview URLs per collection.
391
+ The editor loads entries on the client from `/api/canopycms/[branch]/entries`, so server prefetch is optional. Use `editor.previewBase` to override preview URLs.
391
392
 
392
393
  6. **Theme it**
393
394
  Wrap editor surfaces with `CanopyCMSProvider` to load Mantine styles and customize `brand`/`primary`/`neutral`/`accent` colors and color scheme. Pass `themeOptions` into `Editor` if desired.
@@ -395,13 +396,13 @@ The editor loads entries on the client from `/api/canopycms/[branch]/entries`, s
395
396
  _TODO_ show an example
396
397
 
397
398
  7. **Split builds**
398
- Keep your public build free of editor bundles by importing only from `canopycms` (server helpers + data loaders). Host the editor in a separate app or build target that imports from `canopycms/client` and mounts the API routes above.
399
+ Keep your public build free of editor bundles by importing only from `canopycms` (server helpers + data loaders) and `canopycms/preview` (the live-preview hook). Host the editor in a separate app or build target that imports from `canopycms/client` and mounts the API routes above.
399
400
 
400
401
  _TODO_ show real examples of what to do
401
402
 
402
403
  ### Preview branch awareness
403
404
 
404
- - When building preview URLs, include the current branch as a query param (e.g., `/?branch=feature-foo` or `/posts/hello?branch=feature-foo`) so SSR preview pages read from the same branch workspace the editor is editing. The `Editor` component appends the branch param automatically to `previewBaseByCollection`; your page loaders should read `searchParams.branch` and pass it into `createContentReader`.
405
+ - When building preview URLs, include the current branch as a query param (e.g., `/?branch=feature-foo` or `/posts/hello?branch=feature-foo`) so SSR preview pages read from the same branch workspace the editor is editing. The `Editor` component appends the branch param to the preview URLs it builds; your page loaders should read `searchParams.branch` and pass it as the `branch` option to `getCanopy()`'s `read`/`readByUrlPath`.
405
406
  - For public static builds, omit/ignore the branch param; this pattern is only for the editor/preview environment.
406
407
  - Likewise, include `branch` in your editor route (e.g., `/edit?branch=feature-foo`) and have your editor page pass it to `<Editor>` so reloads/links preserve the selected branch. The `Editor` will also reflect branch switches back into the query string.
407
408
 
@@ -414,7 +415,7 @@ The `useCanopyPreview` hook provides live updates to your preview components as
414
415
  ```tsx
415
416
  'use client'
416
417
 
417
- import { useCanopyPreview } from 'canopycms/client'
418
+ import { useCanopyPreview } from 'canopycms/preview'
418
419
  import type { PostContent } from './schemas'
419
420
 
420
421
  export function PostView({ data }: { data: PostContent }) {
@@ -450,7 +451,7 @@ When using reference fields (foreign key relationships to other content), the ed
450
451
  ```tsx
451
452
  'use client'
452
453
 
453
- import { useCanopyPreview } from 'canopycms/client'
454
+ import { useCanopyPreview } from 'canopycms/preview'
454
455
 
455
456
  export function PostView({ data }: { data: PostContent }) {
456
457
  const { data: liveData, isLoading } = useCanopyPreview<PostContent>({
@@ -503,7 +504,7 @@ The `isLoading` object mirrors your data structure:
503
504
  - **Auto-initialization**: If no `defaultRemoteUrl` is configured, CanopyCMS automatically creates a local remote at `.canopy-dev/remote.git` and seeds it with your current `baseBranch` (e.g., `main`). This allows fully local testing of branching and submission workflows without requiring an external GitHub remote.
504
505
  - **Manual remote**: You can still provide an explicit `defaultRemoteUrl` to use a real remote or custom local path.
505
506
  - Use `npx canopycms sync push` / `npx canopycms sync pull` to sync content between your working tree and the CMS.
506
- - **`prod`**: EFS-backed roots under `$CANOPYCMS_WORKSPACE_ROOT` (default: `/mnt/efs/workspace`). Requires `defaultRemoteUrl`.
507
+ - **`prod`**: EFS-backed roots under `$CANOPYCMS_WORKSPACE_ROOT` (default: `/mnt/efs`). Requires `defaultRemoteUrl`.
507
508
 
508
509
  Branch metadata lives in `.canopy-meta/branch.json`; registry in `branches.json` at the branches root. Content APIs resolve the workspace root from branch state + mode instead of relying on `process.cwd()`.
509
510
 
@@ -596,9 +597,9 @@ export default defineCanopyConfig({
596
597
 
597
598
  ## Assets and media
598
599
 
599
- Uploaded images and PDFs go into a content-addressed asset store, and images are served
600
- through an on-demand transform layer. Both the **local** adapter (for development) and the
601
- **S3** adapter ship today.
600
+ Uploaded images and PDFs go into a content-addressed asset store, and images are served as
601
+ resized derivatives stored before each release. Both the **local** adapter (for development)
602
+ and the **S3** adapter ship today.
602
603
 
603
604
  ```typescript
604
605
  // canopycms.config.ts — production
@@ -616,8 +617,10 @@ media: { adapter: 's3', bucket: 'my-site-assets', region: 'us-east-1' }
616
617
  immutable and deduplicated. A draft branch references its images immediately; publishing
617
618
  needs no separate asset step.
618
619
  - **Delivery** — images are served from `/assets/t/{directives}/…` URLs that resize, crop and
619
- convert to WebP on first request, then cache immutably. Build responsive markup with the
620
- exported helpers, which are client-safe:
620
+ convert, written before release by `canopycms collect-asset-refs <outDir>` then
621
+ `canopycms materialize-assets --refs <file>`, and cached immutably. Every `/assets/t/` URL a
622
+ page can request must appear as text in the build output. Build responsive markup with the
623
+ exported helpers, which are client-safe and take any width from 1 to 8192:
621
624
 
622
625
  ```typescript
623
626
  import { assetUrl, assetSrcSet } from 'canopycms'
@@ -632,7 +635,9 @@ media: { adapter: 's3', bucket: 'my-site-assets', region: 'us-east-1' }
632
635
  />
633
636
  ```
634
637
 
635
- SVGs and PDFs are served statically, with no transform.
638
+ Passing an `image` field value applies its stored crop (`opts.crop` overrides it). Its `width`
639
+ and `height` are the uncropped original's, so scale them by `crop.w` and `crop.h` for a cropped
640
+ value. SVGs and PDFs are served statically, with no transform.
636
641
 
637
642
  - **Where `/assets` is mounted** — the URLs above are root-relative, because that is what gets
638
643
  stored in content and content moves between branches and environments. If your renderer sees
@@ -669,8 +674,10 @@ to enable the interactive crop step, or `altOptional: true` for decorative image
669
674
  > CanopyCMS" as visible to your whole editorial team.
670
675
 
671
676
  For deployment, the `canopycms-cdk` package ships an `AssetSupport` construct that provisions
672
- the bucket, the transform Lambda and the CloudFront behaviors. Fuller documentation — every
673
- config option, the transform directive syntax, and the AWS wiring — is in the project README's
674
- `#media-configuration` section and in `docs/deploying-to-aws.md`. Read both at the ref this
677
+ the bucket and serves `/assets/*` from S3 only: an unmaterialized URL is a 403, and
678
+ `lazyPublicTransforms: true` transforms misses in a Lambda instead. Fuller documentation — every
679
+ config option, the release steps and their IAM grants, and the AWS wiring — is in the project
680
+ README's `#media-configuration` section, `docs/deploying-to-aws.md` and
681
+ `docs/adopter-migration.md`. Read them at the ref this
675
682
  build came from, not at `main` — see
676
683
  [Which documentation matches this build](#which-documentation-matches-this-build).
@@ -13,6 +13,7 @@
13
13
  import { ContentStore } from '../content-store.js';
14
14
  import { BranchSchemaCache } from '../branch-schema-cache.js';
15
15
  import { getErrorMessage } from '../utils/error.js';
16
+ import { workerNotReadyResponse } from '../http/worker-not-ready.js';
16
17
  import { generateAIContent } from './generate.js';
17
18
  import { resolveBranchRoot } from './resolve-branch.js';
18
19
  /**
@@ -76,6 +77,13 @@ export function createAIContentHandler(options) {
76
77
  catch (error) {
77
78
  // Log the real error server-side; don't leak internals to unauthenticated callers
78
79
  console.error('AI content handler error:', getErrorMessage(error));
80
+ const notReady = workerNotReadyResponse(error);
81
+ if (notReady) {
82
+ return new Response(JSON.stringify({ error: notReady.body.error }), {
83
+ status: notReady.status,
84
+ headers: { 'Content-Type': 'application/json', ...notReady.headers },
85
+ });
86
+ }
79
87
  return new Response(JSON.stringify({ error: 'Internal server error' }), {
80
88
  status: 500,
81
89
  headers: { 'Content-Type': 'application/json' },
@@ -17,19 +17,18 @@ import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../bran
17
17
  // Same constants the reader uses, rather than a second copy of the two string
18
18
  // literals.
19
19
  import { BRANCH_META_DIR, BRANCH_META_FILE } from '../branch-metadata-file.js';
20
- import { scanBranchHealth } from '../branch-health.js';
20
+ import { ORPHAN_YOUTH_THRESHOLD_MS, scanBranchHealth, } from '../branch-health.js';
21
+ import { formatDirStamp } from '../branch-provisioning.js';
21
22
  import { ContentIdIndex } from '../content-id-index.js';
22
23
  import { invalidateContentIndexesDurable } from '../content-index-generation.js';
23
24
  import { getDefaultBranchBase, sanitizeBranchName } from '../paths/index.js';
24
25
  import { withOccFileLock } from '../utils/occ-json-write.js';
25
- import { tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
26
+ import { branchProvisioningLockName, tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
26
27
  import { withContentWriteLock, ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, } from '../utils/content-write-lock.js';
27
28
  import { getErrorMessage, isNodeError, isNotFoundError } from '../utils/error.js';
28
29
  import { defineEndpoint } from './route-builder.js';
29
30
  /** [H1] A fresh (< 5 min old) init lock blocks purge -- provisioning may be running. */
30
31
  const PROVISIONING_LOCK_FRESH_MS = 5 * 60_000;
31
- /** An orphan dir younger than this may still be a clone in progress; corrupt dirs are exempt. */
32
- const ORPHAN_YOUTH_THRESHOLD_MS = 15 * 60_000;
33
32
  // Mirrors deleteTaskHandler's fileName pattern in admin.ts: conservative
34
33
  // charset plus explicit traversal/dot-prefix refinements (the regex alone
35
34
  // technically excludes '/' already, but the refinements keep intent explicit
@@ -40,13 +39,6 @@ const dirNameSchema = z
40
39
  .refine((v) => !v.includes('..'), { message: 'dirName must not contain ..' })
41
40
  .refine((v) => !v.startsWith('.'), { message: 'dirName must not be dot-prefixed' });
42
41
  const branchDirParamsSchema = z.object({ dirName: dirNameSchema });
43
- /** Compact UTC stamp for trash/archive names: `YYYYMMDDTHHMMSSZ` (no colons -- portability). */
44
- function formatTrashStamp(date) {
45
- return date
46
- .toISOString()
47
- .replace(/[-:]/g, '')
48
- .replace(/\.\d{3}Z$/, 'Z');
49
- }
50
42
  /**
51
43
  * Re-resolve dirName under baseRoot and enforce containment (belt-and-
52
44
  * suspenders on top of the zod regex, matching deleteTaskHandler's pattern
@@ -147,12 +139,17 @@ const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
147
139
  // True orphan (no branch.json, no load error) vs. corrupt-metadata
148
140
  // (loadOnly threw). Only true orphans are subject to the youth rail below.
149
141
  const isTrueOrphan = !hadLoadError;
150
- // [H1] freshness rail: a fresh init lock means provisioning may genuinely
151
- // be in progress; a stale one is just crash debris and does not block.
152
- const lockPath = path.join(baseRoot, `.${params.dirName}.init.lock`);
142
+ // [H1] freshness rail: a fresh init lock means provisioning, or the worker's
143
+ // sync of this branch, may genuinely be in progress; a stale one is just
144
+ // crash debris and does not block.
145
+ const lockPath = path.join(baseRoot, branchProvisioningLockName(params.dirName));
153
146
  const lockStat = await fs.stat(lockPath).catch(() => null);
154
147
  if (lockStat && Date.now() - lockStat.mtimeMs < PROVISIONING_LOCK_FRESH_MS) {
155
- return { ok: false, status: 409, error: 'Provisioning may be in progress' };
148
+ return {
149
+ ok: false,
150
+ status: 409,
151
+ error: 'Provisioning, or the worker syncing this branch, may be in progress',
152
+ };
156
153
  }
157
154
  // Youth rail: a brand-new orphan dir may be a clone that just hasn't
158
155
  // written branch.json yet. Corrupt-metadata dirs are exempt -- a
@@ -171,7 +168,7 @@ const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
171
168
  // contention instead of hanging the request for ~5 minutes.
172
169
  let releaseProvisioningLock;
173
170
  try {
174
- releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, `.${params.dirName}.init.lock`);
171
+ releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, branchProvisioningLockName(params.dirName));
175
172
  }
176
173
  catch (err) {
177
174
  if (isNodeError(err) && err.code === 'ELOCKED') {
@@ -197,7 +194,7 @@ const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
197
194
  // [C1] The timestamp lives in the NAME, not the dir's mtime: rename()
198
195
  // preserves the original mtime, so mtime-based retention would delete
199
196
  // a months-stale orphan's trash on the very first cleanup pass.
200
- const trashName = `.trash-${params.dirName}-${formatTrashStamp(new Date())}`;
197
+ const trashName = `.trash-${params.dirName}-${formatDirStamp(new Date())}`;
201
198
  const trashPath = path.join(baseRoot, trashName);
202
199
  try {
203
200
  await fs.rename(dirPath, trashPath);
@@ -279,7 +276,7 @@ const repairBranchDirHandler = async (_gc, ctx, req, params) => {
279
276
  // Held until the `finally` below, AFTER save() completes.
280
277
  let releaseProvisioningLock;
281
278
  try {
282
- releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, `.${params.dirName}.init.lock`);
279
+ releaseProvisioningLock = await tryAcquireProvisioningLock(baseRoot, branchProvisioningLockName(params.dirName));
283
280
  }
284
281
  catch (err) {
285
282
  if (isNodeError(err) && err.code === 'ELOCKED') {
@@ -301,7 +298,7 @@ const repairBranchDirHandler = async (_gc, ctx, req, params) => {
301
298
  if (recheck === 'missing') {
302
299
  throw new RepairPreconditionError('No metadata file -- use purge for orphans', 409);
303
300
  }
304
- const archivedName = `${BRANCH_META_FILE}.corrupt-${formatTrashStamp(new Date())}`;
301
+ const archivedName = `${BRANCH_META_FILE}.corrupt-${formatDirStamp(new Date())}`;
305
302
  await fs.rename(branchJsonPath, path.join(dirPath, BRANCH_META_DIR, archivedName));
306
303
  return archivedName;
307
304
  });
@@ -444,7 +441,7 @@ const repairContentDuplicatesHandler = async (_gc, ctx, _req, params) => {
444
441
  if (duplicates.length === 0) {
445
442
  return { ok: false, status: 409, error: 'No duplicate content IDs found' };
446
443
  }
447
- const stamp = formatTrashStamp(new Date());
444
+ const stamp = formatDirStamp(new Date());
448
445
  try {
449
446
  for (const dup of duplicates) {
450
447
  const archivedAs = [];
@@ -8,7 +8,7 @@
8
8
  import { z } from 'zod';
9
9
  import type { ApiResponse } from './types.js';
10
10
  import type { Task, QueueStats, CorruptTaskFile } from '../task-queue/cms-task-queue.js';
11
- import type { WorkerStatusReport } from '../types.js';
11
+ import type { BuildIdentity, WorkerStatusReport } from '../types.js';
12
12
  import type { OperatingMode } from '../operating-mode/index.js';
13
13
  export type { BranchHealthResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse, } from './admin-branch-health.js';
14
14
  type WorkerLivenessState = 'alive' | 'stale' | 'absent';
@@ -31,6 +31,27 @@ export interface AdminStatusData {
31
31
  worker: WorkerLiveness;
32
32
  workerStatus: WorkerStatusReport | null;
33
33
  statusReadError?: string;
34
+ /**
35
+ * Why this process cannot provision the settings workspace (groups and path rules), when it
36
+ * cannot. Its requests that resolve a user answer 503 meanwhile; bootstrap admins still reach
37
+ * /admin.
38
+ */
39
+ settingsWorkspaceError?: string;
40
+ /** Build of the API process answering this request. */
41
+ build: BuildIdentity;
42
+ /** Whether `media` is configured; without it every upload returns 501. */
43
+ assetStore: {
44
+ configured: boolean;
45
+ };
46
+ /**
47
+ * Whether THIS API process can load sharp. When false, any editor image not yet stored (a new
48
+ * crop, thumbnail or preview size, transformed on demand here) fails, and raster uploads are
49
+ * accepted without decode validation. Speaks only for the process that answers the request.
50
+ */
51
+ imageProcessing: {
52
+ available: boolean;
53
+ error?: string;
54
+ };
34
55
  }
35
56
  /** Response type for GET /admin/status */
36
57
  export type AdminStatusResponse = ApiResponse<AdminStatusData>;
@@ -54,10 +75,10 @@ declare const listAdminTasksParamsSchema: z.ZodObject<{
54
75
  status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
55
76
  limit: z.ZodOptional<z.ZodNumber>;
56
77
  }, "strip", z.ZodTypeAny, {
57
- status: "pending" | "processing" | "completed" | "failed" | "corrupt";
78
+ status: "failed" | "corrupt" | "pending" | "processing" | "completed";
58
79
  limit?: number | undefined;
59
80
  }, {
60
- status: "pending" | "processing" | "completed" | "failed" | "corrupt";
81
+ status: "failed" | "corrupt" | "pending" | "processing" | "completed";
61
82
  limit?: number | undefined;
62
83
  }>;
63
84
  export type ListAdminTasksParams = z.infer<typeof listAdminTasksParamsSchema>;
@@ -65,10 +86,10 @@ declare const deleteTaskParamsSchema: z.ZodObject<{
65
86
  status: z.ZodEnum<["pending", "failed", "corrupt"]>;
66
87
  fileName: z.ZodEffects<z.ZodString, string, string>;
67
88
  }, "strip", z.ZodTypeAny, {
68
- status: "pending" | "failed" | "corrupt";
89
+ status: "failed" | "corrupt" | "pending";
69
90
  fileName: string;
70
91
  }, {
71
- status: "pending" | "failed" | "corrupt";
92
+ status: "failed" | "corrupt" | "pending";
72
93
  fileName: string;
73
94
  }>;
74
95
  export type DeleteTaskParams = z.infer<typeof deleteTaskParamsSchema>;
@@ -108,10 +129,10 @@ export declare const ADMIN_ROUTES: {
108
129
  status: z.ZodEnum<["pending", "processing", "completed", "failed", "corrupt"]>;
109
130
  limit: z.ZodOptional<z.ZodNumber>;
110
131
  }, "strip", z.ZodTypeAny, {
111
- status: "pending" | "processing" | "completed" | "failed" | "corrupt";
132
+ status: "failed" | "corrupt" | "pending" | "processing" | "completed";
112
133
  limit?: number | undefined;
113
134
  }, {
114
- status: "pending" | "processing" | "completed" | "failed" | "corrupt";
135
+ status: "failed" | "corrupt" | "pending" | "processing" | "completed";
115
136
  limit?: number | undefined;
116
137
  }>, undefined, AdminTasksResponse>;
117
138
  readonly retryTask: import("./route-builder.js").RouteDefinition<z.ZodObject<{
@@ -125,10 +146,10 @@ export declare const ADMIN_ROUTES: {
125
146
  status: z.ZodEnum<["pending", "failed", "corrupt"]>;
126
147
  fileName: z.ZodEffects<z.ZodString, string, string>;
127
148
  }, "strip", z.ZodTypeAny, {
128
- status: "pending" | "failed" | "corrupt";
149
+ status: "failed" | "corrupt" | "pending";
129
150
  fileName: string;
130
151
  }, {
131
- status: "pending" | "failed" | "corrupt";
152
+ status: "failed" | "corrupt" | "pending";
132
153
  fileName: string;
133
154
  }>, undefined, AdminDeleteTaskResponse>;
134
155
  };
package/dist/api/admin.js CHANGED
@@ -12,7 +12,9 @@ import { getQueueStats, listTasks, listCorruptTaskFiles, requeueFailedTask, } fr
12
12
  import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
13
13
  import { WORKER_STATUS_FILE } from '../task-queue/worker-status.js';
14
14
  import { defineEndpoint } from './route-builder.js';
15
- import { getErrorMessage, isNotFoundError } from '../utils/error.js';
15
+ import { getErrorMessage, isNotFoundError, redactCredentials } from '../utils/error.js';
16
+ import { getBuildIdentity } from '../build-identity.js';
17
+ import { loadSharp } from '../assets/sharp-loader.js';
16
18
  import { ADMIN_BRANCH_HEALTH_ROUTES } from './admin-branch-health.js';
17
19
  /**
18
20
  * 60_000 = DEFAULT_LOCK_STALE_MS in worker/cms-worker.ts, hardcoded rather
@@ -72,6 +74,15 @@ async function readWorkerStatus(taskDir) {
72
74
  return { workerStatus: null, statusReadError: getErrorMessage(err) };
73
75
  }
74
76
  }
77
+ async function readSettingsWorkspaceError(ctx) {
78
+ try {
79
+ await ctx.services.getSettingsBranchRoot();
80
+ return undefined;
81
+ }
82
+ catch (err) {
83
+ return redactCredentials(getErrorMessage(err));
84
+ }
85
+ }
75
86
  /** Age (ms) of the oldest file in pending/, or undefined if empty/missing. */
76
87
  async function getOldestPendingAgeMs(taskDir) {
77
88
  const pendingDir = path.join(taskDir, 'pending');
@@ -123,14 +134,26 @@ const deleteTaskParamsSchema = z.object({
123
134
  .regex(/^[A-Za-z0-9._-]{1,120}\.json$/)
124
135
  .refine((v) => !v.includes('..'), { message: 'fileName must not contain ..' }),
125
136
  });
137
+ /** Loads sharp in this process; a failure is reported, never thrown. */
138
+ async function probeImageProcessing() {
139
+ try {
140
+ await loadSharp();
141
+ return { available: true };
142
+ }
143
+ catch (err) {
144
+ return { available: false, error: getErrorMessage(err) };
145
+ }
146
+ }
126
147
  const getAdminStatusHandler = async (_gc, ctx, _req) => {
127
148
  const taskDir = getTaskQueueDir(ctx.services.config);
128
149
  try {
129
- const [queueStats, oldestPendingAgeMs, worker, { workerStatus, statusReadError }] = await Promise.all([
150
+ const [queueStats, oldestPendingAgeMs, worker, { workerStatus, statusReadError }, settingsWorkspaceError, imageProcessing,] = await Promise.all([
130
151
  getQueueStats(taskDir),
131
152
  getOldestPendingAgeMs(taskDir),
132
153
  classifyWorkerLiveness(taskDir),
133
154
  readWorkerStatus(taskDir),
155
+ readSettingsWorkspaceError(ctx),
156
+ probeImageProcessing(),
134
157
  ]);
135
158
  return {
136
159
  ok: true,
@@ -145,6 +168,10 @@ const getAdminStatusHandler = async (_gc, ctx, _req) => {
145
168
  worker,
146
169
  workerStatus,
147
170
  ...(statusReadError ? { statusReadError } : {}),
171
+ ...(settingsWorkspaceError ? { settingsWorkspaceError } : {}),
172
+ build: getBuildIdentity(),
173
+ assetStore: { configured: !!ctx.assetStore },
174
+ imageProcessing,
148
175
  },
149
176
  };
150
177
  }
@@ -255,6 +282,9 @@ const getAdminStatus = defineEndpoint({
255
282
  queue: { pending: 0, processing: 0, completed: 0, failed: 0, corrupt: 0 },
256
283
  worker: { state: 'absent' },
257
284
  workerStatus: null,
285
+ build: { canopycmsVersion: '0.0.0' },
286
+ assetStore: { configured: false },
287
+ imageProcessing: { available: true },
258
288
  },
259
289
  guards: ['admin'],
260
290
  handler: getAdminStatusHandler,
@@ -4,8 +4,8 @@ import { ASSET_PREFIXES } from '../assets/keys.js';
4
4
  import { ALLOWED_UPLOAD_CONTENT_TYPES } from '../assets/pipeline.js';
5
5
  import { finalizeStagedUpload } from '../assets/finalize.js';
6
6
  import { assetSrc } from '../assets/asset-src.js';
7
- import { formatDirectives, parseTransformPath } from '../assets/transform-directives.js';
8
- import { applyTransform } from '../assets/transform.js';
7
+ import { canonicalizeTransformPath } from '../assets/transform-directives.js';
8
+ import { storeTransform, TRANSFORM_CACHE_CONTROL } from '../assets/materialize.js';
9
9
  import { isAdmin } from '../authorization/helpers.js';
10
10
  function toAssetRecord(meta) {
11
11
  return { ...meta, src: assetSrc(meta) };
@@ -212,63 +212,41 @@ const deleteAssetHandler = async (ctx, req, params) => {
212
212
  await ctx.assetStore.deleteMeta(params.key);
213
213
  return { ok: true, status: 200, data: { deleted: true } };
214
214
  };
215
- /** Cache-Control applied to every transform output this route writes/serves - matches finalize.ts's PUBLIC_CACHE_CONTROL for static public objects. */
216
- const TRANSFORM_CACHE_CONTROL = 'public, max-age=31536000, immutable';
217
215
  /**
218
- * Lazy dev-mode emulation of the prod transform Lambda (reuses `parseTransformPath`/
219
- * `formatDirectives`/`applyTransform` unchanged): parse, load the original, transform, write the
220
- * result back under its CANONICAL key (so a non-canonically-ordered directive string still
221
- * dedupes with any equivalent request), then serve the bytes just computed.
222
- *
223
- * `key` here already starts with `assets/t/` and already missed `rawAssetHandler`'s cache-hit
224
- * `readPublicObject` check.
216
+ * The largest body this route returns inline. The CMS Lambda's Function URL buffers its response
217
+ * and caps it at 6 MiB after base64 encoding, which inflates by 4/3, so 4 MiB leaves headroom.
218
+ * Same bound and reasoning as the transform Lambda's `INLINE_BODY_LIMIT_BYTES`.
225
219
  */
226
- async function serveLazyTransform(assetStore, key) {
227
- const rest = key.slice(ASSET_PREFIXES.transform.length + 1);
228
- const parsed = parseTransformPath(rest.split('/'));
229
- if (!parsed.ok) {
230
- return { ok: false, status: 400, error: parsed.error };
231
- }
232
- const meta = await assetStore.getMeta(parsed.hash32);
233
- if (!meta) {
234
- return { ok: false, status: 404, error: 'Not found' };
235
- }
236
- if (meta.kind !== 'raster') {
237
- return { ok: false, status: 400, error: 'Not a raster asset - svg/pdf are served statically' };
238
- }
239
- // The slug is decorative in the URL but load-bearing in the stored key, so it must equal the
240
- // asset's real slug — the parser only enforces `[a-z0-9-]+`, and any other string that passes
241
- // it aliases the same image into a new cache key. Mirrors the prod transform Lambda's check
242
- // (assets/asset-url.ts); the two paths must agree, or dev accepts URLs prod 404s.
243
- if (parsed.slug !== meta.slug) {
244
- return { ok: false, status: 404, error: 'Not found' };
245
- }
246
- // When the URL omits an explicit `f=` format, the transform preserves the
247
- // source format, so the URL's `{ext}` must match the source's real ext
248
- // exactly - the parser alone can't check this (it doesn't know the source
249
- // format until this meta lookup).
250
- const requestedFormat = parsed.directives.identity ? undefined : parsed.directives.format;
251
- if (requestedFormat === undefined && parsed.ext !== meta.ext) {
252
- return { ok: false, status: 400, error: 'Extension does not match the source format' };
253
- }
254
- const original = await assetStore.readOriginal(parsed.hash32);
255
- if (!original) {
256
- return { ok: false, status: 404, error: 'Not found' };
257
- }
258
- const transformed = await applyTransform({ data: original.data, ext: original.ext }, parsed.directives);
220
+ const INLINE_BODY_LIMIT_BYTES = 4 * 1024 * 1024;
221
+ /**
222
+ * A 302 to a presigned store URL. `no-store` because the URL expires: a cached redirect would
223
+ * outlive it, and a browser revisiting the page would follow it to a 403.
224
+ */
225
+ function presignedRedirect(url) {
226
+ return {
227
+ kind: 'binary',
228
+ status: 302,
229
+ body: new Uint8Array(),
230
+ headers: { location: url, cacheControl: 'no-store' },
231
+ };
232
+ }
233
+ /**
234
+ * This route's own lazy transform: `storeTransform` computes and stores the output, then this
235
+ * serves the bytes just computed. `parsed` is the canonical parse from `canonicalizeTransformPath`,
236
+ * and `canonicalKey` already missed `rawAssetHandler`'s cache check.
237
+ */
238
+ async function serveLazyTransform(assetStore, parsed, canonicalKey) {
239
+ const transformed = await storeTransform(assetStore, parsed, canonicalKey);
259
240
  if (!transformed.ok) {
260
- // Pass the real status through instead of flattening every rejection to 502:
261
- // `applyTransform` already distinguishes client-input errors (400/413) from a genuine decode
262
- // failure (422), none of which are "this server failed." Mirrors the prod transform Lambda.
263
- return { ok: false, status: transformed.status, error: transformed.error };
241
+ // The real status, not a flat 502: none of these rejections is "this server failed".
242
+ const error = transformed.status === 404 ? 'Not found' : transformed.error;
243
+ return { ok: false, status: transformed.status, error };
244
+ }
245
+ if (transformed.data.byteLength > INLINE_BODY_LIMIT_BYTES && assetStore.presignPublicObjectRead) {
246
+ const url = await assetStore.presignPublicObjectRead(canonicalKey);
247
+ if (url)
248
+ return presignedRedirect(url);
264
249
  }
265
- const canonicalKey = `${ASSET_PREFIXES.transform}/${formatDirectives(parsed.directives)}/${parsed.hash32}/${parsed.slug}.${parsed.ext}`;
266
- await assetStore.putPublicObject({
267
- key: canonicalKey,
268
- data: transformed.data,
269
- contentType: transformed.contentType,
270
- cacheControl: TRANSFORM_CACHE_CONTROL,
271
- });
272
250
  return {
273
251
  kind: 'binary',
274
252
  status: 200,
@@ -276,16 +254,47 @@ async function serveLazyTransform(assetStore, key) {
276
254
  headers: { contentType: transformed.contentType, cacheControl: TRANSFORM_CACHE_CONTROL },
277
255
  };
278
256
  }
257
+ /** A redirect to, or the bytes of, the public object stored at `key`; `null` when there is none. */
258
+ async function serveStoredObject(assetStore, key) {
259
+ if (assetStore.presignPublicObjectRead) {
260
+ const url = await assetStore.presignPublicObjectRead(key);
261
+ return url ? presignedRedirect(url) : null;
262
+ }
263
+ const object = await assetStore.readPublicObject(key);
264
+ if (!object)
265
+ return null;
266
+ return {
267
+ kind: 'binary',
268
+ status: 200,
269
+ body: object.data,
270
+ headers: {
271
+ contentType: object.contentType,
272
+ contentDisposition: object.contentDisposition,
273
+ cacheControl: object.cacheControl,
274
+ },
275
+ };
276
+ }
279
277
  /**
280
278
  * Serve a public asset object (sanitized svg/pdf finalize wrote, or a cached transform output)
281
- * for dev-mode `/assets/*` rewrites. Hand-built (not `defineEndpoint`), not registered in
282
- * `ASSET_ROUTES`/the client generator: this returns raw bytes (`CanopyBinaryResponse`), not a
283
- * JSON envelope, so a generated `response.json()` client method would be wrong. Consumers hit
284
- * this route directly (`<img>`/`<a>` src, or a framework rewrite), never through `client.ts`.
279
+ * to the editor, the live preview, and `withCanopy`'s `/assets/*` rewrite. Hand-built (not
280
+ * `defineEndpoint`), not registered in `ASSET_ROUTES`/the client generator: this returns raw bytes
281
+ * (`CanopyBinaryResponse`), not a JSON envelope, so a generated `response.json()` client method
282
+ * would be wrong. Consumers hit this route directly (`<img>`/`<a>` src, or a framework rewrite),
283
+ * never through `client.ts`.
285
284
  *
286
- * Transform outputs (`assets/t/...`) are cache-checked like any other public object first — only
287
- * a MISS under `assets/t/` falls through to `serveLazyTransform`. Mirrors prod (CloudFront
288
- * origin-group -> S3 -> Lambda on miss).
285
+ * A transform key (`assets/t/...`) is resolved to its canonical key first, and that key is what is
286
+ * cache-checked and, on a miss, computed by `serveLazyTransform`. Mirrors `AssetSupport`'s lazy
287
+ * mode (CloudFront origin-group -> S3 -> Lambda on miss), except that a non-canonical spelling is
288
+ * served the canonical bytes rather than the Lambda's 301: this route is authenticated, and
289
+ * redirecting to `/assets/t/...` would bounce the request onto the public path.
290
+ *
291
+ * A stored object on a store that can presign (S3) is answered with a 302 to a presigned GET, so
292
+ * its bytes never pass through this process: the CMS Lambda is concurrency-capped and uncached,
293
+ * the live preview asks for every image on a page at once, and its Function URL cannot return a
294
+ * body over about 6 MiB. The redirect never targets the public `/assets/...` URL, because whether
295
+ * that URL reaches this route again is topology: `withCanopy` rewrites `/assets/:path*` here, so
296
+ * on any deployment where Next serves `/assets` the redirect would loop. Other stores stream the
297
+ * bytes, as does a fresh transform no larger than `INLINE_BODY_LIMIT_BYTES`.
289
298
  */
290
299
  const rawAssetHandler = async (ctx, _req, params) => {
291
300
  if (!ctx.assetStore)
@@ -300,23 +309,22 @@ const rawAssetHandler = async (ctx, _req, params) => {
300
309
  if (!key.startsWith(publicPrefix) || key.includes('..')) {
301
310
  return { ok: false, status: 404, error: 'Not found' };
302
311
  }
303
- const object = await ctx.assetStore.readPublicObject(key);
304
- if (object) {
305
- return {
306
- kind: 'binary',
307
- status: 200,
308
- body: object.data,
309
- headers: {
310
- contentType: object.contentType,
311
- contentDisposition: object.contentDisposition,
312
- cacheControl: object.cacheControl,
313
- },
314
- };
312
+ let readKey = key;
313
+ let transform;
314
+ if (key.startsWith(transformPrefix)) {
315
+ const canonical = canonicalizeTransformPath(key.slice(transformPrefix.length).split('/'), 'any');
316
+ if (!canonical.ok)
317
+ return { ok: false, status: 400, error: canonical.error };
318
+ readKey = `${transformPrefix}${canonical.canonicalPath}`;
319
+ transform = canonical;
315
320
  }
316
- if (!key.startsWith(transformPrefix)) {
321
+ const stored = await serveStoredObject(ctx.assetStore, readKey);
322
+ if (stored)
323
+ return stored;
324
+ if (!transform) {
317
325
  return { ok: false, status: 404, error: 'Not found' };
318
326
  }
319
- return serveLazyTransform(ctx.assetStore, key);
327
+ return serveLazyTransform(ctx.assetStore, transform, readKey);
320
328
  };
321
329
  // Deliberately no 'writableBranch' guard on any endpoint below: none take a
322
330
  // :branch param -- the asset store is branch-agnostic (a single global store,
@@ -0,0 +1,12 @@
1
+ /**
2
+ * How long a create of a branch name its own creator just made answers with that branch rather
3
+ * than a 409, so a request killed between publishing the branch and responding is safe to retry.
4
+ * Dependency-free: the editor applies the same rule when it settles an unanswered create.
5
+ * @internal Exported for tests.
6
+ */
7
+ export declare const IDEMPOTENT_CREATE_WINDOW_MS: number;
8
+ /** Whether `branch` can be the result of `userId`'s create: theirs, and inside the window. */
9
+ export declare function isCreatorsRecentBranch(branch: {
10
+ createdBy: string;
11
+ createdAt: string;
12
+ }, userId: string | undefined): boolean;