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
@@ -1,16 +1,19 @@
1
1
  /**
2
2
  * S3-backed AssetStore. Same bucket-prefix layout as LocalAssetStore (see
3
3
  * keys.ts). Assumes an EXISTING content bucket (versioning/SSE/replication
4
- * already configured by the site's CDK stack) — this store only ever reads
5
- * and writes objects under the five asset prefixes.
4
+ * already configured by the site's CDK stack). It reads and writes under the
5
+ * five asset prefixes, plus a caller's output prefix for `copyPublicObject`.
6
6
  */
7
7
  import { randomUUID } from 'node:crypto';
8
- import { DeleteObjectCommand, GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3';
8
+ import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, paginateListObjectsV2, } from '@aws-sdk/client-s3';
9
9
  import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
10
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
10
11
  import { isHttpUrlOrSameOriginPath } from '../utils/sanitize-href.js';
11
12
  import { ASSET_PREFIXES, createKeyBuilders } from './keys.js';
12
13
  const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
13
14
  const PRESIGN_EXPIRY_SECONDS = 15 * 60;
15
+ /** A presigned read is followed by the browser at once; it is never stored or cached. */
16
+ const PRESIGNED_READ_EXPIRY_SECONDS = 5 * 60;
14
17
  function matchesAwsError(err, name, httpStatusCode) {
15
18
  if (!(err instanceof Error))
16
19
  return false;
@@ -18,7 +21,20 @@ function matchesAwsError(err, name, httpStatusCode) {
18
21
  return shaped.name === name || shaped.$metadata?.httpStatusCode === httpStatusCode;
19
22
  }
20
23
  const isPreconditionFailed = (err) => matchesAwsError(err, 'PreconditionFailed', 412);
21
- const isNoSuchKey = (err) => matchesAwsError(err, 'NoSuchKey', 404);
24
+ /**
25
+ * S3's 409 for a conditional write racing another write to the same key that is still in flight.
26
+ * Matched by name only: a 409 is also `OperationAborted`, which is not this.
27
+ */
28
+ const isConditionalRequestConflict = (err) => err instanceof Error && err.name === 'ConditionalRequestConflict';
29
+ /** At most 4 attempts and under 1.75 s of waiting; a race still unresolved after that throws the 409. */
30
+ const CONFLICT_ATTEMPTS = 4;
31
+ const CONFLICT_BASE_DELAY_MS = 250;
32
+ /**
33
+ * A missing key, never a missing bucket: S3 answers both with 404, and only a GET's error code names
34
+ * which. A HEAD's 404 has no body, so `hasPublicObject` reads either as absent; a caller that must
35
+ * tell them apart follows with a GET (assets/materialize.ts does).
36
+ */
37
+ const isNoSuchKey = (err) => matchesAwsError(err, 'NoSuchKey', 404) && err.name !== 'NoSuchBucket';
22
38
  export class S3AssetStore {
23
39
  constructor(options) {
24
40
  this.capabilities = { directUpload: true };
@@ -35,6 +51,38 @@ export class S3AssetStore {
35
51
  const prefixes = options.prefixes ?? ASSET_PREFIXES;
36
52
  this.keys = createKeyBuilders(prefixes);
37
53
  this.stagingPrefix = `${prefixes.staging}/`;
54
+ this.sleep =
55
+ options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
56
+ }
57
+ /**
58
+ * Sends one create-only request (`IfNoneMatch: '*'`), so S3 answers a taken key with 412. A
59
+ * `ConditionalRequestConflict` is retried here, the only retry layer for it, so a caller with no
60
+ * retry of its own (the lazy transform Lambda, finalize) ends in one of the two results.
61
+ *
62
+ * With `retriedIsCreated`, a 412 after any error the SDK had already retried counts as
63
+ * `created`: one of those attempts may have committed and lost its response. Public objects use
64
+ * it; meta does not, since a meta `already-exists` sends finalize to read the stored winner.
65
+ */
66
+ async createIfAbsent(send, options = {}) {
67
+ let sdkRetried = false;
68
+ for (let attempt = 1;; attempt++) {
69
+ try {
70
+ await send();
71
+ return 'created';
72
+ }
73
+ catch (err) {
74
+ sdkRetried ||= (err.$metadata?.attempts ?? 1) > 1;
75
+ if (isPreconditionFailed(err)) {
76
+ return options.retriedIsCreated && sdkRetried ? 'created' : 'already-exists';
77
+ }
78
+ if (!isConditionalRequestConflict(err) || attempt >= CONFLICT_ATTEMPTS)
79
+ throw err;
80
+ await this.sleep(CONFLICT_BASE_DELAY_MS * 2 ** (attempt - 1) * (0.5 + Math.random() / 2));
81
+ }
82
+ }
83
+ }
84
+ putIfAbsent(input, options = {}) {
85
+ return this.createIfAbsent(() => this.client.send(new PutObjectCommand({ ...input, Bucket: this.bucket, IfNoneMatch: '*' })), options);
38
86
  }
39
87
  /**
40
88
  * Staging methods accept caller-influenced keys (finalize receives the key
@@ -90,14 +138,19 @@ export class S3AssetStore {
90
138
  await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
91
139
  }
92
140
  async putOriginal(input) {
93
- await this.client.send(new PutObjectCommand({
94
- Bucket: this.bucket,
141
+ return this.putIfAbsent({
95
142
  Key: this.keys.originalKey(input.hash32, input.ext),
96
143
  Body: input.data,
97
144
  ContentType: input.contentType,
98
- }));
145
+ });
99
146
  }
100
- async readOriginal(hash32) {
147
+ async readOriginal(hash32, ext) {
148
+ if (ext !== undefined) {
149
+ const direct = await this.getObject(this.keys.originalKey(hash32, ext));
150
+ const data = await direct?.Body?.transformToByteArray();
151
+ if (data)
152
+ return { data, ext, contentType: direct?.ContentType };
153
+ }
101
154
  const prefix = this.keys.originalPrefix(hash32);
102
155
  const listed = await this.client.send(new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix, MaxKeys: 1 }));
103
156
  const foundKey = listed.Contents?.[0]?.Key;
@@ -112,14 +165,45 @@ export class S3AssetStore {
112
165
  return { data, ext: foundKey.slice(prefix.length), contentType: result.ContentType };
113
166
  }
114
167
  async putPublicObject(input) {
115
- await this.client.send(new PutObjectCommand({
116
- Bucket: this.bucket,
168
+ // `encodeURIComponent` writes a space as `%20`, which reads the same to any decoder;
169
+ // URLSearchParams writes `+`.
170
+ const tagging = Object.entries(input.tags ?? {})
171
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
172
+ .join('&');
173
+ return this.putIfAbsent({
117
174
  Key: input.key,
118
175
  Body: input.data,
119
176
  ContentType: input.contentType,
120
177
  ContentDisposition: input.contentDisposition,
121
178
  CacheControl: input.cacheControl,
122
- }));
179
+ Tagging: tagging || undefined,
180
+ }, { retriedIsCreated: true });
181
+ }
182
+ /**
183
+ * A server-side CopyObject. S3 URL-decodes `CopySource`, and keys hold `=`, `,` and `:`, so each
184
+ * segment is encoded and the `/` between them is not. `MetadataDirective: 'COPY'` keeps the
185
+ * source's Content-Type, Cache-Control and Content-Disposition; `TaggingDirective: 'REPLACE'`
186
+ * with no `Tagging` leaves the copy untagged and needs no tagging permission, even from a
187
+ * lazy-tagged source; `COPY` would need `s3:GetObjectTagging` and `s3:PutObjectTagging`.
188
+ */
189
+ async copyPublicObject(sourceKey, destKey) {
190
+ const copySource = `${this.bucket}/${sourceKey.split('/').map(encodeURIComponent).join('/')}`;
191
+ try {
192
+ return await this.createIfAbsent(() => this.client.send(new CopyObjectCommand({
193
+ Bucket: this.bucket,
194
+ Key: destKey,
195
+ CopySource: copySource,
196
+ IfNoneMatch: '*',
197
+ MetadataDirective: 'COPY',
198
+ TaggingDirective: 'REPLACE',
199
+ })), { retriedIsCreated: true });
200
+ }
201
+ catch (err) {
202
+ // By name alone: a copy's error always carries its code, and a missing bucket is a 404 too.
203
+ if (err instanceof Error && err.name === 'NoSuchKey')
204
+ return 'source-missing';
205
+ throw err;
206
+ }
123
207
  }
124
208
  async readPublicObject(key) {
125
209
  const result = await this.getObject(key);
@@ -135,23 +219,45 @@ export class S3AssetStore {
135
219
  cacheControl: result.CacheControl,
136
220
  };
137
221
  }
138
- async putMetaIfAbsent(hash32, meta) {
222
+ /**
223
+ * A HEAD. S3 answers a missing key with 403, not 404, unless the caller may `s3:ListBucket`,
224
+ * so a role without it sees an error here rather than `false`.
225
+ */
226
+ async hasPublicObject(key) {
139
227
  try {
140
- await this.client.send(new PutObjectCommand({
141
- Bucket: this.bucket,
142
- Key: this.keys.metaKey(hash32),
143
- Body: JSON.stringify(meta),
144
- ContentType: 'application/json',
145
- IfNoneMatch: '*',
146
- }));
147
- return 'created';
228
+ await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
229
+ return true;
148
230
  }
149
231
  catch (err) {
150
- if (isPreconditionFailed(err))
151
- return 'already-exists';
232
+ if (isNoSuchKey(err))
233
+ return false;
152
234
  throw err;
153
235
  }
154
236
  }
237
+ async *listPublicObjectKeys(prefix) {
238
+ const pages = paginateListObjectsV2({ client: this.client }, { Bucket: this.bucket, Prefix: prefix });
239
+ for await (const page of pages) {
240
+ for (const object of page.Contents ?? []) {
241
+ if (object.Key)
242
+ yield object.Key;
243
+ }
244
+ }
245
+ }
246
+ /** HEADs first: signing is local and succeeds for a missing key too. */
247
+ async presignPublicObjectRead(key) {
248
+ if (!(await this.hasPublicObject(key)))
249
+ return null;
250
+ return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), {
251
+ expiresIn: PRESIGNED_READ_EXPIRY_SECONDS,
252
+ });
253
+ }
254
+ async putMetaIfAbsent(hash32, meta) {
255
+ return this.putIfAbsent({
256
+ Key: this.keys.metaKey(hash32),
257
+ Body: JSON.stringify(meta),
258
+ ContentType: 'application/json',
259
+ });
260
+ }
155
261
  async getMeta(hash32) {
156
262
  const bytes = await this.getObjectBytes(this.keys.metaKey(hash32));
157
263
  if (!bytes)
@@ -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)
@@ -56,6 +55,18 @@ export type ParseTransformPathResult = ({
56
55
  readonly ok: false;
57
56
  readonly error: string;
58
57
  };
58
+ /**
59
+ * Which widths a transform path accepts. The policy only narrows what parses; a width that parses
60
+ * under both has the same canonical spelling under both.
61
+ *
62
+ * - `allowlist`: multiples of 160 in [160, 4096], plus the small rungs 32, 48, 64, 96 and 128. For
63
+ * the opt-in anonymous on-demand path, where every accepted width is a new stored object an
64
+ * anonymous caller can mint.
65
+ * - `any`: any integer in [1, 8192]. For every path where the URLs come from site code or a
66
+ * signed-in editor: the authenticated route, `assetUrl`/`assetSrcSet`, the collector and the
67
+ * materializer.
68
+ */
69
+ export type TransformWidthPolicy = 'allowlist' | 'any';
59
70
  /**
60
71
  * Decompression-bomb cap: the max `width * height` (post-decode pixel count)
61
72
  * any raster the pipeline handles may have - checked both at upload finalize
@@ -63,10 +74,12 @@ export type ParseTransformPathResult = ({
63
74
  * time (transform.ts, via sharp's `limitInputPixels`). Shared here (rather
64
75
  * than duplicated in each server-only module) so it stays a single number,
65
76
  * and because this file is dependency-free/isomorphic it can be imported by
66
- * both without pulling sharp or file-type into anything. 4096x4096 (16.7 MP)
67
- * comfortably covers the largest output this system ever serves (MAX_WIDTH)
68
- * while still bounding decode-time memory for a small/compressible-but-huge
69
- * source (e.g. a 30000x30000 solid-color PNG).
77
+ * both without pulling sharp or file-type into anything. 24 MP (a 6000x4000
78
+ * camera frame) is sized by measured peak memory: the worst case, a full-size
79
+ * WebP encode of an RGBA WebP source, peaks at 1246 MiB here and grows about
80
+ * 37 MiB per MP, so this is the largest cap that fits the 2048 MB CMS and
81
+ * transform Lambdas with allocator headroom. For an animation the cap bounds
82
+ * all decoded frames together.
70
83
  */
71
84
  export declare const MAX_INPUT_PIXELS: number;
72
85
  /**
@@ -87,8 +100,8 @@ export declare const MAX_INPUT_PIXELS: number;
87
100
  * ignore) rather than by two constants that happen to match today.
88
101
  */
89
102
  export declare const MAX_ANIMATED_FRAMES = 60;
90
- /** True if `width` is on the allowlist: a multiple of 160 in [160, 4096]. */
91
- export declare function isAllowedTransformWidth(width: number): boolean;
103
+ /** True if `policy` accepts `width`; see `TransformWidthPolicy`. */
104
+ export declare function isAllowedTransformWidth(width: number, policy: TransformWidthPolicy): boolean;
92
105
  /**
93
106
  * True when x, y, w, h describe a valid normalized crop rect: all four values
94
107
  * finite and in [0,1], w and h strictly positive, and the rect fits within
@@ -98,16 +111,42 @@ export declare function isAllowedTransformWidth(width: number): boolean;
98
111
  * identical constraints.
99
112
  */
100
113
  export declare function isValidCropRect(x: number, y: number, w: number, h: number): boolean;
114
+ /**
115
+ * Round a crop rect to `CROP_PRECISION` decimals, shrinking `w`/`h` to the
116
+ * space left when rounding pushes `x+w` or `y+h` past 1 (0.66665 + 0.33335
117
+ * rounds to 0.6667 + 0.3334). The result can still be degenerate (a `w` or `h`
118
+ * that rounds to 0), which `isValidCropRect` rejects.
119
+ */
120
+ export declare function roundCropRect(rect: CropRect): CropRect;
101
121
  /**
102
122
  * Parse the three path segments after the `assets/t/` prefix:
103
123
  * `[directivesRaw, hash32, "{slug}.{ext}"]`. The raw route strips the
104
124
  * `assets/t/` prefix and splits the remaining key by `/` before calling this.
105
125
  */
106
- export declare function parseTransformPath(segments: readonly string[]): ParseTransformPathResult;
126
+ export declare function parseTransformPath(segments: readonly string[], policy: TransformWidthPolicy): ParseTransformPathResult;
107
127
  /**
108
128
  * Canonical string form of a directive set, so equivalent directive sets
109
129
  * (different key order, different float formatting) always map to the same
110
- * cache key. Order is fixed alphabetically by key: c, f, q, w.
130
+ * cache key. Order is fixed alphabetically by key: c, f, q, w, and crop is
131
+ * rounded by `roundCropRect`.
111
132
  */
112
133
  export declare function formatDirectives(directives: TransformDirectives): string;
134
+ export type CanonicalTransformPathResult = ({
135
+ readonly ok: true;
136
+ /** `{directives}/{hash32}/{slug}.{ext}` in canonical form, without the `assets/t/` prefix. */
137
+ readonly canonicalPath: string;
138
+ /** True when the requested segments already were `canonicalPath`, byte for byte. */
139
+ readonly isCanonical: boolean;
140
+ } & ParsedTransformPath) | {
141
+ readonly ok: false;
142
+ readonly error: string;
143
+ };
144
+ /**
145
+ * Parse transform-path segments and resolve them to the one spelling their
146
+ * output is stored under. The returned `directives` are parsed back from that
147
+ * canonical spelling, never taken from the request, so a transform computed
148
+ * from them always matches its key. A crop that rounds to zero extent has no
149
+ * canonical spelling and is rejected.
150
+ */
151
+ export declare function canonicalizeTransformPath(segments: readonly string[], policy: TransformWidthPolicy): CanonicalTransformPathResult;
113
152
  export {};
@@ -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,