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
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Writing transform outputs into the store. Server-only.
3
+ *
4
+ * `storeTransform` computes one key and is shared by the authenticated raw route
5
+ * (api/assets.ts), `materializeAssets` (the batch run that writes every key a build references
6
+ * before that build is released) and canopycms-cdk's lazy transform Lambda.
7
+ */
8
+ import { getErrorMessage, isNodeError } from '../utils/error.js';
9
+ import { ASSET_PREFIXES } from './asset-prefixes.js';
10
+ import { loadSharp } from './sharp-loader.js';
11
+ import { applyTransform } from './transform.js';
12
+ import { canonicalizeTransformPath } from './transform-directives.js';
13
+ /** Every transform output is stored under a content-addressed key, so it never changes. */
14
+ export const TRANSFORM_CACHE_CONTROL = 'public, max-age=31536000, immutable';
15
+ /**
16
+ * The object tag on every derivative the lazy transform Lambda writes. Its bucket's `assets/t/`
17
+ * expiry filters on it, so what `materializeAssets` writes, which the Lambda's allowlist may refuse
18
+ * to recompute, is never expired. canopycms-cdk's `AssetSupport` copies these two strings as
19
+ * literals.
20
+ */
21
+ export const LAZY_TRANSFORM_TAG = { key: 'canopy-transform', value: 'lazy' };
22
+ /**
23
+ * Compute the transform `parsed` names from the asset's original and write it under
24
+ * `canonicalKey`. `parsed` must come from `canonicalizeTransformPath`, so the stored pixels
25
+ * always match their key. A rejection is a fact about the asset or the URL and is never worth
26
+ * retrying; a thrown error belongs to the store or the environment. A 404's `error` names what
27
+ * was missing, so a caller answering a browser replaces it.
28
+ */
29
+ export async function storeTransform(store, parsed, canonicalKey, options = {}) {
30
+ const meta = await store.getMeta(parsed.hash32);
31
+ if (!meta) {
32
+ return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no meta in the store` };
33
+ }
34
+ if (meta.kind !== 'raster') {
35
+ return { ok: false, status: 400, error: 'Not a raster asset - svg/pdf are served statically' };
36
+ }
37
+ // The slug is decorative in the URL but load-bearing in the stored key, so it must equal the
38
+ // asset's real slug — the parser only enforces `[a-z0-9-]+`, and any other string that passes
39
+ // it aliases the same image into a new cache key.
40
+ if (parsed.slug !== meta.slug) {
41
+ return {
42
+ ok: false,
43
+ status: 404,
44
+ error: `Slug '${parsed.slug}' is not asset ${parsed.hash32}'s slug '${meta.slug}'`,
45
+ };
46
+ }
47
+ // Without an explicit `f=`, the transform keeps the source format, so the URL's `{ext}` must be
48
+ // the source's real ext. The parser cannot check this: it does not know the source format.
49
+ const requestedFormat = parsed.directives.identity ? undefined : parsed.directives.format;
50
+ if (requestedFormat === undefined && parsed.ext !== meta.ext) {
51
+ return { ok: false, status: 400, error: 'Extension does not match the source format' };
52
+ }
53
+ const original = await store.readOriginal(parsed.hash32, meta.ext);
54
+ if (!original) {
55
+ return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no original in the store` };
56
+ }
57
+ const transformed = await applyTransform({ data: original.data, ext: original.ext }, parsed.directives);
58
+ if (!transformed.ok) {
59
+ return { ok: false, status: transformed.status, error: transformed.error };
60
+ }
61
+ await store.putPublicObject({
62
+ key: canonicalKey,
63
+ data: transformed.data,
64
+ contentType: transformed.contentType,
65
+ cacheControl: TRANSFORM_CACHE_CONTROL,
66
+ tags: options.tags,
67
+ });
68
+ return { ok: true, data: transformed.data, contentType: transformed.contentType };
69
+ }
70
+ /** sharp cannot load in this process, so nothing missing can be produced here. */
71
+ export class SharpUnavailableError extends Error {
72
+ constructor(cause) {
73
+ super(`sharp failed to load, so the missing transform outputs cannot be produced in this ` +
74
+ `environment: ${getErrorMessage(cause)}. Install sharp for this platform and rerun.`);
75
+ this.name = 'SharpUnavailableError';
76
+ }
77
+ }
78
+ const DEFAULT_CONCURRENCY = 8;
79
+ const DEFAULT_TRANSFORM_CONCURRENCY = 2;
80
+ // eslint-disable-next-line security/detect-non-literal-regexp -- built from a constant
81
+ const STATIC_KEY_RE = new RegExp(`^${ASSET_PREFIXES.public}/[a-f0-9]{32}/[a-z0-9-]+\\.[a-z0-9]{1,10}$`);
82
+ const DEFAULT_LIST_THRESHOLD = 100;
83
+ const DEFAULT_ATTEMPTS = 3;
84
+ const DEFAULT_BASE_DELAY_MS = 500;
85
+ const TRANSIENT_NODE_CODES = new Set([
86
+ 'ECONNRESET',
87
+ 'ECONNREFUSED',
88
+ 'ETIMEDOUT',
89
+ 'EPIPE',
90
+ 'EAI_AGAIN',
91
+ 'EBUSY',
92
+ 'EMFILE',
93
+ ]);
94
+ const TRANSIENT_ERROR_NAMES = new Set([
95
+ 'TimeoutError',
96
+ 'RequestTimeout',
97
+ 'RequestTimeoutException',
98
+ 'SlowDown',
99
+ 'Throttling',
100
+ 'ThrottlingException',
101
+ 'ServiceUnavailable',
102
+ 'InternalError',
103
+ ]);
104
+ /**
105
+ * @internal Exported for tests. Throttling, 5xx and dropped connections are transient. Any other
106
+ * 4xx (AccessDenied, NoSuchBucket) is a configuration fault that a retry only delays.
107
+ */
108
+ export function isTransientStoreError(err) {
109
+ if (!(err instanceof Error))
110
+ return false;
111
+ if (isNodeError(err) && err.code && TRANSIENT_NODE_CODES.has(err.code))
112
+ return true;
113
+ const shaped = err;
114
+ if (shaped.$retryable)
115
+ return true;
116
+ if (TRANSIENT_ERROR_NAMES.has(err.name))
117
+ return true;
118
+ const status = shaped.$metadata?.httpStatusCode;
119
+ return status !== undefined && (status === 429 || status >= 500);
120
+ }
121
+ /** Run `fn` over `items` with at most `limit` in flight. `fn` must not reject. */
122
+ async function forEachBounded(items, limit, fn) {
123
+ let next = 0;
124
+ const worker = async () => {
125
+ while (next < items.length) {
126
+ const item = items[next++];
127
+ await fn(item);
128
+ }
129
+ };
130
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
131
+ }
132
+ /**
133
+ * Make every referenced transform key exist in `store`, and check every static key does. A key
134
+ * already stored is left alone:
135
+ * keys are content-addressed, so an existing object is the right one. sharp is loaded only when
136
+ * something is missing, and a sharp that cannot load throws `SharpUnavailableError` rather than
137
+ * failing each key.
138
+ */
139
+ export async function materializeAssets(options) {
140
+ const { store } = options;
141
+ const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY);
142
+ const transformConcurrency = Math.max(1, options.transformConcurrency ?? DEFAULT_TRANSFORM_CONCURRENCY);
143
+ const listThreshold = options.listThreshold ?? DEFAULT_LIST_THRESHOLD;
144
+ const attempts = Math.max(1, options.attempts ?? DEFAULT_ATTEMPTS);
145
+ const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
146
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
147
+ const withRetry = async (operation) => {
148
+ for (let attempt = 1;; attempt++) {
149
+ try {
150
+ return await operation();
151
+ }
152
+ catch (err) {
153
+ if (attempt >= attempts || !isTransientStoreError(err))
154
+ throw err;
155
+ await sleep(baseDelayMs * 2 ** (attempt - 1) * (1 + Math.random()));
156
+ }
157
+ }
158
+ };
159
+ const references = new Map();
160
+ const staticKeys = new Set((options.statics ?? []).map((target) => target.key));
161
+ for (const target of [...options.targets, ...(options.statics ?? [])]) {
162
+ const entry = references.get(target.key) ?? { routes: new Set(), files: new Set() };
163
+ target.routes.forEach((route) => entry.routes.add(route));
164
+ target.files.forEach((file) => entry.files.add(file));
165
+ references.set(target.key, entry);
166
+ }
167
+ const outcomes = new Map();
168
+ const fail = (key, failure, error) => outcomes.set(key, { status: 'failed', failure, error });
169
+ // The refs file is an editable file on disk, so every key is re-validated here.
170
+ const transformPrefix = `${ASSET_PREFIXES.transform}/`;
171
+ const valid = [];
172
+ const validStatics = [];
173
+ for (const key of references.keys()) {
174
+ if (staticKeys.has(key)) {
175
+ if (STATIC_KEY_RE.test(key))
176
+ validStatics.push(key);
177
+ else
178
+ fail(key, 'content', 'Not a static asset key (expected assets/{hash32}/{slug}.{ext})');
179
+ continue;
180
+ }
181
+ if (!key.startsWith(transformPrefix)) {
182
+ fail(key, 'content', `Not a transform key (expected a ${transformPrefix} prefix)`);
183
+ continue;
184
+ }
185
+ const segments = key.slice(transformPrefix.length).split('/');
186
+ const canonical = canonicalizeTransformPath(segments, 'any');
187
+ if (!canonical.ok) {
188
+ fail(key, 'content', `Invalid transform key: ${canonical.error}`);
189
+ }
190
+ else if (!canonical.isCanonical) {
191
+ fail(key, 'content', `Not canonical; the stored key would be ${transformPrefix}${canonical.canonicalPath}`);
192
+ }
193
+ else {
194
+ valid.push({ key, directives: segments[0], parsed: canonical });
195
+ }
196
+ }
197
+ const byDirectives = new Map();
198
+ for (const target of valid) {
199
+ const group = byDirectives.get(target.directives) ?? [];
200
+ group.push(target);
201
+ byDirectives.set(target.directives, group);
202
+ }
203
+ const toHead = [];
204
+ for (const [directives, group] of byDirectives) {
205
+ if (store.listPublicObjectKeys && group.length >= listThreshold) {
206
+ const listPrefix = `${transformPrefix}${directives}/`;
207
+ const listKeys = store.listPublicObjectKeys.bind(store);
208
+ try {
209
+ const found = await withRetry(async () => {
210
+ const keys = [];
211
+ for await (const key of listKeys(listPrefix))
212
+ keys.push(key);
213
+ return keys;
214
+ });
215
+ const listed = new Set(found);
216
+ for (const target of group) {
217
+ if (listed.has(target.key))
218
+ outcomes.set(target.key, { status: 'existed' });
219
+ }
220
+ continue;
221
+ }
222
+ catch {
223
+ // A listing that keeps failing costs only speed: HEAD the group instead, and let each
224
+ // HEAD report its own failure.
225
+ }
226
+ }
227
+ toHead.push(...group);
228
+ }
229
+ await forEachBounded(toHead, concurrency, async (target) => {
230
+ try {
231
+ if (await withRetry(() => store.hasPublicObject(target.key))) {
232
+ outcomes.set(target.key, { status: 'existed' });
233
+ }
234
+ }
235
+ catch (err) {
236
+ fail(target.key, 'store', `Existence check failed: ${getErrorMessage(err)}`);
237
+ }
238
+ });
239
+ await forEachBounded(validStatics, concurrency, async (key) => {
240
+ try {
241
+ // A HEAD cannot tell a missing key from a missing bucket, and a static key has no
242
+ // transform's meta read to tell them apart afterwards; a GET names which.
243
+ const exists = (await withRetry(() => store.hasPublicObject(key))) ||
244
+ (await withRetry(() => store.readPublicObject(key))) !== null;
245
+ if (exists) {
246
+ outcomes.set(key, { status: 'existed' });
247
+ }
248
+ else {
249
+ fail(key, 'content', 'No stored object; an svg or pdf is written at upload only');
250
+ }
251
+ }
252
+ catch (err) {
253
+ fail(key, 'store', `Existence check failed: ${getErrorMessage(err)}`);
254
+ }
255
+ });
256
+ const missing = valid.filter((target) => !outcomes.has(target.key));
257
+ if (missing.length > 0) {
258
+ try {
259
+ await loadSharp();
260
+ }
261
+ catch (err) {
262
+ throw new SharpUnavailableError(err);
263
+ }
264
+ }
265
+ await forEachBounded(missing, transformConcurrency, async (target) => {
266
+ try {
267
+ const result = await withRetry(() => storeTransform(store, target.parsed, target.key));
268
+ if (result.ok) {
269
+ outcomes.set(target.key, { status: 'created' });
270
+ }
271
+ else {
272
+ fail(target.key, 'content', `${result.status}: ${result.error}`);
273
+ }
274
+ }
275
+ catch (err) {
276
+ fail(target.key, 'store', getErrorMessage(err));
277
+ }
278
+ });
279
+ const results = [...references.keys()].sort().map((key) => {
280
+ const refs = references.get(key);
281
+ const outcome = outcomes.get(key) ?? {
282
+ status: 'failed',
283
+ failure: 'store',
284
+ error: 'No outcome recorded',
285
+ };
286
+ return {
287
+ key,
288
+ routes: [...(refs?.routes ?? [])].sort(),
289
+ files: [...(refs?.files ?? [])].sort(),
290
+ ...outcome,
291
+ };
292
+ });
293
+ const count = (predicate) => results.filter(predicate).length;
294
+ return {
295
+ summary: {
296
+ total: results.length,
297
+ existed: count((r) => r.status === 'existed'),
298
+ created: count((r) => r.status === 'created'),
299
+ failed: count((r) => r.status === 'failed'),
300
+ contentFailures: count((r) => r.status === 'failed' && r.failure === 'content'),
301
+ storeFailures: count((r) => r.status === 'failed' && r.failure === 'store'),
302
+ },
303
+ results,
304
+ };
305
+ }
@@ -48,6 +48,7 @@ export declare class LocalAssetStore implements AssetStore {
48
48
  data: Uint8Array;
49
49
  contentType: string;
50
50
  }): Promise<void>;
51
+ /** Ignores the `ext` hint: a directory read costs nothing here and needs no permission. */
51
52
  readOriginal(hash32: string): Promise<{
52
53
  data: Uint8Array;
53
54
  ext: string;
@@ -59,8 +60,11 @@ export declare class LocalAssetStore implements AssetStore {
59
60
  contentType: string;
60
61
  contentDisposition?: string;
61
62
  cacheControl?: string;
63
+ /** Ignored: tags exist for bucket lifecycle rules, and a local store has none. */
64
+ tags?: Readonly<Record<string, string>>;
62
65
  }): Promise<void>;
63
66
  readPublicObject(key: string): Promise<PublicObject | null>;
67
+ hasPublicObject(key: string): Promise<boolean>;
64
68
  /**
65
69
  * Atomic-exclusive create: opens with the `wx` flag so two concurrent
66
70
  * writers racing on the same hash32 have exactly one winner ('created')
@@ -113,6 +113,7 @@ export class LocalAssetStore {
113
113
  await this.writeFile(filePath, input.data);
114
114
  await this.writeHeadersSidecar(filePath, { contentType: input.contentType });
115
115
  }
116
+ /** Ignores the `ext` hint: a directory read costs nothing here and needs no permission. */
116
117
  async readOriginal(hash32) {
117
118
  const dir = this.resolveKey(ASSET_PREFIXES.originals);
118
119
  let entries;
@@ -157,6 +158,16 @@ export class LocalAssetStore {
157
158
  cacheControl: headers.cacheControl,
158
159
  };
159
160
  }
161
+ async hasPublicObject(key) {
162
+ try {
163
+ return (await fs.stat(this.resolveKey(key))).isFile();
164
+ }
165
+ catch (err) {
166
+ if (isNotFoundError(err))
167
+ return false;
168
+ throw err;
169
+ }
170
+ }
160
171
  /**
161
172
  * Atomic-exclusive create: opens with the `wx` flag so two concurrent
162
173
  * writers racing on the same hash32 have exactly one winner ('created')
@@ -60,7 +60,7 @@ export declare class S3AssetStore implements AssetStore {
60
60
  data: Uint8Array;
61
61
  contentType: string;
62
62
  }): Promise<void>;
63
- readOriginal(hash32: string): Promise<{
63
+ readOriginal(hash32: string, ext?: string): Promise<{
64
64
  data: Uint8Array;
65
65
  ext: string;
66
66
  contentType?: string;
@@ -71,8 +71,17 @@ export declare class S3AssetStore implements AssetStore {
71
71
  contentType: string;
72
72
  contentDisposition?: string;
73
73
  cacheControl?: string;
74
+ tags?: Readonly<Record<string, string>>;
74
75
  }): Promise<void>;
75
76
  readPublicObject(key: string): Promise<PublicObject | null>;
77
+ /**
78
+ * A HEAD. S3 answers a missing key with 403, not 404, unless the caller may `s3:ListBucket`,
79
+ * so a role without it sees an error here rather than `false`.
80
+ */
81
+ hasPublicObject(key: string): Promise<boolean>;
82
+ listPublicObjectKeys(prefix: string): AsyncIterable<string>;
83
+ /** HEADs first: signing is local and succeeds for a missing key too. */
84
+ presignPublicObjectRead(key: string): Promise<string | null>;
76
85
  putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<'created' | 'already-exists'>;
77
86
  getMeta(hash32: string): Promise<AssetMeta | null>;
78
87
  listMeta(input?: {
@@ -5,12 +5,15 @@
5
5
  * and writes objects under the five asset prefixes.
6
6
  */
7
7
  import { randomUUID } from 'node:crypto';
8
- import { DeleteObjectCommand, GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3';
8
+ import { 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,12 @@ 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
+ * A missing key, never a missing bucket: S3 answers both with 404, and only a GET's error code names
26
+ * which. A HEAD's 404 has no body, so `hasPublicObject` reads either as absent; a caller that must
27
+ * tell them apart follows with a GET (assets/materialize.ts does).
28
+ */
29
+ const isNoSuchKey = (err) => matchesAwsError(err, 'NoSuchKey', 404) && err.name !== 'NoSuchBucket';
22
30
  export class S3AssetStore {
23
31
  constructor(options) {
24
32
  this.capabilities = { directUpload: true };
@@ -97,7 +105,13 @@ export class S3AssetStore {
97
105
  ContentType: input.contentType,
98
106
  }));
99
107
  }
100
- async readOriginal(hash32) {
108
+ async readOriginal(hash32, ext) {
109
+ if (ext !== undefined) {
110
+ const direct = await this.getObject(this.keys.originalKey(hash32, ext));
111
+ const data = await direct?.Body?.transformToByteArray();
112
+ if (data)
113
+ return { data, ext, contentType: direct?.ContentType };
114
+ }
101
115
  const prefix = this.keys.originalPrefix(hash32);
102
116
  const listed = await this.client.send(new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix, MaxKeys: 1 }));
103
117
  const foundKey = listed.Contents?.[0]?.Key;
@@ -112,6 +126,11 @@ export class S3AssetStore {
112
126
  return { data, ext: foundKey.slice(prefix.length), contentType: result.ContentType };
113
127
  }
114
128
  async putPublicObject(input) {
129
+ // `encodeURIComponent` writes a space as `%20`, which reads the same to any decoder;
130
+ // URLSearchParams writes `+`.
131
+ const tagging = Object.entries(input.tags ?? {})
132
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
133
+ .join('&');
115
134
  await this.client.send(new PutObjectCommand({
116
135
  Bucket: this.bucket,
117
136
  Key: input.key,
@@ -119,6 +138,7 @@ export class S3AssetStore {
119
138
  ContentType: input.contentType,
120
139
  ContentDisposition: input.contentDisposition,
121
140
  CacheControl: input.cacheControl,
141
+ Tagging: tagging || undefined,
122
142
  }));
123
143
  }
124
144
  async readPublicObject(key) {
@@ -135,6 +155,38 @@ export class S3AssetStore {
135
155
  cacheControl: result.CacheControl,
136
156
  };
137
157
  }
158
+ /**
159
+ * A HEAD. S3 answers a missing key with 403, not 404, unless the caller may `s3:ListBucket`,
160
+ * so a role without it sees an error here rather than `false`.
161
+ */
162
+ async hasPublicObject(key) {
163
+ try {
164
+ await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
165
+ return true;
166
+ }
167
+ catch (err) {
168
+ if (isNoSuchKey(err))
169
+ return false;
170
+ throw err;
171
+ }
172
+ }
173
+ async *listPublicObjectKeys(prefix) {
174
+ const pages = paginateListObjectsV2({ client: this.client }, { Bucket: this.bucket, Prefix: prefix });
175
+ for await (const page of pages) {
176
+ for (const object of page.Contents ?? []) {
177
+ if (object.Key)
178
+ yield object.Key;
179
+ }
180
+ }
181
+ }
182
+ /** HEADs first: signing is local and succeeds for a missing key too. */
183
+ async presignPublicObjectRead(key) {
184
+ if (!(await this.hasPublicObject(key)))
185
+ return null;
186
+ return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), {
187
+ expiresIn: PRESIGNED_READ_EXPIRY_SECONDS,
188
+ });
189
+ }
138
190
  async putMetaIfAbsent(hash32, meta) {
139
191
  try {
140
192
  await this.client.send(new PutObjectCommand({
@@ -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 {};