@btst/stack 2.8.0 → 2.9.0

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 (214) hide show
  1. package/README.md +3 -2
  2. package/dist/components/markdown/index.d.cts +15 -2
  3. package/dist/components/markdown/index.d.mts +15 -2
  4. package/dist/components/markdown/index.d.ts +15 -2
  5. package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.cjs +30 -1
  6. package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.mjs +30 -1
  7. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.cjs +49 -9
  8. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.mjs +50 -10
  9. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.cjs +77 -9
  10. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.mjs +77 -9
  11. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.cjs +24 -5
  12. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.mjs +24 -5
  13. package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.cjs +47 -13
  14. package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.mjs +47 -13
  15. package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.cjs +1 -1
  16. package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.mjs +1 -1
  17. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.cjs +6 -2
  18. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.mjs +6 -2
  19. package/dist/packages/stack/src/plugins/media/api/adapters/local.cjs +55 -0
  20. package/dist/packages/stack/src/plugins/media/api/adapters/local.mjs +37 -0
  21. package/dist/packages/stack/src/plugins/media/api/getters.cjs +83 -0
  22. package/dist/packages/stack/src/plugins/media/api/getters.mjs +78 -0
  23. package/dist/packages/stack/src/plugins/media/api/mutations.cjs +88 -0
  24. package/dist/packages/stack/src/plugins/media/api/mutations.mjs +82 -0
  25. package/dist/packages/stack/src/plugins/media/api/plugin.cjs +525 -0
  26. package/dist/packages/stack/src/plugins/media/api/plugin.mjs +523 -0
  27. package/dist/packages/stack/src/plugins/media/api/query-key-defs.cjs +19 -0
  28. package/dist/packages/stack/src/plugins/media/api/query-key-defs.mjs +16 -0
  29. package/dist/packages/stack/src/plugins/media/api/serializers.cjs +17 -0
  30. package/dist/packages/stack/src/plugins/media/api/serializers.mjs +14 -0
  31. package/dist/packages/stack/src/plugins/media/api/storage-adapter.cjs +15 -0
  32. package/dist/packages/stack/src/plugins/media/api/storage-adapter.mjs +11 -0
  33. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.cjs +129 -0
  34. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.mjs +127 -0
  35. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.cjs +58 -0
  36. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.mjs +56 -0
  37. package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.cjs +94 -0
  38. package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.mjs +92 -0
  39. package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.cjs +171 -0
  40. package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.mjs +168 -0
  41. package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.cjs +308 -0
  42. package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.mjs +305 -0
  43. package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.cjs +104 -0
  44. package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.mjs +102 -0
  45. package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.cjs +70 -0
  46. package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.mjs +68 -0
  47. package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.cjs +21 -0
  48. package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.mjs +17 -0
  49. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.cjs +35 -0
  50. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.cjs +125 -0
  51. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.mjs +123 -0
  52. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.mjs +33 -0
  53. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.cjs +222 -0
  54. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.mjs +214 -0
  55. package/dist/packages/stack/src/plugins/media/client/plugin.cjs +94 -0
  56. package/dist/packages/stack/src/plugins/media/client/plugin.mjs +92 -0
  57. package/dist/packages/stack/src/plugins/media/client/upload.cjs +121 -0
  58. package/dist/packages/stack/src/plugins/media/client/upload.mjs +119 -0
  59. package/dist/packages/stack/src/plugins/media/client/utils/image-compression.cjs +67 -0
  60. package/dist/packages/stack/src/plugins/media/client/utils/image-compression.mjs +65 -0
  61. package/dist/packages/stack/src/plugins/media/db.cjs +62 -0
  62. package/dist/packages/stack/src/plugins/media/db.mjs +60 -0
  63. package/dist/packages/stack/src/plugins/media/schemas.cjs +41 -0
  64. package/dist/packages/stack/src/plugins/media/schemas.mjs +35 -0
  65. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.cjs +18 -1
  66. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.mjs +19 -2
  67. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.cjs +2 -2
  68. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.mjs +2 -2
  69. package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.cjs +3 -2
  70. package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.mjs +3 -2
  71. package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.cjs +12 -5
  72. package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.mjs +12 -5
  73. package/dist/plugins/blog/api/index.d.cts +2 -2
  74. package/dist/plugins/blog/api/index.d.mts +2 -2
  75. package/dist/plugins/blog/api/index.d.ts +2 -2
  76. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  77. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  78. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  79. package/dist/plugins/blog/client/index.d.cts +60 -3
  80. package/dist/plugins/blog/client/index.d.mts +60 -3
  81. package/dist/plugins/blog/client/index.d.ts +60 -3
  82. package/dist/plugins/blog/query-keys.d.cts +2 -2
  83. package/dist/plugins/blog/query-keys.d.mts +2 -2
  84. package/dist/plugins/blog/query-keys.d.ts +2 -2
  85. package/dist/plugins/cms/client/index.d.cts +73 -3
  86. package/dist/plugins/cms/client/index.d.mts +73 -3
  87. package/dist/plugins/cms/client/index.d.ts +73 -3
  88. package/dist/plugins/kanban/api/index.d.cts +1 -1
  89. package/dist/plugins/kanban/api/index.d.mts +1 -1
  90. package/dist/plugins/kanban/api/index.d.ts +1 -1
  91. package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
  92. package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
  93. package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
  94. package/dist/plugins/kanban/client/index.d.cts +1 -1
  95. package/dist/plugins/kanban/client/index.d.mts +1 -1
  96. package/dist/plugins/kanban/client/index.d.ts +1 -1
  97. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  98. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  99. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  100. package/dist/plugins/media/api/adapters/s3.cjs +106 -0
  101. package/dist/plugins/media/api/adapters/s3.d.cts +60 -0
  102. package/dist/plugins/media/api/adapters/s3.d.mts +60 -0
  103. package/dist/plugins/media/api/adapters/s3.d.ts +60 -0
  104. package/dist/plugins/media/api/adapters/s3.mjs +104 -0
  105. package/dist/plugins/media/api/adapters/vercel-blob.cjs +54 -0
  106. package/dist/plugins/media/api/adapters/vercel-blob.d.cts +41 -0
  107. package/dist/plugins/media/api/adapters/vercel-blob.d.mts +41 -0
  108. package/dist/plugins/media/api/adapters/vercel-blob.d.ts +41 -0
  109. package/dist/plugins/media/api/adapters/vercel-blob.mjs +52 -0
  110. package/dist/plugins/media/api/index.cjs +26 -0
  111. package/dist/plugins/media/api/index.d.cts +116 -0
  112. package/dist/plugins/media/api/index.d.mts +116 -0
  113. package/dist/plugins/media/api/index.d.ts +116 -0
  114. package/dist/plugins/media/api/index.mjs +6 -0
  115. package/dist/plugins/media/client/components/index.cjs +10 -0
  116. package/dist/plugins/media/client/components/index.d.cts +55 -0
  117. package/dist/plugins/media/client/components/index.d.mts +55 -0
  118. package/dist/plugins/media/client/components/index.d.ts +55 -0
  119. package/dist/plugins/media/client/components/index.mjs +2 -0
  120. package/dist/plugins/media/client/hooks/index.cjs +13 -0
  121. package/dist/plugins/media/client/hooks/index.d.cts +53 -0
  122. package/dist/plugins/media/client/hooks/index.d.mts +53 -0
  123. package/dist/plugins/media/client/hooks/index.d.ts +53 -0
  124. package/dist/plugins/media/client/hooks/index.mjs +1 -0
  125. package/dist/plugins/media/client/index.cjs +9 -0
  126. package/dist/plugins/media/client/index.d.cts +242 -0
  127. package/dist/plugins/media/client/index.d.mts +242 -0
  128. package/dist/plugins/media/client/index.d.ts +242 -0
  129. package/dist/plugins/media/client/index.mjs +2 -0
  130. package/dist/plugins/media/client.css +1 -0
  131. package/dist/plugins/media/query-keys.cjs +72 -0
  132. package/dist/plugins/media/query-keys.d.cts +49 -0
  133. package/dist/plugins/media/query-keys.d.mts +49 -0
  134. package/dist/plugins/media/query-keys.d.ts +49 -0
  135. package/dist/plugins/media/query-keys.mjs +70 -0
  136. package/dist/plugins/media/style.css +1 -0
  137. package/dist/shared/{stack.DOZ1EXjM.d.mts → stack.6mEHS2WH.d.mts} +3 -3
  138. package/dist/shared/{stack.DX-tQ93o.d.cts → stack.AJTXI7kw.d.cts} +3 -3
  139. package/dist/shared/{stack.DRpeDS6X.d.ts → stack.BMx2QYOK.d.ts} +25 -0
  140. package/dist/shared/stack.BUTXWiG-.d.ts +286 -0
  141. package/dist/shared/stack.C7Y9sBDg.d.mts +286 -0
  142. package/dist/shared/stack.C7vfOBmO.d.mts +63 -0
  143. package/dist/shared/stack.CAni8dnD.d.cts +63 -0
  144. package/dist/shared/stack.CLcnSF_b.d.cts +25 -0
  145. package/dist/shared/stack.CLcnSF_b.d.mts +25 -0
  146. package/dist/shared/stack.CLcnSF_b.d.ts +25 -0
  147. package/dist/shared/stack.CYSwntXC.d.ts +63 -0
  148. package/dist/shared/{stack.Jb0kQDJC.d.mts → stack.Cd6McBu1.d.mts} +25 -0
  149. package/dist/shared/stack.CoBj86jf.d.cts +109 -0
  150. package/dist/shared/stack.CoBj86jf.d.mts +109 -0
  151. package/dist/shared/stack.CoBj86jf.d.ts +109 -0
  152. package/dist/shared/{stack.BXxrFL9R.d.ts → stack.D7HSzZdG.d.ts} +5 -5
  153. package/dist/shared/{stack.DzOhpIYM.d.mts → stack.DjgpFWq3.d.cts} +5 -5
  154. package/dist/shared/{stack.BxFl46lB.d.cts → stack.DxQl8Wa1.d.cts} +25 -0
  155. package/dist/shared/{stack.BSqJrCTM.d.cts → stack.IUeyQKrm.d.mts} +5 -5
  156. package/dist/shared/{stack.VF6FhyZw.d.ts → stack.QYn-Px94.d.ts} +3 -3
  157. package/dist/shared/stack.vxskCkim.d.cts +286 -0
  158. package/package.json +115 -6
  159. package/src/plugins/blog/client/components/forms/image-field.tsx +35 -4
  160. package/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx +67 -12
  161. package/src/plugins/blog/client/components/forms/markdown-editor.tsx +106 -10
  162. package/src/plugins/blog/client/overrides.ts +58 -1
  163. package/src/plugins/cms/client/components/forms/content-form.tsx +26 -7
  164. package/src/plugins/cms/client/components/forms/file-upload.tsx +73 -15
  165. package/src/plugins/cms/client/overrides.ts +57 -2
  166. package/src/plugins/kanban/client/components/forms/board-form.tsx +1 -1
  167. package/src/plugins/kanban/client/components/forms/task-form.tsx +7 -1
  168. package/src/plugins/kanban/client/overrides.ts +25 -0
  169. package/src/plugins/media/__tests__/__stubs__/vercel-blob-server.ts +9 -0
  170. package/src/plugins/media/__tests__/getters.test.ts +274 -0
  171. package/src/plugins/media/__tests__/mutations.test.ts +299 -0
  172. package/src/plugins/media/__tests__/plugin.test.ts +752 -0
  173. package/src/plugins/media/__tests__/query-key-defs.test.ts +54 -0
  174. package/src/plugins/media/__tests__/storage-adapters.test.ts +351 -0
  175. package/src/plugins/media/api/adapters/local.ts +79 -0
  176. package/src/plugins/media/api/adapters/s3.ts +198 -0
  177. package/src/plugins/media/api/adapters/vercel-blob.ts +132 -0
  178. package/src/plugins/media/api/getters.ts +174 -0
  179. package/src/plugins/media/api/index.ts +41 -0
  180. package/src/plugins/media/api/mutations.ts +179 -0
  181. package/src/plugins/media/api/plugin.ts +855 -0
  182. package/src/plugins/media/api/query-key-defs.ts +41 -0
  183. package/src/plugins/media/api/serializers.ts +28 -0
  184. package/src/plugins/media/api/storage-adapter.ts +139 -0
  185. package/src/plugins/media/client/components/index.tsx +6 -0
  186. package/src/plugins/media/client/components/media-picker/asset-card.tsx +150 -0
  187. package/src/plugins/media/client/components/media-picker/asset-preview-button.tsx +67 -0
  188. package/src/plugins/media/client/components/media-picker/browse-tab.tsx +116 -0
  189. package/src/plugins/media/client/components/media-picker/folder-tree.tsx +188 -0
  190. package/src/plugins/media/client/components/media-picker/index.tsx +347 -0
  191. package/src/plugins/media/client/components/media-picker/upload-tab.tsx +108 -0
  192. package/src/plugins/media/client/components/media-picker/url-tab.tsx +72 -0
  193. package/src/plugins/media/client/components/media-picker/utils.ts +17 -0
  194. package/src/plugins/media/client/components/pages/library-page.internal.tsx +134 -0
  195. package/src/plugins/media/client/components/pages/library-page.tsx +42 -0
  196. package/src/plugins/media/client/hooks/index.tsx +9 -0
  197. package/src/plugins/media/client/hooks/use-media.tsx +289 -0
  198. package/src/plugins/media/client/index.ts +4 -0
  199. package/src/plugins/media/client/overrides.ts +127 -0
  200. package/src/plugins/media/client/plugin.tsx +184 -0
  201. package/src/plugins/media/client/upload.ts +171 -0
  202. package/src/plugins/media/client/utils/image-compression.ts +131 -0
  203. package/src/plugins/media/client.css +1 -0
  204. package/src/plugins/media/db.ts +62 -0
  205. package/src/plugins/media/query-keys.ts +96 -0
  206. package/src/plugins/media/schemas.ts +37 -0
  207. package/src/plugins/media/style.css +1 -0
  208. package/src/plugins/media/types.ts +26 -0
  209. package/dist/shared/{stack.BWp0hcm9.d.ts → stack.BQmuNl5p.d.cts} +3 -3
  210. package/dist/shared/{stack.BWp0hcm9.d.cts → stack.BQmuNl5p.d.mts} +3 -3
  211. package/dist/shared/{stack.BWp0hcm9.d.mts → stack.BQmuNl5p.d.ts} +3 -3
  212. package/dist/shared/{stack.BvCR4-9H.d.ts → stack.D4Cea8II.d.ts} +3 -3
  213. package/dist/shared/{stack.CWxAl9K3.d.mts → stack.HE_IvqV5.d.mts} +3 -3
  214. package/dist/shared/{stack.BOokfhZD.d.cts → stack.Rtcvl8sS.d.cts} +3 -3
@@ -0,0 +1,752 @@
1
+ import { afterEach, describe, it, expect, vi } from "vitest";
2
+ import { createMemoryAdapter } from "@btst/adapter-memory";
3
+ import type { DBAdapter as Adapter, DatabaseDefinition } from "@btst/db";
4
+ import { stack } from "../../../api";
5
+ import { mediaBackendPlugin, type MediaBackendConfig } from "../api/plugin";
6
+ import { localAdapter } from "../api/adapters/local";
7
+ import type {
8
+ DirectStorageAdapter,
9
+ S3StorageAdapter,
10
+ VercelBlobStorageAdapter,
11
+ } from "../api/storage-adapter";
12
+
13
+ type AdapterFactory = (db: DatabaseDefinition) => Adapter;
14
+
15
+ const testAdapter: AdapterFactory = (db: DatabaseDefinition): Adapter =>
16
+ createMemoryAdapter(db)({});
17
+
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ vi.clearAllMocks();
21
+ });
22
+
23
+ function createBackend(
24
+ config: Partial<MediaBackendConfig> & {
25
+ adapterFactory?: AdapterFactory;
26
+ } = {},
27
+ ) {
28
+ const { adapterFactory = testAdapter, storageAdapter, ...overrides } = config;
29
+
30
+ return stack({
31
+ basePath: "/api",
32
+ plugins: {
33
+ media: mediaBackendPlugin({
34
+ storageAdapter: storageAdapter ?? localAdapter(),
35
+ ...overrides,
36
+ }),
37
+ },
38
+ adapter: adapterFactory,
39
+ });
40
+ }
41
+
42
+ function createLocalStorageAdapter(
43
+ overrides: Partial<DirectStorageAdapter> = {},
44
+ ): DirectStorageAdapter {
45
+ return {
46
+ type: "local",
47
+ upload: vi.fn(async (_buffer, options) => ({
48
+ url: `/uploads/${options.filename}`,
49
+ })),
50
+ delete: vi.fn(async () => undefined),
51
+ ...overrides,
52
+ };
53
+ }
54
+
55
+ function createS3StorageAdapter(
56
+ urlPrefix = "https://assets.example.com",
57
+ ): S3StorageAdapter {
58
+ return {
59
+ type: "s3",
60
+ urlPrefix,
61
+ generateUploadToken: vi.fn(async (options) => {
62
+ const normalizedPrefix = urlPrefix.replace(/\/$/, "");
63
+ const key = options.folderId
64
+ ? `${options.folderId}/${options.filename}`
65
+ : options.filename;
66
+ return {
67
+ type: "presigned-url" as const,
68
+ payload: {
69
+ uploadUrl: "https://s3.example.com/upload",
70
+ publicUrl: `${normalizedPrefix}/${key}`,
71
+ key,
72
+ method: "PUT" as const,
73
+ headers: { "Content-Type": options.mimeType },
74
+ },
75
+ };
76
+ }),
77
+ delete: vi.fn(async () => undefined),
78
+ } satisfies S3StorageAdapter;
79
+ }
80
+
81
+ function createVercelBlobStorageAdapter(
82
+ overrides: Partial<VercelBlobStorageAdapter> = {},
83
+ ): VercelBlobStorageAdapter {
84
+ return {
85
+ type: "vercel-blob",
86
+ urlHostnameSuffix: ".public.blob.vercel-storage.com",
87
+ handleRequest: vi.fn(async (request, callbacks) => {
88
+ const body = (await request.json()) as {
89
+ pathname?: string;
90
+ clientPayload?: string | null;
91
+ };
92
+ const tokenOptions = await callbacks.onBeforeGenerateToken?.(
93
+ body.pathname ?? "photo.jpg",
94
+ body.clientPayload ?? null,
95
+ );
96
+ return { ok: true, tokenOptions };
97
+ }),
98
+ delete: vi.fn(async () => undefined),
99
+ ...overrides,
100
+ };
101
+ }
102
+
103
+ function createS3Backend(
104
+ config: Omit<Partial<MediaBackendConfig>, "storageAdapter"> & {
105
+ storageAdapter?: S3StorageAdapter;
106
+ adapterFactory?: AdapterFactory;
107
+ } = {},
108
+ ) {
109
+ return createBackend({
110
+ storageAdapter: config.storageAdapter ?? createS3StorageAdapter(),
111
+ allowedUrlPrefixes: config.allowedUrlPrefixes,
112
+ hooks: config.hooks,
113
+ maxFileSizeBytes: config.maxFileSizeBytes,
114
+ allowedMimeTypes: config.allowedMimeTypes,
115
+ adapterFactory: config.adapterFactory,
116
+ });
117
+ }
118
+
119
+ function createVercelBlobBackend(
120
+ config: Omit<Partial<MediaBackendConfig>, "storageAdapter"> & {
121
+ storageAdapter?: VercelBlobStorageAdapter;
122
+ adapterFactory?: AdapterFactory;
123
+ } = {},
124
+ ) {
125
+ return createBackend({
126
+ storageAdapter: config.storageAdapter ?? createVercelBlobStorageAdapter(),
127
+ allowedUrlPrefixes: config.allowedUrlPrefixes,
128
+ hooks: config.hooks,
129
+ maxFileSizeBytes: config.maxFileSizeBytes,
130
+ allowedMimeTypes: config.allowedMimeTypes,
131
+ adapterFactory: config.adapterFactory,
132
+ });
133
+ }
134
+
135
+ const createAssetRequestBody = (url: string) => ({
136
+ filename: "photo.jpg",
137
+ originalName: "Photo.jpg",
138
+ mimeType: "image/jpeg",
139
+ size: 1024,
140
+ url,
141
+ });
142
+
143
+ function createJsonRequest(
144
+ path: string,
145
+ method: string,
146
+ body?: unknown,
147
+ ): Request {
148
+ return new Request(`http://localhost${path}`, {
149
+ method,
150
+ headers: body ? { "Content-Type": "application/json" } : undefined,
151
+ body: body ? JSON.stringify(body) : undefined,
152
+ });
153
+ }
154
+
155
+ function createUploadRequest(options?: {
156
+ fileName?: string;
157
+ mimeType?: string;
158
+ content?: string;
159
+ folderId?: string;
160
+ }): Request {
161
+ const formData = new FormData();
162
+ formData.set(
163
+ "file",
164
+ new File(
165
+ [options?.content ?? "hello world"],
166
+ options?.fileName ?? "photo.jpg",
167
+ {
168
+ type: options?.mimeType ?? "image/jpeg",
169
+ },
170
+ ),
171
+ );
172
+
173
+ if (options?.folderId) {
174
+ formData.set("folderId", options.folderId);
175
+ }
176
+
177
+ return new Request("http://localhost/api/media/upload", {
178
+ method: "POST",
179
+ body: formData,
180
+ });
181
+ }
182
+
183
+ async function createFolderViaApi(
184
+ backend: ReturnType<typeof createBackend>,
185
+ input: { name: string; parentId?: string },
186
+ ) {
187
+ const response = await backend.handler(
188
+ createJsonRequest("/api/media/folders", "POST", input),
189
+ );
190
+
191
+ expect(response.ok).toBe(true);
192
+ return (await response.json()) as {
193
+ id: string;
194
+ name: string;
195
+ parentId?: string;
196
+ };
197
+ }
198
+
199
+ async function createAssetViaApi(
200
+ backend: ReturnType<typeof createBackend>,
201
+ input: ReturnType<typeof createAssetRequestBody> & {
202
+ folderId?: string;
203
+ alt?: string;
204
+ },
205
+ ) {
206
+ const response = await backend.handler(
207
+ createJsonRequest("/api/media/assets", "POST", input),
208
+ );
209
+
210
+ expect(response.ok).toBe(true);
211
+ return (await response.json()) as {
212
+ id: string;
213
+ folderId?: string;
214
+ alt?: string;
215
+ };
216
+ }
217
+
218
+ async function parseRequestBody(
219
+ request: Request,
220
+ ): Promise<Record<string, unknown> | undefined> {
221
+ const contentType = request.headers.get("content-type") ?? "";
222
+ if (contentType.includes("multipart/form-data")) {
223
+ const formData = await request.formData();
224
+ const body: Record<string, unknown> = {};
225
+ formData.forEach((value, key) => {
226
+ body[key] = value;
227
+ });
228
+ return body;
229
+ }
230
+ return undefined;
231
+ }
232
+
233
+ async function invokeEndpoint(
234
+ backend: ReturnType<typeof createBackend>,
235
+ endpointKey: string,
236
+ request: Request,
237
+ ) {
238
+ const body = await parseRequestBody(request);
239
+ return (backend.router as any).endpoints[endpointKey]({
240
+ request,
241
+ headers: request.headers,
242
+ method: request.method,
243
+ params: {},
244
+ query: {},
245
+ body,
246
+ asResponse: true,
247
+ });
248
+ }
249
+
250
+ describe("mediaBackendPlugin create-asset URL validation", () => {
251
+ it("rejects client-supplied URLs when using localAdapter by default", async () => {
252
+ const backend = createBackend();
253
+
254
+ const response = await backend.handler(
255
+ new Request("http://localhost/api/media/assets", {
256
+ method: "POST",
257
+ headers: { "Content-Type": "application/json" },
258
+ body: JSON.stringify(
259
+ createAssetRequestBody("https://evil.example/tracker.jpg"),
260
+ ),
261
+ }),
262
+ );
263
+
264
+ expect(response.status).toBe(400);
265
+ await expect(response.text()).resolves.toContain(
266
+ "Client-supplied asset URLs are not allowed with localAdapter",
267
+ );
268
+
269
+ const assets = await backend.api.media.listAssets();
270
+ expect(assets.items).toHaveLength(0);
271
+ });
272
+
273
+ it("allows localAdapter asset creation when trusted URL prefixes are explicitly configured", async () => {
274
+ const backend = createBackend({
275
+ allowedUrlPrefixes: ["https://cdn.example.com/uploads/"],
276
+ });
277
+
278
+ const response = await backend.handler(
279
+ new Request("http://localhost/api/media/assets", {
280
+ method: "POST",
281
+ headers: { "Content-Type": "application/json" },
282
+ body: JSON.stringify(
283
+ createAssetRequestBody("https://cdn.example.com/uploads/photo.jpg"),
284
+ ),
285
+ }),
286
+ );
287
+
288
+ expect(response.status).toBe(200);
289
+
290
+ const assets = await backend.api.media.listAssets();
291
+ expect(assets.items).toHaveLength(1);
292
+ expect(assets.items[0]?.url).toBe(
293
+ "https://cdn.example.com/uploads/photo.jpg",
294
+ );
295
+ });
296
+ });
297
+
298
+ describe("mediaBackendPlugin S3 URL validation", () => {
299
+ it("rejects spoofed domains for explicit allowedUrlPrefixes", async () => {
300
+ const backend = createS3Backend({
301
+ allowedUrlPrefixes: ["https://assets.example.com"],
302
+ });
303
+
304
+ const response = await backend.handler(
305
+ new Request("http://localhost/api/media/assets", {
306
+ method: "POST",
307
+ headers: { "Content-Type": "application/json" },
308
+ body: JSON.stringify(
309
+ createAssetRequestBody(
310
+ "https://assets.example.com.evil.com/payload.jpg",
311
+ ),
312
+ ),
313
+ }),
314
+ );
315
+
316
+ expect(response.status).toBe(400);
317
+ await expect(response.text()).resolves.toContain(
318
+ "URL must start with one of: https://assets.example.com",
319
+ );
320
+ });
321
+
322
+ it("rejects spoofed domains for the S3 adapter public URL prefix", async () => {
323
+ const backend = createS3Backend({
324
+ storageAdapter: createS3StorageAdapter("https://assets.example.com"),
325
+ });
326
+
327
+ const response = await backend.handler(
328
+ new Request("http://localhost/api/media/assets", {
329
+ method: "POST",
330
+ headers: { "Content-Type": "application/json" },
331
+ body: JSON.stringify(
332
+ createAssetRequestBody(
333
+ "https://assets.example.com.evil.com/payload.jpg",
334
+ ),
335
+ ),
336
+ }),
337
+ );
338
+
339
+ expect(response.status).toBe(400);
340
+ await expect(response.text()).resolves.toContain(
341
+ "URL must start with the configured S3 publicBaseUrl: https://assets.example.com",
342
+ );
343
+ });
344
+
345
+ it("accepts valid asset URLs on the configured prefix boundary", async () => {
346
+ const backend = createS3Backend({
347
+ allowedUrlPrefixes: ["https://assets.example.com/"],
348
+ });
349
+
350
+ const response = await backend.handler(
351
+ new Request("http://localhost/api/media/assets", {
352
+ method: "POST",
353
+ headers: { "Content-Type": "application/json" },
354
+ body: JSON.stringify(
355
+ createAssetRequestBody("https://assets.example.com/folder/photo.jpg"),
356
+ ),
357
+ }),
358
+ );
359
+
360
+ expect(response.ok).toBe(true);
361
+ });
362
+ });
363
+
364
+ describe("mediaBackendPlugin direct upload", () => {
365
+ it("uploads a file, creates an asset record, and associates it with a folder", async () => {
366
+ const storageAdapter = createLocalStorageAdapter({
367
+ upload: vi.fn(async () => ({ url: "/uploads/photo-123.jpg" })),
368
+ });
369
+ const backend = createBackend({ storageAdapter });
370
+ const folder = await createFolderViaApi(backend, { name: "Photos" });
371
+
372
+ const response = await invokeEndpoint(
373
+ backend,
374
+ "media_uploadDirect",
375
+ createUploadRequest({ fileName: "photo.jpg", folderId: folder.id }),
376
+ );
377
+
378
+ expect(response.ok).toBe(true);
379
+ expect(storageAdapter.upload).toHaveBeenCalledWith(
380
+ expect.any(Buffer),
381
+ expect.objectContaining({
382
+ filename: "photo.jpg",
383
+ mimeType: "image/jpeg",
384
+ folderId: folder.id,
385
+ }),
386
+ );
387
+
388
+ const asset = (await response.json()) as {
389
+ filename: string;
390
+ originalName: string;
391
+ url: string;
392
+ folderId?: string;
393
+ };
394
+ expect(asset.filename).toBe("photo-123.jpg");
395
+ expect(asset.originalName).toBe("photo.jpg");
396
+ expect(asset.url).toBe("/uploads/photo-123.jpg");
397
+ expect(asset.folderId).toBe(folder.id);
398
+
399
+ const assets = await backend.api.media.listAssets({ folderId: folder.id });
400
+ expect(assets.items).toHaveLength(1);
401
+ expect(assets.items[0]?.url).toBe("/uploads/photo-123.jpg");
402
+ });
403
+
404
+ it("cleans up the uploaded file if creating the DB record fails", async () => {
405
+ const storageAdapter = createLocalStorageAdapter({
406
+ upload: vi.fn(async () => ({ url: "/uploads/will-be-rolled-back.jpg" })),
407
+ });
408
+ const failingAdapterFactory: AdapterFactory = (db) => {
409
+ const adapter = testAdapter(db);
410
+ return {
411
+ ...adapter,
412
+ create: async (args) => {
413
+ if (args.model === "mediaAsset") {
414
+ throw new Error("DB write failed");
415
+ }
416
+ return adapter.create(args);
417
+ },
418
+ } as Adapter;
419
+ };
420
+ const backend = createBackend({
421
+ storageAdapter,
422
+ adapterFactory: failingAdapterFactory,
423
+ });
424
+
425
+ await expect(
426
+ invokeEndpoint(backend, "media_uploadDirect", createUploadRequest()),
427
+ ).rejects.toThrow("DB write failed");
428
+ expect(storageAdapter.delete).toHaveBeenCalledWith(
429
+ "/uploads/will-be-rolled-back.jpg",
430
+ );
431
+
432
+ const assets = await backend.api.media.listAssets();
433
+ expect(assets.items).toHaveLength(0);
434
+ });
435
+ });
436
+
437
+ describe("mediaBackendPlugin asset deletion", () => {
438
+ it("returns 500 and keeps the DB record when storage deletion fails", async () => {
439
+ const storageAdapter = createLocalStorageAdapter({
440
+ delete: vi.fn(async () => {
441
+ throw new Error("storage unavailable");
442
+ }),
443
+ });
444
+ const backend = createBackend({
445
+ storageAdapter,
446
+ allowedUrlPrefixes: ["https://cdn.example.com/uploads/"],
447
+ });
448
+ const asset = await createAssetViaApi(backend, {
449
+ ...createAssetRequestBody("https://cdn.example.com/uploads/photo.jpg"),
450
+ });
451
+
452
+ const response = await backend.handler(
453
+ createJsonRequest(`/api/media/assets/${asset.id}`, "DELETE"),
454
+ );
455
+
456
+ expect(response.status).toBe(500);
457
+ await expect(response.text()).resolves.toContain(
458
+ "Failed to delete file from storage",
459
+ );
460
+
461
+ const assets = await backend.api.media.listAssets();
462
+ expect(assets.items).toHaveLength(1);
463
+ expect(assets.items[0]?.id).toBe(asset.id);
464
+ });
465
+ });
466
+
467
+ describe("mediaBackendPlugin asset update route", () => {
468
+ it("updates an asset and calls onBeforeUpdateAsset", async () => {
469
+ const onBeforeUpdateAsset = vi.fn();
470
+ const backend = createBackend({
471
+ allowedUrlPrefixes: ["https://cdn.example.com/uploads/"],
472
+ hooks: { onBeforeUpdateAsset },
473
+ });
474
+ const asset = await createAssetViaApi(backend, {
475
+ ...createAssetRequestBody("https://cdn.example.com/uploads/photo.jpg"),
476
+ alt: "Before",
477
+ });
478
+ const folder = await createFolderViaApi(backend, { name: "Edited" });
479
+
480
+ const response = await backend.handler(
481
+ createJsonRequest(`/api/media/assets/${asset.id}`, "PATCH", {
482
+ alt: "After",
483
+ folderId: folder.id,
484
+ }),
485
+ );
486
+
487
+ expect(response.ok).toBe(true);
488
+ expect(onBeforeUpdateAsset).toHaveBeenCalledWith(
489
+ expect.objectContaining({ id: asset.id }),
490
+ { alt: "After", folderId: folder.id },
491
+ expect.objectContaining({
492
+ body: { alt: "After", folderId: folder.id },
493
+ params: { id: asset.id },
494
+ }),
495
+ );
496
+
497
+ const updated = (await response.json()) as {
498
+ alt?: string;
499
+ folderId?: string;
500
+ };
501
+ expect(updated.alt).toBe("After");
502
+ expect(updated.folderId).toBe(folder.id);
503
+ });
504
+
505
+ it("returns 404 when updating a missing asset", async () => {
506
+ const backend = createBackend();
507
+
508
+ const response = await backend.handler(
509
+ createJsonRequest("/api/media/assets/missing-id", "PATCH", {
510
+ alt: "Nope",
511
+ }),
512
+ );
513
+
514
+ expect(response.status).toBe(404);
515
+ await expect(response.text()).resolves.toContain("Asset not found");
516
+ });
517
+ });
518
+
519
+ describe("mediaBackendPlugin S3 upload token route", () => {
520
+ it("sanitizes the S3 key segment and validates the folder", async () => {
521
+ const storageAdapter = createS3StorageAdapter();
522
+ const backend = createS3Backend({ storageAdapter });
523
+ const folder = await createFolderViaApi(backend, { name: "Uploads" });
524
+
525
+ const response = await backend.handler(
526
+ createJsonRequest("/api/media/upload/token", "POST", {
527
+ filename: "../photo.png",
528
+ mimeType: "image/png",
529
+ size: 2048,
530
+ folderId: folder.id,
531
+ }),
532
+ );
533
+
534
+ expect(response.ok).toBe(true);
535
+ expect(storageAdapter.generateUploadToken).toHaveBeenCalledWith({
536
+ filename: "_-photo.png",
537
+ mimeType: "image/png",
538
+ size: 2048,
539
+ folderId: folder.id,
540
+ });
541
+
542
+ const token = (await response.json()) as {
543
+ payload: { key: string; publicUrl: string };
544
+ };
545
+ expect(token.payload.key).toBe(`${folder.id}/_-photo.png`);
546
+ expect(token.payload.publicUrl).toContain(`${folder.id}/_-photo.png`);
547
+ });
548
+
549
+ it("returns 404 when the requested folder does not exist", async () => {
550
+ const storageAdapter = createS3StorageAdapter();
551
+ const backend = createS3Backend({ storageAdapter });
552
+
553
+ const response = await backend.handler(
554
+ createJsonRequest("/api/media/upload/token", "POST", {
555
+ filename: "photo.png",
556
+ mimeType: "image/png",
557
+ size: 2048,
558
+ folderId: "missing-folder",
559
+ }),
560
+ );
561
+
562
+ expect(response.status).toBe(404);
563
+ await expect(response.text()).resolves.toContain("Folder not found");
564
+ expect(storageAdapter.generateUploadToken).not.toHaveBeenCalled();
565
+ });
566
+ });
567
+
568
+ describe("mediaBackendPlugin Vercel Blob route", () => {
569
+ it("passes token constraints through to the adapter callback", async () => {
570
+ const storageAdapter = createVercelBlobStorageAdapter();
571
+ const backend = createVercelBlobBackend({
572
+ storageAdapter,
573
+ allowedMimeTypes: ["image/png"],
574
+ maxFileSizeBytes: 4096,
575
+ });
576
+
577
+ const response = await invokeEndpoint(
578
+ backend,
579
+ "media_uploadVercelBlob",
580
+ createJsonRequest("/api/media/upload/vercel-blob", "POST", {
581
+ pathname: "folder/photo.png",
582
+ clientPayload: JSON.stringify({
583
+ mimeType: "image/png",
584
+ size: 512,
585
+ }),
586
+ }),
587
+ );
588
+
589
+ expect(response.ok).toBe(true);
590
+ expect(storageAdapter.handleRequest).toHaveBeenCalledTimes(1);
591
+
592
+ const body = (await response.json()) as {
593
+ tokenOptions: {
594
+ addRandomSuffix: boolean;
595
+ allowedContentTypes?: string[];
596
+ maximumSizeInBytes?: number;
597
+ };
598
+ };
599
+ expect(body.tokenOptions).toEqual({
600
+ addRandomSuffix: true,
601
+ allowedContentTypes: ["image/png"],
602
+ maximumSizeInBytes: 4096,
603
+ });
604
+ });
605
+
606
+ it("falls back safely when clientPayload is invalid JSON", async () => {
607
+ const onBeforeUpload = vi.fn();
608
+ const storageAdapter = createVercelBlobStorageAdapter();
609
+ const backend = createVercelBlobBackend({
610
+ storageAdapter,
611
+ hooks: { onBeforeUpload },
612
+ maxFileSizeBytes: 1024,
613
+ });
614
+
615
+ const response = await invokeEndpoint(
616
+ backend,
617
+ "media_uploadVercelBlob",
618
+ createJsonRequest("/api/media/upload/vercel-blob", "POST", {
619
+ pathname: "folder/photo.png",
620
+ clientPayload: "{not json",
621
+ }),
622
+ );
623
+
624
+ expect(response.ok).toBe(true);
625
+ expect(onBeforeUpload).toHaveBeenCalledWith(
626
+ {
627
+ filename: "photo.png",
628
+ mimeType: "application/octet-stream",
629
+ size: undefined,
630
+ },
631
+ expect.objectContaining({
632
+ headers: expect.any(Headers),
633
+ }),
634
+ );
635
+ });
636
+ });
637
+
638
+ describe("mediaBackendPlugin hook denial behavior", () => {
639
+ it("maps thrown hook errors to a 403 response", async () => {
640
+ const backend = createBackend({
641
+ hooks: {
642
+ onBeforeCreateFolder: () => {
643
+ throw new Error("No folders for you");
644
+ },
645
+ },
646
+ });
647
+
648
+ const response = await backend.handler(
649
+ createJsonRequest("/api/media/folders", "POST", {
650
+ name: "Denied",
651
+ }),
652
+ );
653
+
654
+ expect(response.status).toBe(403);
655
+ await expect(response.text()).resolves.toContain("No folders for you");
656
+ });
657
+
658
+ it("still denies access for old-style hooks that return false", async () => {
659
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
660
+ const backend = createBackend({
661
+ hooks: {
662
+ onBeforeListAssets: (() => false) as unknown as NonNullable<
663
+ NonNullable<MediaBackendConfig["hooks"]>["onBeforeListAssets"]
664
+ >,
665
+ },
666
+ });
667
+
668
+ const response = await backend.handler(
669
+ createJsonRequest("/api/media/assets", "GET"),
670
+ );
671
+
672
+ expect(response.status).toBe(403);
673
+ await expect(response.text()).resolves.toContain(
674
+ "Unauthorized: Cannot list assets",
675
+ );
676
+ expect(warnSpy).toHaveBeenCalled();
677
+ });
678
+ });
679
+
680
+ describe("mediaBackendPlugin folder deletion route", () => {
681
+ it("returns 409 when a descendant folder contains assets", async () => {
682
+ const backend = createBackend({
683
+ allowedUrlPrefixes: ["https://cdn.example.com/uploads/"],
684
+ });
685
+ const parent = await createFolderViaApi(backend, { name: "Parent" });
686
+ const child = await createFolderViaApi(backend, {
687
+ name: "Child",
688
+ parentId: parent.id,
689
+ });
690
+
691
+ await createAssetViaApi(backend, {
692
+ ...createAssetRequestBody("https://cdn.example.com/uploads/photo.jpg"),
693
+ folderId: child.id,
694
+ });
695
+
696
+ const response = await backend.handler(
697
+ createJsonRequest(`/api/media/folders/${parent.id}`, "DELETE"),
698
+ );
699
+
700
+ expect(response.status).toBe(409);
701
+ await expect(response.text()).resolves.toContain("Cannot delete folder");
702
+
703
+ const folders = await backend.api.media.listFolders();
704
+ expect(folders.some((folder) => folder.id === parent.id)).toBe(true);
705
+ expect(folders.some((folder) => folder.id === child.id)).toBe(true);
706
+ });
707
+ });
708
+
709
+ describe("mediaBackendPlugin adapter-specific endpoint gating", () => {
710
+ it("rejects direct uploads when using the S3 adapter", async () => {
711
+ const backend = createS3Backend();
712
+
713
+ const response = await backend.handler(createUploadRequest());
714
+
715
+ expect(response.status).toBe(400);
716
+ await expect(response.text()).resolves.toContain(
717
+ "Direct upload is only supported with the local storage adapter",
718
+ );
719
+ });
720
+
721
+ it("rejects upload token requests when using the local adapter", async () => {
722
+ const backend = createBackend();
723
+
724
+ const response = await backend.handler(
725
+ createJsonRequest("/api/media/upload/token", "POST", {
726
+ filename: "photo.jpg",
727
+ mimeType: "image/jpeg",
728
+ size: 1024,
729
+ }),
730
+ );
731
+
732
+ expect(response.status).toBe(400);
733
+ await expect(response.text()).resolves.toContain(
734
+ "Upload token endpoint is only supported with the S3 storage adapter",
735
+ );
736
+ });
737
+
738
+ it("rejects Vercel Blob requests when using the local adapter", async () => {
739
+ const backend = createBackend();
740
+
741
+ const response = await backend.handler(
742
+ createJsonRequest("/api/media/upload/vercel-blob", "POST", {
743
+ pathname: "photo.jpg",
744
+ }),
745
+ );
746
+
747
+ expect(response.status).toBe(400);
748
+ await expect(response.text()).resolves.toContain(
749
+ "Vercel Blob endpoint is only supported with the vercelBlobAdapter",
750
+ );
751
+ });
752
+ });