canopycms 0.0.67 → 0.0.68-int.100

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 (268) hide show
  1. package/README.md +21 -14
  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 +111 -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 +10 -1
  23. package/dist/api/github-sync.js +20 -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/index.d.ts +1 -1
  36. package/dist/assets/materialize.d.ts +122 -0
  37. package/dist/assets/materialize.js +305 -0
  38. package/dist/assets/store-local.d.ts +4 -0
  39. package/dist/assets/store-local.js +11 -0
  40. package/dist/assets/store-s3.d.ts +10 -1
  41. package/dist/assets/store-s3.js +55 -3
  42. package/dist/assets/transform-directives.d.ts +52 -13
  43. package/dist/assets/transform-directives.js +87 -27
  44. package/dist/assets/transform.d.ts +3 -3
  45. package/dist/assets/transform.js +13 -8
  46. package/dist/assets/types.d.ts +14 -1
  47. package/dist/auth/file-based-auth-cache.js +7 -8
  48. package/dist/authorization/content.d.ts +5 -4
  49. package/dist/authorization/content.js +5 -5
  50. package/dist/authorization/path.d.ts +11 -6
  51. package/dist/authorization/path.js +11 -6
  52. package/dist/authorization/types.d.ts +2 -2
  53. package/dist/branch-health.d.ts +7 -1
  54. package/dist/branch-health.js +8 -3
  55. package/dist/branch-metadata.d.ts +5 -5
  56. package/dist/branch-metadata.js +44 -36
  57. package/dist/branch-provisioning.d.ts +167 -0
  58. package/dist/branch-provisioning.js +501 -0
  59. package/dist/branch-schema-cache.d.ts +5 -3
  60. package/dist/branch-schema-cache.js +29 -11
  61. package/dist/branch-sparse.d.ts +23 -0
  62. package/dist/branch-sparse.js +118 -0
  63. package/dist/branch-workspace.d.ts +42 -3
  64. package/dist/branch-workspace.js +240 -94
  65. package/dist/build/asset-refs.d.ts +93 -0
  66. package/dist/build/asset-refs.js +251 -0
  67. package/dist/build-identity.d.ts +3 -0
  68. package/dist/build-identity.js +13 -0
  69. package/dist/cli/asset-refs.d.ts +30 -0
  70. package/dist/cli/asset-refs.js +168 -0
  71. package/dist/cli/cli.d.ts +2 -1
  72. package/dist/cli/cli.js +4843 -1870
  73. package/dist/cli/generate-ai-content.js +1584 -444
  74. package/dist/cli/github-app-manifest.d.ts +4 -3
  75. package/dist/cli/github-app-manifest.js +4 -3
  76. package/dist/cli/init.js +30 -27
  77. package/dist/cli/migrate.js +12 -8
  78. package/dist/cli/sync.js +83 -37
  79. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  80. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  81. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  82. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  83. package/dist/config/helpers.js +1 -2
  84. package/dist/config/schemas/config.d.ts +19 -32
  85. package/dist/config/schemas/config.js +9 -11
  86. package/dist/config/schemas/field.js +1 -0
  87. package/dist/config/schemas/media.d.ts +4 -13
  88. package/dist/config/schemas/media.js +5 -8
  89. package/dist/config/schemas/url.d.ts +4 -20
  90. package/dist/config/schemas/url.js +6 -22
  91. package/dist/config/types.d.ts +30 -20
  92. package/dist/config/validation.d.ts +6 -0
  93. package/dist/config/validation.js +37 -0
  94. package/dist/content-listing.d.ts +12 -12
  95. package/dist/content-listing.js +10 -6
  96. package/dist/content-reader.d.ts +11 -2
  97. package/dist/content-reader.js +22 -14
  98. package/dist/content-store.d.ts +37 -9
  99. package/dist/content-store.js +70 -43
  100. package/dist/content-tree.d.ts +1 -1
  101. package/dist/content-tree.js +2 -2
  102. package/dist/context.d.ts +27 -9
  103. package/dist/context.js +154 -70
  104. package/dist/editor/BranchManager.d.ts +7 -1
  105. package/dist/editor/BranchManager.js +29 -14
  106. package/dist/editor/CanopyEditor.d.ts +1 -1
  107. package/dist/editor/CanopyEditor.js +2 -6
  108. package/dist/editor/Editor.d.ts +4 -3
  109. package/dist/editor/Editor.js +24 -20
  110. package/dist/editor/FormRenderer.js +34 -18
  111. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  112. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  113. package/dist/editor/components/BranchesDrawer.js +4 -0
  114. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  115. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  116. package/dist/editor/context/AssetContext.d.ts +18 -35
  117. package/dist/editor/context/AssetContext.js +20 -29
  118. package/dist/editor/context/index.d.ts +1 -1
  119. package/dist/editor/context/index.js +1 -1
  120. package/dist/editor/editor-config.d.ts +1 -2
  121. package/dist/editor/editor-config.js +0 -23
  122. package/dist/editor/editor-utils.d.ts +24 -21
  123. package/dist/editor/editor-utils.js +75 -62
  124. package/dist/editor/fields/BlockField.d.ts +1 -0
  125. package/dist/editor/fields/BlockField.js +6 -4
  126. package/dist/editor/fields/CodeField.d.ts +1 -0
  127. package/dist/editor/fields/CodeField.js +2 -2
  128. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  129. package/dist/editor/fields/DateTimeField.js +2 -2
  130. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  131. package/dist/editor/fields/FieldDescription.js +11 -0
  132. package/dist/editor/fields/ImageField.d.ts +1 -0
  133. package/dist/editor/fields/ImageField.js +3 -2
  134. package/dist/editor/fields/InlineGroupField.js +4 -1
  135. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  136. package/dist/editor/fields/MarkdownField.js +115 -20
  137. package/dist/editor/fields/NumberField.d.ts +1 -0
  138. package/dist/editor/fields/NumberField.js +2 -2
  139. package/dist/editor/fields/NumberListField.d.ts +1 -0
  140. package/dist/editor/fields/NumberListField.js +2 -2
  141. package/dist/editor/fields/ObjectField.d.ts +1 -0
  142. package/dist/editor/fields/ObjectField.js +5 -2
  143. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  144. package/dist/editor/fields/ReferenceField.js +5 -4
  145. package/dist/editor/fields/SelectField.d.ts +1 -0
  146. package/dist/editor/fields/SelectField.js +2 -2
  147. package/dist/editor/fields/StringListField.d.ts +1 -0
  148. package/dist/editor/fields/StringListField.js +2 -2
  149. package/dist/editor/fields/TextField.d.ts +1 -0
  150. package/dist/editor/fields/TextField.js +2 -2
  151. package/dist/editor/fields/ToggleField.d.ts +1 -0
  152. package/dist/editor/fields/ToggleField.js +7 -4
  153. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  154. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  155. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  156. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  157. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  158. package/dist/editor/hooks/create-branch-request.js +52 -0
  159. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  160. package/dist/editor/hooks/useBranchActions.js +48 -17
  161. package/dist/editor/hooks/useBranchManager.d.ts +11 -0
  162. package/dist/editor/hooks/useBranchManager.js +80 -2
  163. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  164. package/dist/editor/hooks/useBranchesData.js +9 -3
  165. package/dist/editor/hooks/useCommentSystem.js +25 -6
  166. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  167. package/dist/editor/hooks/useDraftManager.js +193 -36
  168. package/dist/editor/hooks/useEntriesData.js +3 -3
  169. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  170. package/dist/editor/hooks/useEntryManager.js +34 -19
  171. package/dist/editor/media/AssetCard.d.ts +1 -1
  172. package/dist/editor/media/crop-math.d.ts +3 -4
  173. package/dist/editor/media/crop-math.js +11 -22
  174. package/dist/editor/media/editor-image-src.d.ts +7 -0
  175. package/dist/editor/media/editor-image-src.js +12 -0
  176. package/dist/editor/preview-asset-base.d.ts +8 -0
  177. package/dist/editor/preview-asset-base.js +13 -0
  178. package/dist/editor/preview-bridge.d.ts +8 -1
  179. package/dist/editor/preview-bridge.js +15 -2
  180. package/dist/editor/preview-path.d.ts +10 -0
  181. package/dist/editor/preview-path.js +20 -0
  182. package/dist/editor/theme.js +2 -2
  183. package/dist/entry-schema-registry.d.ts +4 -4
  184. package/dist/entry-schema-registry.js +10 -6
  185. package/dist/entry-schema.d.ts +27 -4
  186. package/dist/entry-schema.js +20 -3
  187. package/dist/git-manager.d.ts +173 -6
  188. package/dist/git-manager.js +594 -76
  189. package/dist/github-service.d.ts +8 -0
  190. package/dist/github-service.js +5 -1
  191. package/dist/http/handler.js +37 -13
  192. package/dist/http/index.d.ts +2 -0
  193. package/dist/http/index.js +2 -0
  194. package/dist/http/router.d.ts +2 -0
  195. package/dist/http/router.js +2 -1
  196. package/dist/http/types.d.ts +2 -0
  197. package/dist/http/worker-not-ready.d.ts +10 -0
  198. package/dist/http/worker-not-ready.js +24 -0
  199. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  200. package/dist/operating-mode/types.d.ts +1 -4
  201. package/dist/paths/branch-name.d.ts +5 -0
  202. package/dist/paths/branch-name.js +9 -0
  203. package/dist/paths/branch.d.ts +6 -2
  204. package/dist/paths/branch.js +16 -10
  205. package/dist/paths/index.d.ts +3 -3
  206. package/dist/paths/index.js +3 -3
  207. package/dist/paths/normalize.d.ts +7 -0
  208. package/dist/paths/normalize.js +9 -0
  209. package/dist/paths/validation.js +0 -2
  210. package/dist/reference-resolver.d.ts +5 -21
  211. package/dist/reference-resolver.js +9 -44
  212. package/dist/resolve-canopy-user.js +2 -1
  213. package/dist/schema/schema-store.d.ts +54 -31
  214. package/dist/schema/schema-store.js +77 -31
  215. package/dist/server.d.ts +24 -9
  216. package/dist/server.js +22 -8
  217. package/dist/services.d.ts +18 -6
  218. package/dist/services.js +85 -70
  219. package/dist/settings-workspace.d.ts +23 -3
  220. package/dist/settings-workspace.js +197 -45
  221. package/dist/static/seo.d.ts +2 -14
  222. package/dist/static/seo.js +2 -25
  223. package/dist/submission-attribution.d.ts +73 -0
  224. package/dist/submission-attribution.js +221 -0
  225. package/dist/sync-core.d.ts +12 -1
  226. package/dist/sync-core.js +31 -13
  227. package/dist/task-queue/worker-status.d.ts +8 -0
  228. package/dist/task-queue/worker-status.js +17 -0
  229. package/dist/types.d.ts +31 -2
  230. package/dist/utils/content-write-lock.d.ts +18 -7
  231. package/dist/utils/content-write-lock.js +18 -9
  232. package/dist/utils/debug.d.ts +8 -0
  233. package/dist/utils/debug.js +10 -2
  234. package/dist/utils/git.d.ts +32 -0
  235. package/dist/utils/git.js +42 -0
  236. package/dist/utils/occ-json-write.js +10 -2
  237. package/dist/utils/provision-log.d.ts +25 -0
  238. package/dist/utils/provision-log.js +49 -0
  239. package/dist/utils/provisioning-lock.d.ts +35 -5
  240. package/dist/utils/provisioning-lock.js +73 -12
  241. package/dist/utils/request-timing.d.ts +25 -0
  242. package/dist/utils/request-timing.js +101 -0
  243. package/dist/utils/sanitize-href.d.ts +8 -22
  244. package/dist/utils/sanitize-href.js +11 -28
  245. package/dist/utils/url-prefix.d.ts +30 -0
  246. package/dist/utils/url-prefix.js +61 -0
  247. package/dist/validation/entry-validator.js +9 -3
  248. package/dist/version.d.ts +1 -0
  249. package/dist/version.js +3 -0
  250. package/dist/worker/canopy-state.d.ts +45 -0
  251. package/dist/worker/canopy-state.js +75 -0
  252. package/dist/worker/cms-worker.d.ts +8 -3
  253. package/dist/worker/cms-worker.js +38 -2
  254. package/dist/worker/git-sync.d.ts +30 -12
  255. package/dist/worker/git-sync.js +253 -58
  256. package/dist/worker/history-rewrite.d.ts +1 -1
  257. package/dist/worker/history-rewrite.js +1 -1
  258. package/dist/worker/provisioned-workspace.d.ts +35 -0
  259. package/dist/worker/provisioned-workspace.js +50 -0
  260. package/dist/worker/rebase.d.ts +1 -1
  261. package/dist/worker/rebase.js +95 -23
  262. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  263. package/dist/worker/remote-git-maintenance.js +11 -0
  264. package/dist/worker/sparse-cone.d.ts +20 -0
  265. package/dist/worker/sparse-cone.js +132 -0
  266. package/dist/worker/task-runner.js +39 -4
  267. package/dist/worker/worker-context.d.ts +6 -1
  268. package/package.json +2 -1
@@ -4,17 +4,16 @@
4
4
  * NO imports (not even other files in this directory) so it can be imported
5
5
  * from client bundles (via `assetUrl`/`assetSrcSet` in asset-url.ts, exported
6
6
  * off the package's main entry) as well as from the server-only transform
7
- * engine (transform.ts) and the prod transform Lambda, without
7
+ * engine (transform.ts) and the lazy transform Lambda, without
8
8
  * ever pulling in node:crypto, sharp, or any other server-only dependency.
9
9
  *
10
10
  * `{directives}` is either the literal identity token (`orig`) or a
11
11
  * comma-separated list of `key=value` pairs drawn from:
12
- * w={int} output width - allowlisted to multiples of 160 in [160, 4096]
13
- * (bounds cache-stuffing; upscaling is rejected at transform
14
- * time via `withoutEnlargement`, not here)
12
+ * w={int} output width, accepted per `TransformWidthPolicy` (upscaling
13
+ * is rejected at transform time via `withoutEnlargement`, not here)
15
14
  * f={fmt} output format: webp | jpeg | png - when present, the URL's
16
15
  * `{ext}` must equal it exactly
17
- * q={int} quality 1..100 (encoder-dependent)
16
+ * q={int} quality, a multiple of 5 in [30, 95] under either policy
18
17
  * c={rect} normalized crop rect `x:y:w:h`, four floats in [0,1] with
19
18
  * x+w<=1 and y+h<=1, w>0 and h>0 (colon-separated - commas are
20
19
  * the directive separator)
@@ -24,9 +23,11 @@
24
23
  */
25
24
  /** The identity transform directive name - EXIF-strip only, no other change. */
26
25
  export const IDENTITY_TRANSFORM_DIRECTIVE = 'orig';
27
- const MIN_WIDTH = 160;
28
- const MAX_WIDTH = 4096;
29
- const WIDTH_STEP = 160;
26
+ const MIN_ALLOWLIST_WIDTH = 160;
27
+ const MAX_ALLOWLIST_WIDTH = 4096;
28
+ const ALLOWLIST_WIDTH_STEP = 160;
29
+ const SMALL_ALLOWLIST_WIDTHS = new Set([32, 48, 64, 96, 128]);
30
+ const MAX_ANY_WIDTH = 8192;
30
31
  /**
31
32
  * Decompression-bomb cap: the max `width * height` (post-decode pixel count)
32
33
  * any raster the pipeline handles may have - checked both at upload finalize
@@ -34,12 +35,14 @@ const WIDTH_STEP = 160;
34
35
  * time (transform.ts, via sharp's `limitInputPixels`). Shared here (rather
35
36
  * than duplicated in each server-only module) so it stays a single number,
36
37
  * and because this file is dependency-free/isomorphic it can be imported by
37
- * both without pulling sharp or file-type into anything. 4096x4096 (16.7 MP)
38
- * comfortably covers the largest output this system ever serves (MAX_WIDTH)
39
- * while still bounding decode-time memory for a small/compressible-but-huge
40
- * source (e.g. a 30000x30000 solid-color PNG).
38
+ * both without pulling sharp or file-type into anything. 24 MP (a 6000x4000
39
+ * camera frame) is sized by measured peak memory: the worst case, a full-size
40
+ * WebP encode of an RGBA WebP source, peaks at 1246 MiB here and grows about
41
+ * 37 MiB per MP, so this is the largest cap that fits the 2048 MB CMS and
42
+ * transform Lambdas with allocator headroom. For an animation the cap bounds
43
+ * all decoded frames together.
41
44
  */
42
- export const MAX_INPUT_PIXELS = 4096 * 4096;
45
+ export const MAX_INPUT_PIXELS = 6000 * 4000;
43
46
  /**
44
47
  * Cap on decoded animated frames (GIF/WebP) - without this, `{ animated: true }`
45
48
  * (sharp's `pages: -1`) decodes every frame of a maliciously-crafted
@@ -61,12 +64,17 @@ export const MAX_ANIMATED_FRAMES = 60;
61
64
  // Quality is allowlisted (multiples of 5 in [30, 95] - 14 values) for the
62
65
  // same cache-stuffing reason as width: every accepted directive combination
63
66
  // becomes a stored cache object in prod, so unbounded q would multiply the
64
- // per-asset variant space by 100. Crop remains the one effectively unbounded
65
- // dimension (editor rects need float precision) - prod mitigation (rate
66
- // limiting / signed crops) is tracked in the design record for the CDK PR.
67
+ // per-asset variant space by 100. Crop stays effectively unbounded (editor
68
+ // rects need float precision); the public path answers it by computing nothing
69
+ // (ARCHITECTURE.md, "Why transform by URL directive and materialize at release?").
67
70
  const MIN_QUALITY = 30;
68
71
  const MAX_QUALITY = 95;
69
72
  const QUALITY_STEP = 5;
73
+ /**
74
+ * Decimal places a crop value keeps in its canonical form. The editor rounds a
75
+ * new crop to this (editor/media/crop-math.ts), so a crop the editor stores is
76
+ * already canonical; a URL carrying more decimals canonicalizes to the rounded rect.
77
+ */
70
78
  const CROP_PRECISION = 4;
71
79
  const HASH32_RE = /^[a-f0-9]{32}$/;
72
80
  const SLUG_RE = /^[a-z0-9-]+$/;
@@ -85,15 +93,22 @@ const KNOWN_DIRECTIVE_KEYS = new Set(['w', 'f', 'q', 'c']);
85
93
  function isOutputFormat(value) {
86
94
  return value === 'webp' || value === 'jpeg' || value === 'png';
87
95
  }
88
- /** True if `width` is on the allowlist: a multiple of 160 in [160, 4096]. */
89
- export function isAllowedTransformWidth(width) {
90
- return (Number.isInteger(width) && width >= MIN_WIDTH && width <= MAX_WIDTH && width % WIDTH_STEP === 0);
96
+ /** True if `policy` accepts `width`; see `TransformWidthPolicy`. */
97
+ export function isAllowedTransformWidth(width, policy) {
98
+ if (!Number.isInteger(width))
99
+ return false;
100
+ if (policy === 'any')
101
+ return width >= 1 && width <= MAX_ANY_WIDTH;
102
+ return (SMALL_ALLOWLIST_WIDTHS.has(width) ||
103
+ (width >= MIN_ALLOWLIST_WIDTH &&
104
+ width <= MAX_ALLOWLIST_WIDTH &&
105
+ width % ALLOWLIST_WIDTH_STEP === 0));
91
106
  }
92
- function parseWidth(value) {
107
+ function parseWidth(value, policy) {
93
108
  if (!POSITIVE_INT_RE.test(value))
94
109
  return null;
95
110
  const n = Number(value);
96
- return isAllowedTransformWidth(n) ? n : null;
111
+ return isAllowedTransformWidth(n, policy) ? n : null;
97
112
  }
98
113
  /** True if `quality` is on the allowlist: a multiple of 5 in [30, 95]. */
99
114
  function isAllowedTransformQuality(quality) {
@@ -145,6 +160,27 @@ function parseCrop(value) {
145
160
  return null;
146
161
  return { x, y, w, h };
147
162
  }
163
+ function roundToCropPrecision(n) {
164
+ const factor = 10 ** CROP_PRECISION;
165
+ return Math.round(n * factor) / factor;
166
+ }
167
+ /**
168
+ * Round a crop rect to `CROP_PRECISION` decimals, shrinking `w`/`h` to the
169
+ * space left when rounding pushes `x+w` or `y+h` past 1 (0.66665 + 0.33335
170
+ * rounds to 0.6667 + 0.3334). The result can still be degenerate (a `w` or `h`
171
+ * that rounds to 0), which `isValidCropRect` rejects.
172
+ */
173
+ export function roundCropRect(rect) {
174
+ const x = roundToCropPrecision(rect.x);
175
+ const y = roundToCropPrecision(rect.y);
176
+ let w = roundToCropPrecision(rect.w);
177
+ let h = roundToCropPrecision(rect.h);
178
+ if (x + w > 1)
179
+ w = roundToCropPrecision(1 - x);
180
+ if (y + h > 1)
181
+ h = roundToCropPrecision(1 - y);
182
+ return { x, y, w, h };
183
+ }
148
184
  function formatUnitFloat(n) {
149
185
  return n.toFixed(CROP_PRECISION);
150
186
  }
@@ -156,7 +192,7 @@ function err(error) {
156
192
  * `assets/t/` and the hash32 segment) into a `TransformDirectives`, or
157
193
  * `orig` into the identity directive.
158
194
  */
159
- function parseDirectivesString(raw) {
195
+ function parseDirectivesString(raw, policy) {
160
196
  if (raw === IDENTITY_TRANSFORM_DIRECTIVE) {
161
197
  return { ok: true, directives: { identity: true } };
162
198
  }
@@ -187,7 +223,7 @@ function parseDirectivesString(raw) {
187
223
  seen.add(key);
188
224
  switch (key) {
189
225
  case 'w': {
190
- const w = parseWidth(value);
226
+ const w = parseWidth(value, policy);
191
227
  if (w === null)
192
228
  return err(`Invalid width: '${value}'`);
193
229
  width = w;
@@ -222,7 +258,7 @@ function parseDirectivesString(raw) {
222
258
  * `[directivesRaw, hash32, "{slug}.{ext}"]`. The raw route strips the
223
259
  * `assets/t/` prefix and splits the remaining key by `/` before calling this.
224
260
  */
225
- export function parseTransformPath(segments) {
261
+ export function parseTransformPath(segments, policy) {
226
262
  if (segments.length !== 3) {
227
263
  return err(`Expected 3 path segments (directives/hash32/slug.ext), got ${segments.length}`);
228
264
  }
@@ -242,7 +278,7 @@ export function parseTransformPath(segments) {
242
278
  if (!EXT_RE.test(ext)) {
243
279
  return err(`Invalid ext: '${ext}'`);
244
280
  }
245
- const parsedDirectives = parseDirectivesString(directivesRaw);
281
+ const parsedDirectives = parseDirectivesString(directivesRaw, policy);
246
282
  if (!parsedDirectives.ok) {
247
283
  return parsedDirectives;
248
284
  }
@@ -255,7 +291,8 @@ export function parseTransformPath(segments) {
255
291
  /**
256
292
  * Canonical string form of a directive set, so equivalent directive sets
257
293
  * (different key order, different float formatting) always map to the same
258
- * cache key. Order is fixed alphabetically by key: c, f, q, w.
294
+ * cache key. Order is fixed alphabetically by key: c, f, q, w, and crop is
295
+ * rounded by `roundCropRect`.
259
296
  */
260
297
  export function formatDirectives(directives) {
261
298
  if (directives.identity) {
@@ -263,7 +300,7 @@ export function formatDirectives(directives) {
263
300
  }
264
301
  const parts = [];
265
302
  if (directives.crop) {
266
- const { x, y, w, h } = directives.crop;
303
+ const { x, y, w, h } = roundCropRect(directives.crop);
267
304
  parts.push(`c=${formatUnitFloat(x)}:${formatUnitFloat(y)}:${formatUnitFloat(w)}:${formatUnitFloat(h)}`);
268
305
  }
269
306
  if (directives.format !== undefined) {
@@ -282,3 +319,26 @@ export function formatDirectives(directives) {
282
319
  // empty directives segment.
283
320
  return parts.length > 0 ? parts.join(',') : IDENTITY_TRANSFORM_DIRECTIVE;
284
321
  }
322
+ /**
323
+ * Parse transform-path segments and resolve them to the one spelling their
324
+ * output is stored under. The returned `directives` are parsed back from that
325
+ * canonical spelling, never taken from the request, so a transform computed
326
+ * from them always matches its key. A crop that rounds to zero extent has no
327
+ * canonical spelling and is rejected.
328
+ */
329
+ export function canonicalizeTransformPath(segments, policy) {
330
+ const parsed = parseTransformPath(segments, policy);
331
+ if (!parsed.ok)
332
+ return parsed;
333
+ const canonicalSegments = [
334
+ formatDirectives(parsed.directives),
335
+ parsed.hash32,
336
+ `${parsed.slug}.${parsed.ext}`,
337
+ ];
338
+ const canonical = parseTransformPath(canonicalSegments, policy);
339
+ if (!canonical.ok) {
340
+ return err(`No valid form at ${CROP_PRECISION} crop decimals: ${canonical.error}`);
341
+ }
342
+ const canonicalPath = canonicalSegments.join('/');
343
+ return { ...canonical, canonicalPath, isCanonical: canonicalPath === segments.join('/') };
344
+ }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * The shared transform engine: applies a parsed `TransformDirectives` to source image bytes
3
3
  * with sharp. Server-only - never import this from client/editor code (kept in its own file,
4
- * separate from the dependency-free transform-directives.ts). Used by the dev-mode lazy
5
- * `/assets/t/*` emulation in api/assets.ts and, unchanged, by the prod transform Lambda
6
- * (packages/canopycms-cdk/lambda/asset-transform).
4
+ * separate from the dependency-free transform-directives.ts). Its one caller here is
5
+ * `storeTransform` (materialize.ts), which the raw route, `materialize-assets` and the lazy
6
+ * transform Lambda share.
7
7
  *
8
8
  * sharp is imported for its TYPES only and loaded on first use through `loadSharp()`
9
9
  * (sharp-loader.ts), so importing this module never loads libvips - see sharp-loader.ts for
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * The shared transform engine: applies a parsed `TransformDirectives` to source image bytes
3
3
  * with sharp. Server-only - never import this from client/editor code (kept in its own file,
4
- * separate from the dependency-free transform-directives.ts). Used by the dev-mode lazy
5
- * `/assets/t/*` emulation in api/assets.ts and, unchanged, by the prod transform Lambda
6
- * (packages/canopycms-cdk/lambda/asset-transform).
4
+ * separate from the dependency-free transform-directives.ts). Its one caller here is
5
+ * `storeTransform` (materialize.ts), which the raw route, `materialize-assets` and the lazy
6
+ * transform Lambda share.
7
7
  *
8
8
  * sharp is imported for its TYPES only and loaded on first use through `loadSharp()`
9
9
  * (sharp-loader.ts), so importing this module never loads libvips - see sharp-loader.ts for
@@ -27,7 +27,12 @@ import { loadSharp } from './sharp-loader.js';
27
27
  import { MAX_ANIMATED_FRAMES, MAX_INPUT_PIXELS, } from './transform-directives.js';
28
28
  /** Raster formats the transform engine accepts as input. svg/pdf never reach here - they're served statically. */
29
29
  const ALLOWED_INPUT_EXTS = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif']);
30
- /** Defensive cap on encoded output size - prevents cache-stuffing with giant re-encodes. */
30
+ /**
31
+ * Cap on a resize's encoded output, against cache-stuffing with giant re-encodes. An identity
32
+ * (`orig`) is exempt: an asset has one, its input is bounded by the upload and pixel caps, and a
33
+ * noisy PNG over about 10 MiB re-encodes to at least its own size, so the cap would refuse an
34
+ * ordinary upload's `orig` and fail the release that references it.
35
+ */
31
36
  const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
32
37
  const CONTENT_TYPE_BY_FORMAT = {
33
38
  webp: 'image/webp',
@@ -154,9 +159,9 @@ export async function applyTransform(input, directives) {
154
159
  // `pages` value that EXCEEDS the source's actual page count makes sharp
155
160
  // throw ("bad page number") rather than clamping, so `MAX_ANIMATED_FRAMES`
156
161
  // can only be applied as `Math.min(totalPages, MAX_ANIMATED_FRAMES)`.
157
- // `limitInputPixels` gates this probe exactly like it gates the real
158
- // pipeline below - an oversized source throws here already, before any
159
- // pixel buffer is ever allocated.
162
+ // `limitInputPixels` on this probe counts one frame, so an oversized still
163
+ // throws here; the load below counts every frame it reads, so an over-cap
164
+ // animation throws there (measured at the cap: at most about one frame's buffer).
160
165
  const probeMeta = await sharp(input.data, { limitInputPixels: MAX_INPUT_PIXELS }).metadata();
161
166
  const totalPages = probeMeta.pages ?? 1;
162
167
  const pagesToRead = Math.min(totalPages, MAX_ANIMATED_FRAMES);
@@ -178,7 +183,7 @@ export async function applyTransform(input, directives) {
178
183
  ? encode(pipeline, format, quality)
179
184
  : encodeSourceFormat(pipeline, sourceExt, quality);
180
185
  const data = await pipeline.toBuffer();
181
- if (data.byteLength > MAX_OUTPUT_BYTES) {
186
+ if (!directives.identity && data.byteLength > MAX_OUTPUT_BYTES) {
182
187
  return {
183
188
  ok: false,
184
189
  status: 413,
@@ -55,7 +55,12 @@ export interface AssetStore {
55
55
  data: Uint8Array;
56
56
  contentType: string;
57
57
  }): Promise<void>;
58
- readOriginal(hash32: string): Promise<{
58
+ /**
59
+ * `ext` is where the original is expected (its meta's `ext`): that key is read first, and any
60
+ * `{hash32}.*` is looked for only on a miss. A hit needs no list permission; on S3 without
61
+ * `s3:ListBucket` a miss is a 403, which throws.
62
+ */
63
+ readOriginal(hash32: string, ext?: string): Promise<{
59
64
  data: Uint8Array;
60
65
  ext: string;
61
66
  contentType?: string;
@@ -66,8 +71,16 @@ export interface AssetStore {
66
71
  contentType: string;
67
72
  contentDisposition?: string;
68
73
  cacheControl?: string;
74
+ /** Object tags; bucket lifecycle rules can filter on them. */
75
+ tags?: Readonly<Record<string, string>>;
69
76
  }): Promise<void>;
70
77
  readPublicObject(key: string): Promise<PublicObject | null>;
78
+ /** Whether a public object exists at `key`, without reading its body. */
79
+ hasPublicObject(key: string): Promise<boolean>;
80
+ /** Every public object key under `prefix`, in the store's own pages. S3 only. */
81
+ listPublicObjectKeys?(prefix: string): AsyncIterable<string>;
82
+ /** A short-lived URL a browser can GET `key` from directly, or `null` if absent. S3 only. */
83
+ presignPublicObjectRead?(key: string): Promise<string | null>;
71
84
  putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<'created' | 'already-exists'>;
72
85
  getMeta(hash32: string): Promise<AssetMeta | null>;
73
86
  listMeta(input?: {
@@ -168,14 +168,13 @@ export async function writeAuthCacheSnapshot(cachePath, files) {
168
168
  await fs.rename(tmpPath, finalPath);
169
169
  }
170
170
  // The symlink target MUST be relative (the bare `snapshot-<ts>` basename),
171
- // because writer and reader do not share a mount namespace: in prod the EC2
172
- // worker writes through /mnt/efs/workspace/.cache while the CMS Lambda mounts
173
- // the /workspace access point and reads the SAME directory as /mnt/efs/.cache.
174
- // An absolute target recorded by one is a nonexistent path to the other, and
175
- // `resolveActiveCacheDir`'s escape guard then falls back to the flat layout,
176
- // where the worker never writes — leaving the Lambda a permanently empty
177
- // cache. A relative target resolves against whichever cachePath the reader
178
- // was given, so it is correct from both.
171
+ // because writer and reader are separate hosts (in prod, the EC2 worker and
172
+ // the CMS Lambda) that each mount EFS themselves, so nothing guarantees they
173
+ // reach this directory by the same path. An absolute target recorded by one
174
+ // can be a nonexistent path to the other, and `resolveActiveCacheDir`'s
175
+ // escape guard then falls back to the flat layout, where the worker never
176
+ // writes, leaving the reader a permanently empty cache. A relative target
177
+ // resolves against whichever cachePath the reader was given.
179
178
  const currentLink = path.join(cachePath, 'current');
180
179
  const tmpLink = path.join(cachePath, `current-${timestamp}`);
181
180
  await fs.symlink(path.basename(snapshotDir), tmpLink);
@@ -6,12 +6,13 @@ import type { BranchContext } from '../types.js';
6
6
  import type { PermissionLevel } from '../config/index.js';
7
7
  import type { CanopyUser } from '../user.js';
8
8
  import type { ContentAccessResult, ContentAccessDeps } from './types.js';
9
- import type { PhysicalPath } from '../paths/types.js';
9
+ import type { LogicalPath } from '../paths/types.js';
10
10
  /**
11
11
  * A bound content-access checker: evaluates a single path/level synchronously
12
12
  * against permissions that were already loaded when the checker was created.
13
+ * The path is the entry's logical path (see `checkPathAccess`).
13
14
  */
14
- export type ContentAccessChecker = (relativePath: PhysicalPath, level: PermissionLevel) => ContentAccessResult;
15
+ export type ContentAccessChecker = (logicalPath: LogicalPath, level: PermissionLevel) => ContentAccessResult;
15
16
  /**
16
17
  * Resolve the request-constant work once -- branch access, the
17
18
  * settings/permissions root and the path-permission rules, all identical for a
@@ -26,5 +27,5 @@ export declare function createContentAccessChecker(deps: ContentAccessDeps, cont
26
27
  * Single-path check; loads the path permissions from the branch root per call.
27
28
  * @internal Exported for tests.
28
29
  */
29
- export declare function checkContentAccess(deps: ContentAccessDeps, context: BranchContext, branchRoot: string, relativePath: PhysicalPath, user: CanopyUser, level: PermissionLevel): Promise<ContentAccessResult>;
30
- export declare function createCheckContentAccess(deps: ContentAccessDeps): (context: BranchContext, branchRoot: string, relativePath: PhysicalPath, user: CanopyUser, level: PermissionLevel) => Promise<ContentAccessResult>;
30
+ export declare function checkContentAccess(deps: ContentAccessDeps, context: BranchContext, branchRoot: string, logicalPath: LogicalPath, user: CanopyUser, level: PermissionLevel): Promise<ContentAccessResult>;
31
+ export declare function createCheckContentAccess(deps: ContentAccessDeps): (context: BranchContext, branchRoot: string, logicalPath: LogicalPath, user: CanopyUser, level: PermissionLevel) => Promise<ContentAccessResult>;
@@ -28,9 +28,9 @@ export async function createContentAccessChecker(deps, context, branchRoot, user
28
28
  }
29
29
  const rules = await deps.loadPathPermissions(permissionsRoot, deps.mode);
30
30
  const pathChecker = createCheckPathAccess(rules, deps.defaultPathAccess);
31
- return (relativePath, level) => {
31
+ return (logicalPath, level) => {
32
32
  const path = pathChecker({
33
- relativePath,
33
+ logicalPath,
34
34
  user,
35
35
  level,
36
36
  });
@@ -45,10 +45,10 @@ export async function createContentAccessChecker(deps, context, branchRoot, user
45
45
  * Single-path check; loads the path permissions from the branch root per call.
46
46
  * @internal Exported for tests.
47
47
  */
48
- export async function checkContentAccess(deps, context, branchRoot, relativePath, user, level) {
48
+ export async function checkContentAccess(deps, context, branchRoot, logicalPath, user, level) {
49
49
  const check = await createContentAccessChecker(deps, context, branchRoot, user);
50
- return check(relativePath, level);
50
+ return check(logicalPath, level);
51
51
  }
52
52
  export function createCheckContentAccess(deps) {
53
- return (context, branchRoot, relativePath, user, level) => checkContentAccess(deps, context, branchRoot, relativePath, user, level);
53
+ return (context, branchRoot, logicalPath, user, level) => checkContentAccess(deps, context, branchRoot, logicalPath, user, level);
54
54
  }
@@ -1,7 +1,7 @@
1
1
  import type { PathPermission, DefaultPathAccess, PermissionLevel } from '../config/index.js';
2
2
  import type { CanopyUser } from '../user.js';
3
3
  import type { PathPermissionResult } from './types.js';
4
- import type { PhysicalPath } from '../paths/types.js';
4
+ import type { LogicalPath } from '../paths/types.js';
5
5
  /**
6
6
  * Resolve `defaultPathAccess` to an 'allow'/'deny' verdict for one permission
7
7
  * level. String form applies to every level; object form looks up the level,
@@ -11,19 +11,24 @@ import type { PhysicalPath } from '../paths/types.js';
11
11
  */
12
12
  export declare function resolveDefaultPathAccess(defaultAccess: DefaultPathAccess, level: PermissionLevel): 'allow' | 'deny';
13
13
  /**
14
- * Evaluate access for a relative path against config-defined rules.
15
- * Uses defaultAccess when no rule matches. First matching rule wins.
14
+ * Evaluate access for a path against config-defined rules. First matching rule wins;
15
+ * defaultAccess applies when none matches.
16
+ *
17
+ * Rule globs and the checked path are both LOGICAL paths: content-root-prefixed and
18
+ * id-free, the space the Permission Manager writes rules in. An entry's logical path is
19
+ * `<collection logical path>/<slug>` (`entryLogicalPath`), e.g. `content/blog/my-post`, never
20
+ * its on-disk `content/blog.<id>/post.my-post.<id>.json`.
16
21
  * @internal Exported for tests.
17
22
  */
18
- export declare function checkPathAccess({ rules, relativePath, user, defaultAccess, level, }: {
23
+ export declare function checkPathAccess({ rules, logicalPath, user, defaultAccess, level, }: {
19
24
  rules: PathPermission[];
20
- relativePath: PhysicalPath;
25
+ logicalPath: LogicalPath;
21
26
  user: CanopyUser;
22
27
  defaultAccess: DefaultPathAccess;
23
28
  level: PermissionLevel;
24
29
  }): PathPermissionResult;
25
30
  export declare function createCheckPathAccess(rules: PathPermission[], defaultAccess: DefaultPathAccess): (input: {
26
- relativePath: PhysicalPath;
31
+ logicalPath: LogicalPath;
27
32
  user: CanopyUser;
28
33
  level: PermissionLevel;
29
34
  }) => PathPermissionResult;
@@ -5,8 +5,8 @@ function normalize(p) {
5
5
  const normalized = p.split(path.sep).join('/');
6
6
  return normalized.replace(/^\.?\/*/, '');
7
7
  }
8
- function matchesRule(rule, relativePath) {
9
- return minimatch(relativePath, rule.path, { dot: true });
8
+ function matchesRule(rule, logicalPath) {
9
+ return minimatch(logicalPath, rule.path, { dot: true });
10
10
  }
11
11
  function isAllowedByTarget(target, user) {
12
12
  const hasUserConstraint = !!target.allowedUsers?.length;
@@ -32,12 +32,17 @@ export function resolveDefaultPathAccess(defaultAccess, level) {
32
32
  return defaultAccess[level] ?? 'deny';
33
33
  }
34
34
  /**
35
- * Evaluate access for a relative path against config-defined rules.
36
- * Uses defaultAccess when no rule matches. First matching rule wins.
35
+ * Evaluate access for a path against config-defined rules. First matching rule wins;
36
+ * defaultAccess applies when none matches.
37
+ *
38
+ * Rule globs and the checked path are both LOGICAL paths: content-root-prefixed and
39
+ * id-free, the space the Permission Manager writes rules in. An entry's logical path is
40
+ * `<collection logical path>/<slug>` (`entryLogicalPath`), e.g. `content/blog/my-post`, never
41
+ * its on-disk `content/blog.<id>/post.my-post.<id>.json`.
37
42
  * @internal Exported for tests.
38
43
  */
39
- export function checkPathAccess({ rules, relativePath, user, defaultAccess, level, }) {
40
- const normalizedPath = normalize(relativePath);
44
+ export function checkPathAccess({ rules, logicalPath, user, defaultAccess, level, }) {
45
+ const normalizedPath = normalize(logicalPath);
41
46
  // Only Admins bypass all path permissions
42
47
  if (isAdmin(user.groups)) {
43
48
  return { allowed: true, reason: 'admin' };
@@ -1,6 +1,6 @@
1
1
  /**
2
- * A path used in permission rules, e.g. "content/posts".
3
- * SECURITY CRITICAL: always validated to prevent path traversal.
2
+ * A logical-path glob used in permission rules, e.g. "content/posts/**" (see
3
+ * `PathPermission.path`). SECURITY CRITICAL: always validated to prevent path traversal.
4
4
  */
5
5
  export type PermissionPath = string & {
6
6
  readonly __brand: 'PermissionPath';
@@ -6,13 +6,19 @@ import { type DuplicateContentId } from './content-id-index.js';
6
6
  * Without it two stuck states have no in-product recovery, and prod admins have
7
7
  * no filesystem access to see them: a branch dir whose `.canopy-meta/branch.json`
8
8
  * is corrupt (the registry drops it silently) and an orphan dir with no
9
- * `branch.json` at all (a partial delete crash — see `api/branch.ts`).
9
+ * `branch.json` at all (a clone or delete killed in place, until the worker
10
+ * quarantines it — see branch-provisioning.ts).
10
11
  *
11
12
  * `scanBranchHealth` mirrors the registry's own directory-listing rules (skip
12
13
  * non-directories and dot-prefixed names) so the two never disagree about what
13
14
  * counts as missing.
14
15
  */
15
16
  type BranchHealthKind = 'healthy' | 'corrupt-metadata' | 'orphan';
17
+ /**
18
+ * An orphan younger than this may still be a clone in progress: admin purge refuses it and the
19
+ * worker does not quarantine it. Corrupt-metadata dirs are exempt.
20
+ */
21
+ export declare const ORPHAN_YOUTH_THRESHOLD_MS: number;
16
22
  export interface BranchHealthEntry {
17
23
  dirName: string;
18
24
  kind: BranchHealthKind;
@@ -5,16 +5,21 @@ import { ContentIdIndex } from './content-id-index.js';
5
5
  import { sanitizeBranchName } from './paths/branch-name.js';
6
6
  import { getErrorMessage, isNodeError, isNotFoundError } from './utils/error.js';
7
7
  import { isRebaseInProgress } from './utils/git.js';
8
+ import { branchProvisioningLockName } from './utils/provisioning-lock.js';
8
9
  /**
9
- * The provisioning lock marker's path for a branch directory, matching what
10
- * `branch-workspace.ts`'s `ensureGitWorkspace()` passes exactly.
10
+ * An orphan younger than this may still be a clone in progress: admin purge refuses it and the
11
+ * worker does not quarantine it. Corrupt-metadata dirs are exempt.
12
+ */
13
+ export const ORPHAN_YOUTH_THRESHOLD_MS = 15 * 60_000;
14
+ /**
15
+ * The provisioning lock marker's path for a branch directory.
11
16
  *
12
17
  * `acquireProvisioningLock` passes the name as `lockfilePath`, overriding
13
18
  * proper-lockfile's default `${target}.lock`, so the marker (a mkdir-based
14
19
  * directory) sits at exactly this path — no extra `.lock` suffix.
15
20
  */
16
21
  function provisioningLockPath(baseRoot, dirName) {
17
- return path.join(baseRoot, `.${dirName}.init.lock`);
22
+ return path.join(baseRoot, branchProvisioningLockName(dirName));
18
23
  }
19
24
  /** Stat the provisioning lock marker, if present. Never throws. */
20
25
  async function readProvisioningLock(baseRoot, dirName) {
@@ -63,16 +63,16 @@ export declare class BranchMetadataFileManager {
63
63
  * entry with no clone behind it. It runs BEFORE the lock stack so a doomed
64
64
  * save fails fast instead of paying for a lock.
65
65
  *
66
- * Accepted residual window: a save that passes the check can still race a
67
- * `rm` that starts moments later and is mid-flight when the write lands.
68
- * Closing that needs a tombstone OUTSIDE the tree being removed, and the
69
- * lockfile taken next lives INSIDE `branchRoot`, so it can promise no more
70
- * than "the directory existed a moment ago".
66
+ * A save that passes the check just before a delete still cannot write: the
67
+ * delete renames the tree away under the same lockfile, and that lockfile
68
+ * creates only its own directory (`withOccFileLock`), so the save fails there.
71
69
  */
72
70
  save(incoming: BranchMetadataUpdate): Promise<BranchMetadataFile>;
73
71
  /** Invalidate the registry cache so the next list() regenerates. */
74
72
  private invalidateRegistry;
75
73
  }
74
+ /** The branch.json a brand-new branch starts with: {@link mergeBranchMetadata} over nothing. */
75
+ export declare function buildInitialBranchMetadata(incoming: BranchMetadataUpdate, now?: string): BranchMetadataFile;
76
76
  /**
77
77
  * Fields save() accepts. `createdBy` applies on creation only and is ignored
78
78
  * once metadata exists; createdAt/updatedAt are managed here, not by callers.
@@ -101,11 +101,9 @@ export class BranchMetadataFileManager {
101
101
  * entry with no clone behind it. It runs BEFORE the lock stack so a doomed
102
102
  * save fails fast instead of paying for a lock.
103
103
  *
104
- * Accepted residual window: a save that passes the check can still race a
105
- * `rm` that starts moments later and is mid-flight when the write lands.
106
- * Closing that needs a tombstone OUTSIDE the tree being removed, and the
107
- * lockfile taken next lives INSIDE `branchRoot`, so it can promise no more
108
- * than "the directory existed a moment ago".
104
+ * A save that passes the check just before a delete still cannot write: the
105
+ * delete renames the tree away under the same lockfile, and that lockfile
106
+ * creates only its own directory (`withOccFileLock`), so the save fails there.
109
107
  */
110
108
  async save(incoming) {
111
109
  try {
@@ -121,37 +119,7 @@ export class BranchMetadataFileManager {
121
119
  try {
122
120
  saved = await withLock(this.filePath, () => withOccFileLock(this.filePath, () => withOccRetry(async () => {
123
121
  const { meta: existing, version } = await this.load();
124
- const now = new Date().toISOString();
125
- const defaults = {
126
- name: 'unknown',
127
- status: 'editing',
128
- access: {},
129
- createdBy: 'unknown',
130
- createdAt: now,
131
- updatedAt: now,
132
- };
133
- const merged = {
134
- schemaVersion: CURRENT_SCHEMA_VERSION,
135
- version: version ?? 0,
136
- branch: {
137
- ...defaults,
138
- ...existing?.branch,
139
- ...incoming.branch,
140
- access: {
141
- ...existing?.branch?.access,
142
- ...incoming.branch?.access,
143
- },
144
- // Immutable after creation
145
- createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
146
- createdAt: existing?.branch.createdAt ?? defaults.createdAt,
147
- // Fork point is recorded once at creation; later saves must not move it
148
- baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch,
149
- // Always stamped fresh; the spreads above would otherwise let
150
- // the creation-time value win forever, freezing the timestamp
151
- // the editor's Branches panel sorts and displays by
152
- updatedAt: now,
153
- },
154
- };
122
+ const merged = mergeBranchMetadata(existing, version, incoming);
155
123
  const written = await this.write(merged, version);
156
124
  merged.version = written.version;
157
125
  merged.writeId = written.writeId;
@@ -178,6 +146,46 @@ export class BranchMetadataFileManager {
178
146
  await registry.invalidate();
179
147
  }
180
148
  }
149
+ /**
150
+ * The branch.json that {@link BranchMetadataFileManager.save} writes for `incoming` over
151
+ * `existing`. Pure, so a staged workspace's first branch.json (branch-provisioning.ts) is built by
152
+ * the same rules as a save and the two cannot drift.
153
+ */
154
+ function mergeBranchMetadata(existing, version, incoming, now = new Date().toISOString()) {
155
+ const defaults = {
156
+ name: 'unknown',
157
+ status: 'editing',
158
+ access: {},
159
+ createdBy: 'unknown',
160
+ createdAt: now,
161
+ updatedAt: now,
162
+ };
163
+ return {
164
+ schemaVersion: CURRENT_SCHEMA_VERSION,
165
+ version: version ?? 0,
166
+ branch: {
167
+ ...defaults,
168
+ ...existing?.branch,
169
+ ...incoming.branch,
170
+ access: {
171
+ ...existing?.branch?.access,
172
+ ...incoming.branch?.access,
173
+ },
174
+ // Immutable after creation
175
+ createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
176
+ createdAt: existing?.branch.createdAt ?? defaults.createdAt,
177
+ // Fork point is recorded once at creation; later saves must not move it
178
+ baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch,
179
+ // Always stamped fresh; the spreads above would otherwise let the creation-time value win
180
+ // forever, freezing the timestamp the editor's Branches panel sorts and displays by
181
+ updatedAt: now,
182
+ },
183
+ };
184
+ }
185
+ /** The branch.json a brand-new branch starts with: {@link mergeBranchMetadata} over nothing. */
186
+ export function buildInitialBranchMetadata(incoming, now) {
187
+ return mergeBranchMetadata(null, null, incoming, now);
188
+ }
181
189
  /**
182
190
  * The metadata update for archiving a branch whose PR merged. Shared by the
183
191
  * worker's merge-poll (CmsWorker.pollMergeState) and the manual markAsMerged