canopycms 0.1.0 → 0.2.0-int.119

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 (113) hide show
  1. package/CHANGELOG.md +144 -41
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +98 -37
  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/finalize.js +14 -1
  10. package/dist/assets/keys.d.ts +4 -1
  11. package/dist/assets/keys.js +4 -1
  12. package/dist/assets/store-local.d.ts +18 -9
  13. package/dist/assets/store-local.js +58 -26
  14. package/dist/assets/store-s3.d.ts +19 -4
  15. package/dist/assets/store-s3.js +56 -13
  16. package/dist/assets/types.d.ts +26 -2
  17. package/dist/cli/cli.js +112 -34
  18. package/dist/editor/BranchManager.js +2 -1
  19. package/dist/editor/CommentsPanel.js +67 -29
  20. package/dist/editor/Editor.d.ts +3 -0
  21. package/dist/editor/Editor.js +58 -36
  22. package/dist/editor/EntryNavigator.d.ts +8 -1
  23. package/dist/editor/EntryNavigator.js +31 -16
  24. package/dist/editor/FormHeader.d.ts +2 -1
  25. package/dist/editor/FormHeader.js +9 -5
  26. package/dist/editor/FormRenderer.d.ts +7 -2
  27. package/dist/editor/FormRenderer.js +51 -16
  28. package/dist/editor/PreviewFrame.d.ts +11 -2
  29. package/dist/editor/PreviewFrame.js +20 -4
  30. package/dist/editor/PreviewPane.d.ts +15 -1
  31. package/dist/editor/PreviewPane.js +55 -8
  32. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  33. package/dist/editor/PreviewToolbarTools.js +9 -1
  34. package/dist/editor/branch-status.d.ts +6 -0
  35. package/dist/editor/branch-status.js +10 -0
  36. package/dist/editor/comments/BranchComments.d.ts +2 -4
  37. package/dist/editor/comments/BranchComments.js +7 -11
  38. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  39. package/dist/editor/comments/ThreadCarousel.js +4 -49
  40. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  41. package/dist/editor/components/BranchLockBanner.d.ts +2 -0
  42. package/dist/editor/components/BranchLockBanner.js +6 -2
  43. package/dist/editor/components/BranchPill.d.ts +5 -4
  44. package/dist/editor/components/BranchPill.js +6 -8
  45. package/dist/editor/components/BranchesDrawer.js +3 -3
  46. package/dist/editor/components/EditorHeader.d.ts +2 -6
  47. package/dist/editor/components/EditorHeader.js +29 -28
  48. package/dist/editor/components/EditorRail.d.ts +3 -4
  49. package/dist/editor/components/EditorRail.js +3 -4
  50. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  51. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  52. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  53. package/dist/editor/components/QuickOpenPalette.js +6 -1
  54. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  55. package/dist/editor/components/RenameEntryModal.js +20 -5
  56. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  57. package/dist/editor/components/SaveSplitButton.js +25 -12
  58. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  59. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  60. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  61. package/dist/editor/components/UndoToastMessage.js +6 -4
  62. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  63. package/dist/editor/components/UnsavedMark.js +20 -0
  64. package/dist/editor/components/UserBadge.js +13 -11
  65. package/dist/editor/copy.d.ts +4 -0
  66. package/dist/editor/copy.js +4 -0
  67. package/dist/editor/editor-utils.d.ts +49 -9
  68. package/dist/editor/editor-utils.js +117 -16
  69. package/dist/editor/entry-search.d.ts +4 -2
  70. package/dist/editor/entry-search.js +2 -3
  71. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  72. package/dist/editor/fields/ObjectField.d.ts +13 -2
  73. package/dist/editor/fields/ObjectField.js +38 -9
  74. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  75. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  76. package/dist/editor/group-manager/index.js +1 -1
  77. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  78. package/dist/editor/hooks/useEditorLayout.js +5 -0
  79. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  80. package/dist/editor/hooks/useEntryManager.js +10 -8
  81. package/dist/editor/media/AssetCard.d.ts +6 -3
  82. package/dist/editor/media/AssetCard.js +20 -7
  83. package/dist/editor/media/AssetListRow.d.ts +9 -4
  84. package/dist/editor/media/AssetListRow.js +14 -7
  85. package/dist/editor/media/MediaLibrary.js +5 -4
  86. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  87. package/dist/editor/media/MediaLibraryBody.js +65 -37
  88. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  89. package/dist/editor/media/useAssetLibrary.js +17 -10
  90. package/dist/editor/pane-header.d.ts +6 -0
  91. package/dist/editor/pane-header.js +6 -0
  92. package/dist/editor/permission-manager/index.js +1 -1
  93. package/dist/editor/preview-bridge.d.ts +16 -0
  94. package/dist/editor/preview-bridge.js +12 -3
  95. package/dist/editor/preview-path.d.ts +11 -4
  96. package/dist/editor/preview-path.js +62 -19
  97. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  98. package/dist/editor/shell/ActivityRail.js +3 -3
  99. package/dist/editor/shell/EditorShell.d.ts +15 -0
  100. package/dist/editor/shell/EditorShell.js +17 -1
  101. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  102. package/dist/editor/shell/OverlayHeader.js +15 -0
  103. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  104. package/dist/editor/shell/PaneLayout.js +68 -13
  105. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  106. package/dist/editor/shell/SidePanelHost.js +70 -33
  107. package/dist/editor/shell/TopBar.js +2 -1
  108. package/dist/editor/theme.js +2 -0
  109. package/dist/editor/undo-removal.d.ts +19 -10
  110. package/dist/editor/undo-removal.js +44 -16
  111. package/dist/version.d.ts +1 -1
  112. package/dist/version.js +1 -1
  113. package/package.json +1 -1
@@ -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
@@ -382,20 +418,43 @@ const listAssets = defineEndpoint({
382
418
  defaultMockData: { assets: [] },
383
419
  handler: listAssetsHandler,
384
420
  });
385
- /**
386
- * DELETE /assets?key={hash32}
387
- */
388
- const deleteAsset = defineEndpoint({
421
+ const hideAsset = defineEndpoint({
389
422
  namespace: 'assets',
390
- name: 'delete',
391
- method: 'DELETE',
392
- path: '/assets',
393
- params: deleteAssetParamsSchema,
394
- responseType: 'AssetDeleteResponse',
423
+ name: 'hide',
424
+ method: 'POST',
425
+ path: '/assets/hide',
426
+ body: assetKeyBodySchema,
427
+ bodyType: 'AssetKeyBody',
428
+ responseType: 'AssetVisibilityResponse',
395
429
  response: {},
396
- defaultMockData: { deleted: true },
397
- handler: deleteAssetHandler,
430
+ defaultMockData: { hidden: true },
431
+ handler: hideAssetHandler,
398
432
  });
433
+ const restoreAsset = defineEndpoint({
434
+ namespace: 'assets',
435
+ name: 'restore',
436
+ method: 'POST',
437
+ path: '/assets/restore',
438
+ body: assetKeyBodySchema,
439
+ bodyType: 'AssetKeyBody',
440
+ responseType: 'AssetVisibilityResponse',
441
+ response: {},
442
+ defaultMockData: { hidden: false },
443
+ handler: restoreAssetHandler,
444
+ });
445
+ /**
446
+ * DELETE /assets answers 410 instead of falling through to `DELETE /:branch` as a branch named
447
+ * "assets". Hand-built like `assetRawRoute`, so the client generator emits no method for it.
448
+ */
449
+ const deleteAssetGone = {
450
+ method: 'DELETE',
451
+ pattern: ['assets'],
452
+ handler: async () => ({
453
+ ok: false,
454
+ status: 410,
455
+ error: 'DELETE /assets is gone: remove a file from the library with POST /assets/hide',
456
+ }),
457
+ };
399
458
  /**
400
459
  * Exported routes for router registration and client codegen.
401
460
  */
@@ -404,7 +463,9 @@ export const ASSET_ROUTES = {
404
463
  finalize: finalizeAsset,
405
464
  uploadProxied,
406
465
  list: listAssets,
407
- delete: deleteAsset,
466
+ hide: hideAsset,
467
+ restore: restoreAsset,
468
+ deleteGone: deleteAssetGone,
408
469
  };
409
470
  /**
410
471
  * 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',
@@ -5,6 +5,8 @@
5
5
  */
6
6
  import { runFinalizePipeline } from './pipeline.js';
7
7
  import { ASSET_PREFIXES } from './keys.js';
8
+ import { getErrorMessage } from '../utils/error.js';
9
+ import { canopyLogWarn } from '../utils/logger.js';
8
10
  /**
9
11
  * A client-supplied staging key must be exactly `asset-staging/{uuid}` — the
10
12
  * shape beginUpload generates. Without this check, finalize's read + cleanup
@@ -37,8 +39,19 @@ export async function finalizeAsset(store, input) {
37
39
  return result;
38
40
  const { meta, data, publicObject } = result;
39
41
  const existing = await store.getMeta(meta.hash32);
40
- if (existing)
42
+ if (existing) {
43
+ // Uploading a file removed from the library puts it back. Hiding is a de-list, not access
44
+ // control: whoever uploads the bytes has the file, and a picker selecting an upload the
45
+ // library never shows would be a dead end. A failed un-hide only leaves the file out of the
46
+ // listing, so the upload still succeeds.
47
+ try {
48
+ await store.unhideAsset(meta.hash32);
49
+ }
50
+ catch (err) {
51
+ canopyLogWarn(`[canopycms] could not restore removed asset ${meta.hash32} to the library on re-upload: ${getErrorMessage(err)}`);
52
+ }
41
53
  return { ok: true, meta: existing };
54
+ }
42
55
  // Either put may find its key taken (`already-exists`): by an earlier attempt that stopped
43
56
  // before the meta commit, or by a concurrent identical upload. The object kept there is this
44
57
  // content's, so finalize goes on to commit the meta either way.
@@ -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;
@@ -3,9 +3,9 @@
3
3
  * bucket-prefix layout as S3AssetStore (see keys.ts) so content behaves
4
4
  * identically across adapters.
5
5
  */
6
- import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, PublicObject, StagedUploadTarget } from './types.js';
6
+ import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, HiddenAssetMarker, ListedAssetMeta, PublicObject, StagedUploadTarget } from './types.js';
7
7
  export interface LocalAssetStoreOptions {
8
- /** Root directory. The five bucket prefixes are created as subdirectories under it. */
8
+ /** Root directory. The six bucket prefixes are created as subdirectories under it. */
9
9
  root: string;
10
10
  }
11
11
  export declare class LocalAssetStore implements AssetStore {
@@ -33,11 +33,12 @@ export declare class LocalAssetStore implements AssetStore {
33
33
  /** Link `tempPath` in at `filePath`; `false` when something is already there. */
34
34
  private linkIfAbsent;
35
35
  /**
36
- * Create-only write of a blob and its headers sidecar. Each is written whole to a temp file, then
37
- * `link()`ed into place, which fails with EEXIST rather than replacing (see docs/concurrency.md).
38
- * The sidecar is linked first, so a reader never finds a blob without its headers. A sidecar
39
- * already there was linked by an earlier writer of this key and is kept, as the blob is: the
40
- * first writer's headers win, as on S3 (a static's Content-Disposition names its uploaded file).
36
+ * Create-only write of a blob and, when `headers` is given, its headers sidecar. Each is written
37
+ * whole to a temp file, then `link()`ed into place, which fails with EEXIST rather than replacing
38
+ * (see docs/concurrency.md). The sidecar is linked first, so a reader never finds a blob without
39
+ * its headers. A sidecar already there was linked by an earlier writer of this key and is kept,
40
+ * as the blob is: the first writer's headers win, as on S3 (a static's Content-Disposition names
41
+ * its uploaded file).
41
42
  */
42
43
  private createExclusive;
43
44
  beginUpload(_input: BeginUploadInput): Promise<StagedUploadTarget>;
@@ -85,12 +86,20 @@ export declare class LocalAssetStore implements AssetStore {
85
86
  */
86
87
  putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<CreateOnlyResult>;
87
88
  getMeta(hash32: string): Promise<AssetMeta | null>;
89
+ /**
90
+ * The cursor names the last item returned, and the next page starts after it in sort order, so a
91
+ * page boundary holds when that item is hidden in between: its meta is never deleted, and the
92
+ * comparison needs only its place, not its presence in the filtered list.
93
+ */
88
94
  listMeta(input?: {
89
95
  cursor?: string;
90
96
  limit?: number;
97
+ includeHidden?: boolean;
91
98
  }): Promise<{
92
- items: AssetMeta[];
99
+ items: ListedAssetMeta[];
93
100
  nextCursor?: string;
94
101
  }>;
95
- deleteMeta(hash32: string): Promise<void>;
102
+ private hiddenHashes;
103
+ hideAsset(hash32: string, marker: HiddenAssetMarker): Promise<CreateOnlyResult>;
104
+ unhideAsset(hash32: string): Promise<boolean>;
96
105
  }
@@ -8,7 +8,7 @@ import path from 'node:path';
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { getErrorMessage, isFileExistsError, isNotFoundError } from '../utils/error.js';
10
10
  import { atomicWriteFile } from '../utils/atomic-write.js';
11
- import { ASSET_PREFIXES, metaKey, originalKey, stagingKey } from './keys.js';
11
+ import { ASSET_PREFIXES, hiddenKey, metaKey, originalKey, stagingKey } from './keys.js';
12
12
  /** Matches S3AssetStore's default; local has no separate configuration knob for it. */
13
13
  const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
14
14
  const DEFAULT_LIST_LIMIT = 24;
@@ -20,6 +20,16 @@ const HEADERS_SIDECAR_SUFFIX = '.headers.json';
20
20
  const TEMP_DIR = '.asset-tmp';
21
21
  /** An original's file name after `{hash32}.`; a sidecar or any other name in the directory fails it. */
22
22
  const ORIGINAL_EXT_RE = /^[a-z0-9]+$/;
23
+ /** A hidden marker's file name; a stray file in the directory fails it. */
24
+ const HIDDEN_MARKER_RE = /^([a-f0-9]{32})\.json$/;
25
+ /** Newest first; hash32 breaks a tie of equal timestamps, so every page boundary is stable. */
26
+ function compareNewestFirst(a, b) {
27
+ if (a.uploadedAt !== b.uploadedAt)
28
+ return a.uploadedAt < b.uploadedAt ? 1 : -1;
29
+ if (a.hash32 === b.hash32)
30
+ return 0;
31
+ return a.hash32 < b.hash32 ? 1 : -1;
32
+ }
23
33
  export class LocalAssetStore {
24
34
  constructor(options) {
25
35
  this.capabilities = { directUpload: false };
@@ -95,11 +105,12 @@ export class LocalAssetStore {
95
105
  }
96
106
  }
97
107
  /**
98
- * Create-only write of a blob and its headers sidecar. Each is written whole to a temp file, then
99
- * `link()`ed into place, which fails with EEXIST rather than replacing (see docs/concurrency.md).
100
- * The sidecar is linked first, so a reader never finds a blob without its headers. A sidecar
101
- * already there was linked by an earlier writer of this key and is kept, as the blob is: the
102
- * first writer's headers win, as on S3 (a static's Content-Disposition names its uploaded file).
108
+ * Create-only write of a blob and, when `headers` is given, its headers sidecar. Each is written
109
+ * whole to a temp file, then `link()`ed into place, which fails with EEXIST rather than replacing
110
+ * (see docs/concurrency.md). The sidecar is linked first, so a reader never finds a blob without
111
+ * its headers. A sidecar already there was linked by an earlier writer of this key and is kept,
112
+ * as the blob is: the first writer's headers win, as on S3 (a static's Content-Disposition names
113
+ * its uploaded file).
103
114
  */
104
115
  async createExclusive(key, data, headers) {
105
116
  const filePath = this.resolveKey(key);
@@ -112,8 +123,10 @@ export class LocalAssetStore {
112
123
  const tempHeaders = path.join(tempDir, randomUUID());
113
124
  try {
114
125
  await fs.writeFile(tempBlob, data);
115
- await fs.writeFile(tempHeaders, JSON.stringify(headers), 'utf-8');
116
- await this.linkIfAbsent(tempHeaders, this.headersSidecarPath(filePath));
126
+ if (headers) {
127
+ await fs.writeFile(tempHeaders, JSON.stringify(headers), 'utf-8');
128
+ await this.linkIfAbsent(tempHeaders, this.headersSidecarPath(filePath));
129
+ }
117
130
  return (await this.linkIfAbsent(tempBlob, filePath)) ? 'created' : 'already-exists';
118
131
  }
119
132
  finally {
@@ -249,6 +262,11 @@ export class LocalAssetStore {
249
262
  throw err;
250
263
  }
251
264
  }
265
+ /**
266
+ * The cursor names the last item returned, and the next page starts after it in sort order, so a
267
+ * page boundary holds when that item is hidden in between: its meta is never deleted, and the
268
+ * comparison needs only its place, not its presence in the filtered list.
269
+ */
252
270
  async listMeta(input) {
253
271
  const limit = input?.limit ?? DEFAULT_LIST_LIMIT;
254
272
  const dir = this.resolveKey(ASSET_PREFIXES.meta);
@@ -269,42 +287,56 @@ export class LocalAssetStore {
269
287
  all.push(JSON.parse(raw));
270
288
  }
271
289
  catch (err) {
272
- // A meta file racing with deleteMeta is expected under concurrency;
273
- // anything else is a real problem worth surfacing via getErrorMessage.
290
+ // Only a file removed by hand since the directory read is skipped; anything else is a real
291
+ // problem worth surfacing via getErrorMessage.
274
292
  if (!isNotFoundError(err)) {
275
293
  throw new Error(`Failed to read asset meta file '${name}': ${getErrorMessage(err)}`);
276
294
  }
277
295
  }
278
296
  }
279
- // Newest first; hash32 is the deterministic tiebreak for equal timestamps
280
- // (common in fast test loops using Date.now()), which keeps pagination
281
- // stable and duplicate/gap-free across calls.
282
- all.sort((a, b) => {
283
- if (a.uploadedAt !== b.uploadedAt)
284
- return a.uploadedAt < b.uploadedAt ? 1 : -1;
285
- return a.hash32 < b.hash32 ? 1 : -1;
286
- });
297
+ all.sort(compareNewestFirst);
298
+ const hidden = await this.hiddenHashes();
299
+ const listed = input?.includeHidden
300
+ ? all.map((meta) => (hidden.has(meta.hash32) ? { ...meta, hidden: true } : meta))
301
+ : all.filter((meta) => !hidden.has(meta.hash32));
287
302
  let startIndex = 0;
288
303
  if (input?.cursor) {
289
304
  const afterHash32 = Buffer.from(input.cursor, 'base64').toString('utf-8');
290
- const idx = all.findIndex((m) => m.hash32 === afterHash32);
291
- startIndex = idx === -1 ? 0 : idx + 1;
305
+ const anchor = all.find((m) => m.hash32 === afterHash32);
306
+ if (anchor) {
307
+ const after = listed.findIndex((m) => compareNewestFirst(m, anchor) > 0);
308
+ startIndex = after === -1 ? listed.length : after;
309
+ }
292
310
  }
293
- const items = all.slice(startIndex, startIndex + limit);
294
- const hasMore = startIndex + limit < all.length;
311
+ const items = listed.slice(startIndex, startIndex + limit);
312
+ const hasMore = startIndex + limit < listed.length;
295
313
  const nextCursor = hasMore
296
314
  ? Buffer.from(items[items.length - 1].hash32, 'utf-8').toString('base64')
297
315
  : undefined;
298
316
  return { items, nextCursor };
299
317
  }
300
- async deleteMeta(hash32) {
301
- const filePath = this.resolveKey(metaKey(hash32));
318
+ async hiddenHashes() {
302
319
  try {
303
- await fs.unlink(filePath);
320
+ const names = await fs.readdir(this.resolveKey(ASSET_PREFIXES.hidden));
321
+ return new Set(names.flatMap((name) => HIDDEN_MARKER_RE.exec(name)?.[1] ?? []));
322
+ }
323
+ catch (err) {
324
+ if (isNotFoundError(err))
325
+ return new Set();
326
+ throw err;
327
+ }
328
+ }
329
+ async hideAsset(hash32, marker) {
330
+ return this.createExclusive(hiddenKey(hash32), new TextEncoder().encode(JSON.stringify(marker)));
331
+ }
332
+ async unhideAsset(hash32) {
333
+ try {
334
+ await fs.unlink(this.resolveKey(hiddenKey(hash32)));
335
+ return true;
304
336
  }
305
337
  catch (err) {
306
338
  if (isNotFoundError(err))
307
- return;
339
+ return false;
308
340
  throw err;
309
341
  }
310
342
  }
@@ -2,10 +2,10 @@
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
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`.
5
+ * six asset prefixes, plus a caller's output prefix for `copyPublicObject`.
6
6
  */
7
7
  import { type AssetPrefixes } from './keys.js';
8
- import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, PublicObject, StagedUploadTarget } from './types.js';
8
+ import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, HiddenAssetMarker, ListedAssetMeta, PublicObject, StagedUploadTarget } from './types.js';
9
9
  export interface S3AssetStoreOptions {
10
10
  bucket: string;
11
11
  region: string;
@@ -102,18 +102,33 @@ export declare class S3AssetStore implements AssetStore {
102
102
  */
103
103
  hasPublicObject(key: string): Promise<boolean>;
104
104
  listPublicObjectKeys(prefix: string): AsyncIterable<string>;
105
+ private listKeys;
105
106
  /** HEADs first: signing is local and succeeds for a missing key too. */
106
107
  presignPublicObjectRead(key: string): Promise<string | null>;
107
108
  putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<CreateOnlyResult>;
108
109
  getMeta(hash32: string): Promise<AssetMeta | null>;
110
+ /**
111
+ * One page of meta keys, filtered by a full listing of the hidden markers: few files are hidden,
112
+ * and a listing needs only `s3:ListBucket`. A hidden file's meta is fetched only for
113
+ * `includeHidden`.
114
+ */
109
115
  listMeta(input?: {
110
116
  cursor?: string;
111
117
  limit?: number;
118
+ includeHidden?: boolean;
112
119
  }): Promise<{
113
- items: AssetMeta[];
120
+ items: ListedAssetMeta[];
114
121
  nextCursor?: string;
115
122
  }>;
116
- deleteMeta(hash32: string): Promise<void>;
123
+ private listHiddenHashes;
124
+ hideAsset(hash32: string, marker: HiddenAssetMarker): Promise<CreateOnlyResult>;
125
+ /**
126
+ * Lists before deleting: a DELETE of an absent key writes a delete marker on a versioned bucket.
127
+ * The listing needs only `s3:ListBucket` covering `asset-hidden/` (a bucket-wide grant does; a
128
+ * policy scoping it by prefix must name this one), no object permission, so finalize can call
129
+ * this on every re-upload under a role that has never hidden a file.
130
+ */
131
+ unhideAsset(hash32: string): Promise<boolean>;
117
132
  private getObject;
118
133
  private getObjectBytes;
119
134
  }