canopycms 0.1.0 → 0.2.0-int.121

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 (136) hide show
  1. package/CHANGELOG.md +202 -43
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +118 -47
  4. package/dist/api/client.d.ts +5 -3
  5. package/dist/api/client.js +7 -3
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/assets/asset-prefixes.d.ts +4 -1
  8. package/dist/assets/asset-prefixes.js +3 -1
  9. package/dist/assets/editor-thumbnail-widths.d.ts +11 -0
  10. package/dist/assets/editor-thumbnail-widths.js +7 -0
  11. package/dist/assets/finalize.d.ts +7 -6
  12. package/dist/assets/finalize.js +29 -10
  13. package/dist/assets/keys.d.ts +4 -1
  14. package/dist/assets/keys.js +4 -1
  15. package/dist/assets/materialize.d.ts +17 -2
  16. package/dist/assets/materialize.js +57 -4
  17. package/dist/assets/store-local.d.ts +18 -9
  18. package/dist/assets/store-local.js +58 -26
  19. package/dist/assets/store-s3.d.ts +25 -6
  20. package/dist/assets/store-s3.js +81 -19
  21. package/dist/assets/types.d.ts +33 -4
  22. package/dist/cli/cli.js +151 -56
  23. package/dist/config/types.d.ts +3 -2
  24. package/dist/editor/BranchManager.js +2 -1
  25. package/dist/editor/CommentsPanel.js +67 -29
  26. package/dist/editor/Editor.d.ts +3 -0
  27. package/dist/editor/Editor.js +93 -39
  28. package/dist/editor/EntryNavigator.d.ts +17 -5
  29. package/dist/editor/EntryNavigator.js +66 -31
  30. package/dist/editor/FormHeader.d.ts +2 -1
  31. package/dist/editor/FormHeader.js +9 -5
  32. package/dist/editor/FormRenderer.d.ts +7 -2
  33. package/dist/editor/FormRenderer.js +51 -16
  34. package/dist/editor/PreviewFrame.d.ts +13 -2
  35. package/dist/editor/PreviewFrame.js +62 -5
  36. package/dist/editor/PreviewPane.d.ts +17 -1
  37. package/dist/editor/PreviewPane.js +55 -8
  38. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  39. package/dist/editor/PreviewToolbarTools.js +9 -1
  40. package/dist/editor/branch-status.d.ts +6 -0
  41. package/dist/editor/branch-status.js +10 -0
  42. package/dist/editor/comments/BranchComments.d.ts +2 -4
  43. package/dist/editor/comments/BranchComments.js +7 -11
  44. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  45. package/dist/editor/comments/ThreadCarousel.js +4 -49
  46. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  47. package/dist/editor/components/BranchLockBanner.d.ts +8 -7
  48. package/dist/editor/components/BranchLockBanner.js +30 -3
  49. package/dist/editor/components/BranchPill.d.ts +5 -4
  50. package/dist/editor/components/BranchPill.js +6 -8
  51. package/dist/editor/components/BranchRowLock.d.ts +12 -0
  52. package/dist/editor/components/BranchRowLock.js +19 -0
  53. package/dist/editor/components/BranchesDrawer.js +3 -3
  54. package/dist/editor/components/EditorHeader.d.ts +2 -6
  55. package/dist/editor/components/EditorHeader.js +14 -28
  56. package/dist/editor/components/EditorRail.d.ts +3 -4
  57. package/dist/editor/components/EditorRail.js +3 -4
  58. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  59. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  60. package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
  61. package/dist/editor/components/PreviewLinkToast.js +33 -0
  62. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  63. package/dist/editor/components/QuickOpenPalette.js +6 -1
  64. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  65. package/dist/editor/components/RenameEntryModal.js +20 -5
  66. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  67. package/dist/editor/components/SaveSplitButton.js +25 -12
  68. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  69. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  70. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  71. package/dist/editor/components/UndoToastMessage.js +6 -4
  72. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  73. package/dist/editor/components/UnsavedMark.js +20 -0
  74. package/dist/editor/components/UserBadge.js +13 -11
  75. package/dist/editor/copy.d.ts +4 -0
  76. package/dist/editor/copy.js +4 -0
  77. package/dist/editor/editor-utils.d.ts +49 -9
  78. package/dist/editor/editor-utils.js +117 -16
  79. package/dist/editor/entry-search.d.ts +4 -2
  80. package/dist/editor/entry-search.js +2 -3
  81. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  82. package/dist/editor/fields/FieldLabel.js +1 -1
  83. package/dist/editor/fields/ImageField.d.ts +1 -1
  84. package/dist/editor/fields/ImageField.js +36 -27
  85. package/dist/editor/fields/ObjectField.d.ts +13 -2
  86. package/dist/editor/fields/ObjectField.js +38 -9
  87. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  88. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  89. package/dist/editor/group-manager/index.js +1 -1
  90. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  91. package/dist/editor/hooks/useBranchManager.js +1 -0
  92. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  93. package/dist/editor/hooks/useEditorLayout.js +5 -0
  94. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  95. package/dist/editor/hooks/useEntryManager.js +10 -8
  96. package/dist/editor/media/AssetCard.d.ts +6 -3
  97. package/dist/editor/media/AssetCard.js +24 -9
  98. package/dist/editor/media/AssetListRow.d.ts +9 -4
  99. package/dist/editor/media/AssetListRow.js +18 -9
  100. package/dist/editor/media/CropStep.d.ts +8 -10
  101. package/dist/editor/media/CropStep.js +39 -27
  102. package/dist/editor/media/MediaLibrary.js +5 -4
  103. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  104. package/dist/editor/media/MediaLibraryBody.js +65 -37
  105. package/dist/editor/media/crop-availability.d.ts +6 -0
  106. package/dist/editor/media/crop-availability.js +19 -0
  107. package/dist/editor/media/crop-math.d.ts +19 -13
  108. package/dist/editor/media/crop-math.js +50 -11
  109. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  110. package/dist/editor/media/useAssetLibrary.js +17 -10
  111. package/dist/editor/pane-header.d.ts +6 -0
  112. package/dist/editor/pane-header.js +6 -0
  113. package/dist/editor/permission-manager/index.js +1 -1
  114. package/dist/editor/preview-bridge.d.ts +23 -0
  115. package/dist/editor/preview-bridge.js +105 -4
  116. package/dist/editor/preview-path.d.ts +16 -4
  117. package/dist/editor/preview-path.js +80 -19
  118. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  119. package/dist/editor/shell/ActivityRail.js +3 -3
  120. package/dist/editor/shell/EditorShell.d.ts +15 -0
  121. package/dist/editor/shell/EditorShell.js +17 -1
  122. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  123. package/dist/editor/shell/OverlayHeader.js +15 -0
  124. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  125. package/dist/editor/shell/PaneLayout.js +68 -13
  126. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  127. package/dist/editor/shell/SidePanelHost.js +70 -33
  128. package/dist/editor/shell/TopBar.js +2 -1
  129. package/dist/editor/theme.d.ts +1 -0
  130. package/dist/editor/theme.js +19 -1
  131. package/dist/editor/undo-removal.d.ts +19 -10
  132. package/dist/editor/undo-removal.js +44 -16
  133. package/dist/server.d.ts +2 -0
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. package/package.json +2 -2
@@ -42,12 +42,14 @@ const finalizeAssetBodySchema = z.object({
42
42
  const listAssetsParamsSchema = z.object({
43
43
  cursor: z.string().optional(),
44
44
  limit: z.coerce.number().int().positive().max(100).optional(),
45
+ /** Admin only: also list files removed from the library, each marked `hidden: true`. */
46
+ includeHidden: z.enum(['true', 'false']).optional(),
45
47
  });
46
48
  /** hash32 is a sha-256 truncated to 32 hex chars (keys.ts's `hashBytes`) - never any other shape. */
47
49
  const hash32Schema = z
48
50
  .string()
49
51
  .regex(/^[a-f0-9]{32}$/, 'key must be a 32-character lowercase hex string');
50
- const deleteAssetParamsSchema = z.object({ key: hash32Schema });
52
+ const assetKeyBodySchema = z.object({ key: hash32Schema });
51
53
  /**
52
54
  * Presign a direct (or proxied) upload target. Any authenticated user - there
53
55
  * is no finer "editor" role, and upload needs to work for every non-admin
@@ -75,7 +77,8 @@ const presignAssetHandler = async (ctx, _req, body) => {
75
77
  /**
76
78
  * Finalize a staged upload: read the staged bytes, run the finalize pipeline
77
79
  * (sniff/hash/dims/sanitize), write originals + meta, and return the result.
78
- * Any authenticated user (same rationale as presign).
80
+ * Any authenticated user (same rationale as presign). Uploading a file that was
81
+ * removed from the library puts it back; `finalizeAsset` says why.
79
82
  */
80
83
  const finalizeAssetHandler = async (ctx, req, body) => {
81
84
  if (!ctx.assetStore)
@@ -168,49 +171,82 @@ const uploadProxiedHandler = async (ctx, req) => {
168
171
  /**
169
172
  * List assets - any authenticated user can list assets (key enumeration is
170
173
  * accepted: unlisted != private, see .claude/future-tasks/resolved/assets-media-system.md).
174
+ * Files removed from the library are left out; only an admin may ask for them too.
171
175
  */
172
- const listAssetsHandler = async (ctx, _req, params) => {
176
+ const listAssetsHandler = async (ctx, req, params) => {
173
177
  if (!ctx.assetStore)
174
178
  return { ok: false, status: 501, error: 'Asset store not configured' };
179
+ const includeHidden = params.includeHidden === 'true';
180
+ if (includeHidden && !isAdmin(req.user.groups)) {
181
+ return { ok: false, status: 403, error: 'Only admins can list removed files' };
182
+ }
175
183
  const { items, nextCursor } = await ctx.assetStore.listMeta({
176
184
  cursor: params.cursor,
177
185
  limit: params.limit,
186
+ includeHidden,
178
187
  });
179
188
  return { ok: true, status: 200, data: { assets: items.map(toAssetRecord), nextCursor } };
180
189
  };
181
190
  /**
182
- * Delete asset - an Admin may delete any asset; anyone else may delete only an asset whose
183
- * recorded `uploadedBy` is them. `key` (hash32) is pre-validated by `deleteAssetParamsSchema`.
184
- * Deletes the meta sidecar only - blobs are immortal until a future GC worker task (see
185
- * .claude/future-tasks/resolved/assets-media-system.md).
191
+ * Who may remove a file from the library or restore it: an admin may act on any file; anyone else
192
+ * only on a file whose recorded `uploadedBy` is them. `null` when the caller may act.
186
193
  *
187
- * The ownership check lives here, not in a declarative guard, because it needs the asset's meta,
188
- * which guards can't read. Two fail-closed choices:
194
+ * The check lives here, not in a declarative guard, because it needs the asset's meta, which
195
+ * guards can't read. Two fail-closed choices:
189
196
  * - Meta with no `uploadedBy` (the field is optional) is admin-only — defaulting to "anyone may
190
- * delete" would open every legacy asset to everyone.
197
+ * act" would open every legacy asset to everyone.
191
198
  * - A missing asset returns the same 403 as an unowned one for non-admins, so the endpoint isn't
192
- * an existence oracle over a content-addressed keyspace.
199
+ * an existence oracle over a content-addressed keyspace. An admin, who can list every file,
200
+ * gets a 404, so no marker is ever written for a file that does not exist.
193
201
  *
194
202
  * `uploadedBy` records the FIRST uploader only: finalizeAsset dedups on content hash, so a second
195
- * person uploading an identical file gains no delete rights over it — benign (a 403 where they
196
- * expected success, never the reverse; see
197
- * safeinsights/canopycms#572).
203
+ * person uploading an identical file gains no rights over it — benign (a 403 where they expected
204
+ * success, never the reverse; see safeinsights/canopycms#572).
198
205
  *
199
206
  * Coupled to the blob-GC follow-up in safeinsights/canopycms#575: this permission is safe because
200
- * delete is a de-list, not a destroy — nothing another branch references breaks. If GC ever makes
201
- * delete destroy the blob, this needs a reference check too.
207
+ * removal hides a file and destroys nothing, so nothing any branch or build references breaks. If
208
+ * GC ever deletes blobs, it needs a reference check of its own.
209
+ */
210
+ async function visibilityRefusal(store, req, key, verb) {
211
+ const meta = await store.getMeta(key);
212
+ if (isAdmin(req.user.groups)) {
213
+ return meta ? null : { ok: false, status: 404, error: 'File not found' };
214
+ }
215
+ if (!meta?.uploadedBy || meta.uploadedBy !== req.user.userId) {
216
+ return { ok: false, status: 403, error: `You can only ${verb} files you uploaded` };
217
+ }
218
+ return null;
219
+ }
220
+ /**
221
+ * Remove a file from the library: write its hidden marker and leave its meta, originals and
222
+ * public objects alone, so every URL and every transform, stored or not yet computed, keeps
223
+ * working for content on any branch and in any build. `visibilityRefusal` says who may.
224
+ *
225
+ * The marker is create-only, so a second remove finds it and changes nothing. Remove, restore
226
+ * and a re-upload's un-hide touch only the marker, and nothing but the library listing reads it:
227
+ * the worst interleaving of two of them lists or omits one file until the next action.
202
228
  */
203
- const deleteAssetHandler = async (ctx, req, params) => {
229
+ const hideAssetHandler = async (ctx, req, body) => {
204
230
  if (!ctx.assetStore)
205
231
  return { ok: false, status: 501, error: 'Asset store not configured' };
206
- if (!isAdmin(req.user.groups)) {
207
- const meta = await ctx.assetStore.getMeta(params.key);
208
- if (!meta?.uploadedBy || meta.uploadedBy !== req.user.userId) {
209
- return { ok: false, status: 403, error: 'You can only delete assets you uploaded' };
210
- }
211
- }
212
- await ctx.assetStore.deleteMeta(params.key);
213
- return { ok: true, status: 200, data: { deleted: true } };
232
+ const refusal = await visibilityRefusal(ctx.assetStore, req, body.key, 'remove');
233
+ if (refusal)
234
+ return refusal;
235
+ await ctx.assetStore.hideAsset(body.key, {
236
+ hiddenAt: new Date().toISOString(),
237
+ hiddenBy: req.user.userId,
238
+ });
239
+ return { ok: true, status: 200, data: { hidden: true } };
240
+ };
241
+ /** Put a removed file back in the library by deleting its hidden marker; see `hideAssetHandler`. */
242
+ const restoreAssetHandler = async (ctx, req, body) => {
243
+ if (!ctx.assetStore)
244
+ return { ok: false, status: 501, error: 'Asset store not configured' };
245
+ const refusal = await visibilityRefusal(ctx.assetStore, req, body.key, 'restore');
246
+ if (refusal)
247
+ return refusal;
248
+ await ctx.assetStore.unhideAsset(body.key);
249
+ return { ok: true, status: 200, data: { hidden: false } };
214
250
  };
215
251
  /**
216
252
  * The largest body this route returns inline. The CMS Lambda's Function URL buffers its response
@@ -219,15 +255,19 @@ const deleteAssetHandler = async (ctx, req, params) => {
219
255
  */
220
256
  const INLINE_BODY_LIMIT_BYTES = 4 * 1024 * 1024;
221
257
  /**
222
- * A 302 to a presigned store URL. `no-store` because the URL expires: a cached redirect would
223
- * outlive it, and a browser revisiting the page would follow it to a 403.
258
+ * A 302 to a presigned store URL, cacheable by this browser alone for as long as the store says
259
+ * the URL stays reusable: the redirect then outlives a sign-out by at most that long, which is
260
+ * accepted because the target is a public-prefix object under an unguessable key.
224
261
  */
225
- function presignedRedirect(url) {
262
+ function presignedRedirect({ url, maxAgeSeconds }) {
226
263
  return {
227
264
  kind: 'binary',
228
265
  status: 302,
229
266
  body: new Uint8Array(),
230
- headers: { location: url, cacheControl: 'no-store' },
267
+ headers: {
268
+ location: url,
269
+ cacheControl: maxAgeSeconds > 0 ? `private, max-age=${maxAgeSeconds}` : 'no-store',
270
+ },
231
271
  };
232
272
  }
233
273
  /**
@@ -243,9 +283,9 @@ async function serveLazyTransform(assetStore, parsed, canonicalKey) {
243
283
  return { ok: false, status: transformed.status, error };
244
284
  }
245
285
  if (transformed.data.byteLength > INLINE_BODY_LIMIT_BYTES && assetStore.presignPublicObjectRead) {
246
- const url = await assetStore.presignPublicObjectRead(canonicalKey);
247
- if (url)
248
- return presignedRedirect(url);
286
+ const presigned = await assetStore.presignPublicObjectRead(canonicalKey);
287
+ if (presigned)
288
+ return presignedRedirect(presigned);
249
289
  }
250
290
  return {
251
291
  kind: 'binary',
@@ -254,11 +294,17 @@ async function serveLazyTransform(assetStore, parsed, canonicalKey) {
254
294
  headers: { contentType: transformed.contentType, cacheControl: TRANSFORM_CACHE_CONTROL },
255
295
  };
256
296
  }
297
+ /**
298
+ * For a streamed object stored without a Cache-Control of its own. Every key under the public
299
+ * prefix is content-addressed, so its bytes never change; `private` because this route is
300
+ * authenticated.
301
+ */
302
+ const STREAMED_OBJECT_CACHE_CONTROL = 'private, max-age=31536000, immutable';
257
303
  /** A redirect to, or the bytes of, the public object stored at `key`; `null` when there is none. */
258
304
  async function serveStoredObject(assetStore, key) {
259
305
  if (assetStore.presignPublicObjectRead) {
260
- const url = await assetStore.presignPublicObjectRead(key);
261
- return url ? presignedRedirect(url) : null;
306
+ const presigned = await assetStore.presignPublicObjectRead(key);
307
+ return presigned ? presignedRedirect(presigned) : null;
262
308
  }
263
309
  const object = await assetStore.readPublicObject(key);
264
310
  if (!object)
@@ -270,7 +316,7 @@ async function serveStoredObject(assetStore, key) {
270
316
  headers: {
271
317
  contentType: object.contentType,
272
318
  contentDisposition: object.contentDisposition,
273
- cacheControl: object.cacheControl,
319
+ cacheControl: object.cacheControl ?? STREAMED_OBJECT_CACHE_CONTROL,
274
320
  },
275
321
  };
276
322
  }
@@ -382,20 +428,43 @@ const listAssets = defineEndpoint({
382
428
  defaultMockData: { assets: [] },
383
429
  handler: listAssetsHandler,
384
430
  });
385
- /**
386
- * DELETE /assets?key={hash32}
387
- */
388
- const deleteAsset = defineEndpoint({
431
+ const hideAsset = defineEndpoint({
389
432
  namespace: 'assets',
390
- name: 'delete',
391
- method: 'DELETE',
392
- path: '/assets',
393
- params: deleteAssetParamsSchema,
394
- responseType: 'AssetDeleteResponse',
433
+ name: 'hide',
434
+ method: 'POST',
435
+ path: '/assets/hide',
436
+ body: assetKeyBodySchema,
437
+ bodyType: 'AssetKeyBody',
438
+ responseType: 'AssetVisibilityResponse',
395
439
  response: {},
396
- defaultMockData: { deleted: true },
397
- handler: deleteAssetHandler,
440
+ defaultMockData: { hidden: true },
441
+ handler: hideAssetHandler,
398
442
  });
443
+ const restoreAsset = defineEndpoint({
444
+ namespace: 'assets',
445
+ name: 'restore',
446
+ method: 'POST',
447
+ path: '/assets/restore',
448
+ body: assetKeyBodySchema,
449
+ bodyType: 'AssetKeyBody',
450
+ responseType: 'AssetVisibilityResponse',
451
+ response: {},
452
+ defaultMockData: { hidden: false },
453
+ handler: restoreAssetHandler,
454
+ });
455
+ /**
456
+ * DELETE /assets answers 410 instead of falling through to `DELETE /:branch` as a branch named
457
+ * "assets". Hand-built like `assetRawRoute`, so the client generator emits no method for it.
458
+ */
459
+ const deleteAssetGone = {
460
+ method: 'DELETE',
461
+ pattern: ['assets'],
462
+ handler: async () => ({
463
+ ok: false,
464
+ status: 410,
465
+ error: 'DELETE /assets is gone: remove a file from the library with POST /assets/hide',
466
+ }),
467
+ };
399
468
  /**
400
469
  * Exported routes for router registration and client codegen.
401
470
  */
@@ -404,7 +473,9 @@ export const ASSET_ROUTES = {
404
473
  finalize: finalizeAsset,
405
474
  uploadProxied,
406
475
  list: listAssets,
407
- delete: deleteAsset,
476
+ hide: hideAsset,
477
+ restore: restoreAsset,
478
+ deleteGone: deleteAssetGone,
408
479
  };
409
480
  /**
410
481
  * GET /assets/raw/{key...} - see `rawAssetHandler` above for why this is
@@ -15,7 +15,7 @@ import type { ContentReadResponse, ContentWriteResponse, ReferenceValidationResp
15
15
  import type { ReferenceOptionsResponse } from './reference-options.js';
16
16
  import type { ResolveReferencesBody, ResolveReferencesResponse } from './resolve-references.js';
17
17
  import type { DeleteEntryResponse, EntriesResponse } from './entries.js';
18
- import type { AssetDeleteResponse, AssetsListResponse, FinalizeAssetBody, FinalizeAssetResponse, PresignAssetBody, PresignAssetResponse } from './assets.js';
18
+ import type { AssetKeyBody, AssetVisibilityResponse, AssetsListResponse, FinalizeAssetBody, FinalizeAssetResponse, PresignAssetBody, PresignAssetResponse } from './assets.js';
19
19
  import type { BatchGetUserMetadataBody, BatchGetUserMetadataResponse, GetUserMetadataResponse, ListGroupsResponse, PermissionsResponse, SearchUsersResponse, UpdatePermissionsBody } from './permissions.js';
20
20
  import type { ExternalGroupsResponse, InternalGroupsResponse, UpdateInternalGroupsBody, UpdateInternalGroupsResponse } from './groups.js';
21
21
  import type { UserInfoResponse } from './user.js';
@@ -131,8 +131,10 @@ export declare class CanopyApiClient {
131
131
  } & RequestOptions) => Promise<FinalizeAssetResponse>;
132
132
  /** GET /assets */
133
133
  list: (params: Record<string, string>, options?: RequestOptions) => Promise<AssetsListResponse>;
134
- /** DELETE /assets */
135
- delete: (params: Record<string, string>, options?: RequestOptions) => Promise<AssetDeleteResponse>;
134
+ /** POST /assets/hide */
135
+ hide: (body: AssetKeyBody, options?: RequestOptions) => Promise<AssetVisibilityResponse>;
136
+ /** POST /assets/restore */
137
+ restore: (body: AssetKeyBody, options?: RequestOptions) => Promise<AssetVisibilityResponse>;
136
138
  };
137
139
  readonly permissions: {
138
140
  /** GET /permissions */
@@ -139,9 +139,13 @@ export class CanopyApiClient {
139
139
  list: (params, options) => {
140
140
  return this.request('GET', this.buildPath('/assets', params), undefined, 'standard', options);
141
141
  },
142
- /** DELETE /assets */
143
- delete: (params, options) => {
144
- return this.request('DELETE', this.buildPath('/assets', params), undefined, 'standard', options);
142
+ /** POST /assets/hide */
143
+ hide: (body, options) => {
144
+ return this.request('POST', '/assets/hide', body, 'standard', options);
145
+ },
146
+ /** POST /assets/restore */
147
+ restore: (body, options) => {
148
+ return this.request('POST', '/assets/restore', body, 'standard', options);
145
149
  },
146
150
  };
147
151
  this.permissions = {
@@ -5,7 +5,7 @@ export type { CommentsResponse, AddCommentResponse, ResolveCommentResponse } fro
5
5
  export type { EntriesResponse, ListEntriesResponse, CollectionItem, ListEntriesParams, DeleteEntryResponse, } from './entries.js';
6
6
  export type { PermissionsResponse, UpdatePermissionsBody, SearchUsersParams, GetUserMetadataResponse, GetUserMetadataParams, } from './permissions.js';
7
7
  export type { UserInfoResponse } from './user.js';
8
- export type { AssetRecord, AssetsListResponse, PresignAssetResponse, PresignAssetBody, FinalizeAssetResponse, FinalizeAssetBody, AssetDeleteResponse, } from './assets.js';
8
+ export type { AssetRecord, AssetsListResponse, PresignAssetResponse, PresignAssetBody, FinalizeAssetResponse, FinalizeAssetBody, AssetKeyBody, AssetVisibilityResponse, } from './assets.js';
9
9
  export type { InternalGroupsResponse, ExternalGroupsResponse, UpdateInternalGroupsBody, SearchExternalGroupsParams, ExternalGroup, } from './groups.js';
10
10
  export type { ReferenceOptionsResponse } from './reference-options.js';
11
11
  export type { ResolveReferencesResponse } from './resolve-references.js';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The five S3/local bucket-prefix strings (see keys.ts's module doc for the full layout).
2
+ * The six S3/local bucket-prefix strings (see keys.ts's module doc for the full layout).
3
3
  *
4
4
  * Split into its own dependency-free file so isomorphic modules (transform-directives.ts,
5
5
  * asset-url.ts) can import the prefixes without pulling keys.ts's `node:crypto` import into a
@@ -9,6 +9,8 @@ export declare const ASSET_PREFIXES: {
9
9
  readonly originals: "asset-originals";
10
10
  readonly staging: "asset-staging";
11
11
  readonly meta: "asset-meta";
12
+ /** One marker per file removed from the media library; its meta stays (see `hideAsset`). */
13
+ readonly hidden: "asset-hidden";
12
14
  readonly public: "assets";
13
15
  /** Transform-output prefix; directive-aware key building lives in transform-directives.ts. */
14
16
  readonly transform: "assets/t";
@@ -21,6 +23,7 @@ export interface AssetPrefixes {
21
23
  originals: string;
22
24
  staging: string;
23
25
  meta: string;
26
+ hidden: string;
24
27
  public: string;
25
28
  transform: string;
26
29
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The five S3/local bucket-prefix strings (see keys.ts's module doc for the full layout).
2
+ * The six S3/local bucket-prefix strings (see keys.ts's module doc for the full layout).
3
3
  *
4
4
  * Split into its own dependency-free file so isomorphic modules (transform-directives.ts,
5
5
  * asset-url.ts) can import the prefixes without pulling keys.ts's `node:crypto` import into a
@@ -9,6 +9,8 @@ export const ASSET_PREFIXES = {
9
9
  originals: 'asset-originals',
10
10
  staging: 'asset-staging',
11
11
  meta: 'asset-meta',
12
+ /** One marker per file removed from the media library; its meta stays (see `hideAsset`). */
13
+ hidden: 'asset-hidden',
12
14
  public: 'assets',
13
15
  /** Transform-output prefix; directive-aware key building lives in transform-directives.ts. */
14
16
  transform: 'assets/t',
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The widths the editor's media UI requests through the raw route: a library list row, a library
3
+ * card and an image field's preview. Finalize stores each for a still raster upload, new or
4
+ * re-uploaded (`storeEditorThumbnails`), so a first view reads a stored object instead of
5
+ * transforming in the request. Isomorphic: the editor components and finalize both import it.
6
+ */
7
+ export declare const EDITOR_THUMBNAIL_WIDTHS: {
8
+ readonly listRow: 80;
9
+ readonly card: 160;
10
+ readonly imageField: 320;
11
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The widths the editor's media UI requests through the raw route: a library list row, a library
3
+ * card and an image field's preview. Finalize stores each for a still raster upload, new or
4
+ * re-uploaded (`storeEditorThumbnails`), so a first view reads a stored object instead of
5
+ * transforming in the request. Isomorphic: the editor components and finalize both import it.
6
+ */
7
+ export const EDITOR_THUMBNAIL_WIDTHS = { listRow: 80, card: 160, imageField: 320 };
@@ -15,12 +15,13 @@ export type FinalizeAssetResult = {
15
15
  * Run the finalize pipeline over raw bytes and persist the result.
16
16
  *
17
17
  * Order matters here too: dedup FIRST (after hashing, inside the pipeline) -
18
- * if an identical blob already exists, return the existing meta and skip all
19
- * writes entirely (first-name-wins). Otherwise write the original, then the
20
- * optional public object, then `putMetaIfAbsent` LAST: meta is the commit
21
- * point for an asset's existence, so a crash between `putOriginal` and here
22
- * leaves only orphaned (harmless, content-addressed) blobs, never a meta
23
- * record pointing at a missing blob.
18
+ * if an identical blob already exists, return the existing meta, touching
19
+ * only its hidden marker and any editor thumbnails it lacks (first-name-wins).
20
+ * Otherwise write the original, then the optional public object, then
21
+ * `putMetaIfAbsent` LAST: meta is the commit point for an asset's existence,
22
+ * so a crash between `putOriginal` and here leaves only orphaned (harmless,
23
+ * content-addressed) blobs, never a meta record pointing at a missing blob.
24
+ * The editor thumbnails come after the commit.
24
25
  * @internal Exported for tests.
25
26
  */
26
27
  export declare function finalizeAsset(store: AssetStore, input: FinalizeInput): Promise<FinalizeAssetResult>;
@@ -5,6 +5,9 @@
5
5
  */
6
6
  import { runFinalizePipeline } from './pipeline.js';
7
7
  import { ASSET_PREFIXES } from './keys.js';
8
+ import { storeEditorThumbnails } from './materialize.js';
9
+ import { getErrorMessage } from '../utils/error.js';
10
+ import { canopyLogWarn } from '../utils/logger.js';
8
11
  /**
9
12
  * A client-supplied staging key must be exactly `asset-staging/{uuid}` — the
10
13
  * shape beginUpload generates. Without this check, finalize's read + cleanup
@@ -23,12 +26,13 @@ export function isValidStagingKey(key) {
23
26
  * Run the finalize pipeline over raw bytes and persist the result.
24
27
  *
25
28
  * Order matters here too: dedup FIRST (after hashing, inside the pipeline) -
26
- * if an identical blob already exists, return the existing meta and skip all
27
- * writes entirely (first-name-wins). Otherwise write the original, then the
28
- * optional public object, then `putMetaIfAbsent` LAST: meta is the commit
29
- * point for an asset's existence, so a crash between `putOriginal` and here
30
- * leaves only orphaned (harmless, content-addressed) blobs, never a meta
31
- * record pointing at a missing blob.
29
+ * if an identical blob already exists, return the existing meta, touching
30
+ * only its hidden marker and any editor thumbnails it lacks (first-name-wins).
31
+ * Otherwise write the original, then the optional public object, then
32
+ * `putMetaIfAbsent` LAST: meta is the commit point for an asset's existence,
33
+ * so a crash between `putOriginal` and here leaves only orphaned (harmless,
34
+ * content-addressed) blobs, never a meta record pointing at a missing blob.
35
+ * The editor thumbnails come after the commit.
32
36
  * @internal Exported for tests.
33
37
  */
34
38
  export async function finalizeAsset(store, input) {
@@ -37,8 +41,21 @@ export async function finalizeAsset(store, input) {
37
41
  return result;
38
42
  const { meta, data, publicObject } = result;
39
43
  const existing = await store.getMeta(meta.hash32);
40
- if (existing)
44
+ if (existing) {
45
+ // Uploading a file removed from the library puts it back. Hiding is a de-list, not access
46
+ // control: whoever uploads the bytes has the file, and a picker selecting an upload the
47
+ // library never shows would be a dead end. A failed un-hide only leaves the file out of the
48
+ // listing, so the upload still succeeds.
49
+ try {
50
+ await store.unhideAsset(meta.hash32);
51
+ }
52
+ catch (err) {
53
+ canopyLogWarn(`[canopycms] could not restore removed asset ${meta.hash32} to the library on re-upload: ${getErrorMessage(err)}`);
54
+ }
55
+ // Stores any thumbnail the existing asset lacks; this upload's bytes are its original's.
56
+ await storeEditorThumbnails(store, existing, data, { skipStored: true });
41
57
  return { ok: true, meta: existing };
58
+ }
42
59
  // Either put may find its key taken (`already-exists`): by an earlier attempt that stopped
43
60
  // before the meta commit, or by a concurrent identical upload. The object kept there is this
44
61
  // content's, so finalize goes on to commit the meta either way.
@@ -49,11 +66,13 @@ export async function finalizeAsset(store, input) {
49
66
  const putResult = await store.putMetaIfAbsent(meta.hash32, meta);
50
67
  if (putResult === 'already-exists') {
51
68
  // Lost a race against a concurrent identical upload - the winner's meta
52
- // (already committed) is authoritative; return it rather than ours.
69
+ // (already committed) is authoritative; return it rather than ours. The
70
+ // winner's own finalize stores its thumbnails.
53
71
  const winner = await store.getMeta(meta.hash32);
54
- if (winner)
55
- return { ok: true, meta: winner };
72
+ return { ok: true, meta: winner ?? meta };
56
73
  }
74
+ // After the commit point: a thumbnail is keyed by the committed meta's slug.
75
+ await storeEditorThumbnails(store, meta, data);
57
76
  return { ok: true, meta };
58
77
  }
59
78
  /**
@@ -7,6 +7,7 @@
7
7
  * asset-originals/{hash32}.{ext} private; full-fidelity originals
8
8
  * asset-staging/{uuid} presigned-POST target
9
9
  * asset-meta/{hash32}.json private; filename/uploader/dims/mime
10
+ * asset-hidden/{hash32}.json private; marks a file removed from the library
10
11
  * assets/t/{directives}/{hash32}/{slug} transform outputs (see transform-directives.ts)
11
12
  * assets/{hash32}/{slug}.{ext} public static: sanitized SVG + PDF only
12
13
  *
@@ -50,8 +51,10 @@ export declare function createKeyBuilders(prefixes?: AssetPrefixes): {
50
51
  stagingKey: (uuid: string) => string;
51
52
  metaKey: (hash32: string) => string;
52
53
  metaPrefix: () => string;
54
+ hiddenKey: (hash32: string) => string;
55
+ hiddenPrefix: () => string;
53
56
  publicKey: (hash32: string, slug: string, ext: string) => string;
54
57
  };
55
- export declare const originalKey: (hash32: string, ext: string) => string, stagingKey: (uuid: string) => string, metaKey: (hash32: string) => string, publicKey: (hash32: string, slug: string, ext: string) => string;
58
+ export declare const originalKey: (hash32: string, ext: string) => string, stagingKey: (uuid: string) => string, metaKey: (hash32: string) => string, hiddenKey: (hash32: string) => string, publicKey: (hash32: string, slug: string, ext: string) => string;
56
59
  /** @internal Exported for tests. */
57
60
  export declare const originalPrefix: (hash32: string) => string, metaPrefix: () => string;
@@ -7,6 +7,7 @@
7
7
  * asset-originals/{hash32}.{ext} private; full-fidelity originals
8
8
  * asset-staging/{uuid} presigned-POST target
9
9
  * asset-meta/{hash32}.json private; filename/uploader/dims/mime
10
+ * asset-hidden/{hash32}.json private; marks a file removed from the library
10
11
  * assets/t/{directives}/{hash32}/{slug} transform outputs (see transform-directives.ts)
11
12
  * assets/{hash32}/{slug}.{ext} public static: sanitized SVG + PDF only
12
13
  *
@@ -81,10 +82,12 @@ export function createKeyBuilders(prefixes = ASSET_PREFIXES) {
81
82
  stagingKey: (uuid) => `${prefixes.staging}/${uuid}`,
82
83
  metaKey: (hash32) => `${prefixes.meta}/${hash32}.json`,
83
84
  metaPrefix: () => `${prefixes.meta}/`,
85
+ hiddenKey: (hash32) => `${prefixes.hidden}/${hash32}.json`,
86
+ hiddenPrefix: () => `${prefixes.hidden}/`,
84
87
  publicKey: (hash32, slug, ext) => `${prefixes.public}/${hash32}/${slug}.${ext}`,
85
88
  };
86
89
  }
87
90
  const keyBuilders = createKeyBuilders();
88
- export const { originalKey, stagingKey, metaKey, publicKey } = keyBuilders;
91
+ export const { originalKey, stagingKey, metaKey, hiddenKey, publicKey } = keyBuilders;
89
92
  /** @internal Exported for tests. */
90
93
  export const { originalPrefix, metaPrefix } = keyBuilders;
@@ -6,7 +6,7 @@
6
6
  * before that build is released) and canopycms-cdk's lazy transform Lambda.
7
7
  */
8
8
  import { type ParsedTransformPath } from './transform-directives.js';
9
- import type { AssetStore, CreateOnlyResult } from './types.js';
9
+ import type { AssetMeta, AssetStore, CreateOnlyResult } from './types.js';
10
10
  /** Every transform output is stored under a content-addressed key, so it never changes. */
11
11
  export declare const TRANSFORM_CACHE_CONTROL = "public, max-age=31536000, immutable";
12
12
  /**
@@ -39,11 +39,26 @@ export type StoreTransformResult = {
39
39
  * `canonicalizeTransformPath`, so the stored pixels always match their key. A rejection is a fact
40
40
  * about the asset or the URL and is never worth retrying; a thrown error belongs to the store or
41
41
  * the environment. A 404's `error` names what was missing, so a caller answering a browser
42
- * replaces it.
42
+ * replaces it. A caller already holding the asset's committed meta and original bytes passes them
43
+ * as `source`, and the store is not read.
43
44
  */
44
45
  export declare function storeTransform(store: AssetStore, parsed: ParsedTransformPath, key: string, options?: {
45
46
  tags?: Readonly<Record<string, string>>;
47
+ source?: {
48
+ meta: AssetMeta;
49
+ data: Uint8Array;
50
+ };
46
51
  }): Promise<StoreTransformResult>;
52
+ /**
53
+ * Store the editor thumbnails of a raster asset whose meta is committed, from `data`, its
54
+ * original's bytes. Best effort: the raw route computes a missing one on first view, so a failure
55
+ * is logged, never thrown, and the first one stops the rest, which would decode the same bytes in
56
+ * the same environment. One width at a time, so one decode is in memory. A multi-page (animated)
57
+ * source is skipped. `skipStored` HEADs each key first, for an asset that may already have them.
58
+ */
59
+ export declare function storeEditorThumbnails(store: AssetStore, meta: AssetMeta, data: Uint8Array, options?: {
60
+ skipStored?: boolean;
61
+ }): Promise<void>;
47
62
  /** One key a build references, and where it was referenced, for the failure report. */
48
63
  export interface MaterializeTarget {
49
64
  /** `assets/t/{directives}/{hash32}/{slug}.{ext}`. */
@@ -6,10 +6,12 @@
6
6
  * before that build is released) and canopycms-cdk's lazy transform Lambda.
7
7
  */
8
8
  import { getErrorMessage, isNodeError } from '../utils/error.js';
9
+ import { canopyLogWarn } from '../utils/logger.js';
9
10
  import { ASSET_PREFIXES } from './asset-prefixes.js';
10
11
  import { loadSharp } from './sharp-loader.js';
11
12
  import { applyTransform } from './transform.js';
12
- import { canonicalizeTransformPath } from './transform-directives.js';
13
+ import { EDITOR_THUMBNAIL_WIDTHS } from './editor-thumbnail-widths.js';
14
+ import { canonicalizeTransformPath, formatDirectives, } from './transform-directives.js';
13
15
  /** Every transform output is stored under a content-addressed key, so it never changes. */
14
16
  export const TRANSFORM_CACHE_CONTROL = 'public, max-age=31536000, immutable';
15
17
  /**
@@ -25,10 +27,15 @@ export const LAZY_TRANSFORM_TAG = { key: 'canopy-transform', value: 'lazy' };
25
27
  * `canonicalizeTransformPath`, so the stored pixels always match their key. A rejection is a fact
26
28
  * about the asset or the URL and is never worth retrying; a thrown error belongs to the store or
27
29
  * the environment. A 404's `error` names what was missing, so a caller answering a browser
28
- * replaces it.
30
+ * replaces it. A caller already holding the asset's committed meta and original bytes passes them
31
+ * as `source`, and the store is not read.
29
32
  */
30
33
  export async function storeTransform(store, parsed, key, options = {}) {
31
- const meta = await store.getMeta(parsed.hash32);
34
+ const { source } = options;
35
+ if (source && source.meta.hash32 !== parsed.hash32) {
36
+ throw new Error(`source is asset ${source.meta.hash32}, not ${parsed.hash32}`);
37
+ }
38
+ const meta = source?.meta ?? (await store.getMeta(parsed.hash32));
32
39
  if (!meta) {
33
40
  return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no meta in the store` };
34
41
  }
@@ -51,7 +58,9 @@ export async function storeTransform(store, parsed, key, options = {}) {
51
58
  if (requestedFormat === undefined && parsed.ext !== meta.ext) {
52
59
  return { ok: false, status: 400, error: 'Extension does not match the source format' };
53
60
  }
54
- const original = await store.readOriginal(parsed.hash32, meta.ext);
61
+ const original = source
62
+ ? { data: source.data, ext: meta.ext }
63
+ : await store.readOriginal(parsed.hash32, meta.ext);
55
64
  if (!original) {
56
65
  return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no original in the store` };
57
66
  }
@@ -68,6 +77,50 @@ export async function storeTransform(store, parsed, key, options = {}) {
68
77
  });
69
78
  return { ok: true, data: transformed.data, contentType: transformed.contentType, stored };
70
79
  }
80
+ /** The canonical keys of an asset's `EDITOR_THUMBNAIL_WIDTHS`, the keys the raw route reads them at. */
81
+ function editorThumbnailTargets(meta) {
82
+ return Object.values(EDITOR_THUMBNAIL_WIDTHS).map((width) => {
83
+ const canonical = canonicalizeTransformPath([formatDirectives({ identity: false, width }), meta.hash32, `${meta.slug}.${meta.ext}`], 'any');
84
+ if (!canonical.ok)
85
+ throw new Error(canonical.error);
86
+ return { key: `${ASSET_PREFIXES.transform}/${canonical.canonicalPath}`, parsed: canonical };
87
+ });
88
+ }
89
+ /**
90
+ * Store the editor thumbnails of a raster asset whose meta is committed, from `data`, its
91
+ * original's bytes. Best effort: the raw route computes a missing one on first view, so a failure
92
+ * is logged, never thrown, and the first one stops the rest, which would decode the same bytes in
93
+ * the same environment. One width at a time, so one decode is in memory. A multi-page (animated)
94
+ * source is skipped. `skipStored` HEADs each key first, for an asset that may already have them.
95
+ */
96
+ export async function storeEditorThumbnails(store, meta, data, options = {}) {
97
+ if (meta.kind !== 'raster')
98
+ return;
99
+ let failure;
100
+ try {
101
+ // An animated source re-encodes every frame at every width, over 15 s for a large GIF: too
102
+ // long to hold an upload for, so its first view transforms it instead. The probe decodes no
103
+ // pixels.
104
+ const sharp = await loadSharp();
105
+ if (((await sharp(data).metadata()).pages ?? 1) > 1)
106
+ return;
107
+ for (const { key, parsed } of editorThumbnailTargets(meta)) {
108
+ if (options.skipStored && (await store.hasPublicObject(key)))
109
+ continue;
110
+ const result = await storeTransform(store, parsed, key, { source: { meta, data } });
111
+ if (!result.ok) {
112
+ failure = `${key}: ${result.error}`;
113
+ break;
114
+ }
115
+ }
116
+ }
117
+ catch (err) {
118
+ failure = getErrorMessage(err);
119
+ }
120
+ if (failure !== undefined) {
121
+ canopyLogWarn(`[canopycms] could not store editor thumbnails of ${meta.hash32}: ${failure}`);
122
+ }
123
+ }
71
124
  /** The `schemaVersion` of every `MaterializeReport`; a consumer gating on the JSON checks it. */
72
125
  export const MATERIALIZE_REPORT_SCHEMA_VERSION = 1;
73
126
  /** sharp cannot load in this process, so nothing missing can be produced here. */