@od-oneapp/storage 2026.1.1301

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 (69) hide show
  1. package/README.md +854 -0
  2. package/dist/client-next.d.mts +61 -0
  3. package/dist/client-next.d.mts.map +1 -0
  4. package/dist/client-next.mjs +111 -0
  5. package/dist/client-next.mjs.map +1 -0
  6. package/dist/client-utils-Dx6W25iz.d.mts +43 -0
  7. package/dist/client-utils-Dx6W25iz.d.mts.map +1 -0
  8. package/dist/client.d.mts +28 -0
  9. package/dist/client.d.mts.map +1 -0
  10. package/dist/client.mjs +183 -0
  11. package/dist/client.mjs.map +1 -0
  12. package/dist/env-BVHLmQdh.mjs +128 -0
  13. package/dist/env-BVHLmQdh.mjs.map +1 -0
  14. package/dist/env.mjs +3 -0
  15. package/dist/health-check-D7LnnDec.mjs +746 -0
  16. package/dist/health-check-D7LnnDec.mjs.map +1 -0
  17. package/dist/health-check-im_huJ59.d.mts +116 -0
  18. package/dist/health-check-im_huJ59.d.mts.map +1 -0
  19. package/dist/index.d.mts +60 -0
  20. package/dist/index.d.mts.map +1 -0
  21. package/dist/index.mjs +3 -0
  22. package/dist/keys.d.mts +37 -0
  23. package/dist/keys.d.mts.map +1 -0
  24. package/dist/keys.mjs +253 -0
  25. package/dist/keys.mjs.map +1 -0
  26. package/dist/server-edge.d.mts +28 -0
  27. package/dist/server-edge.d.mts.map +1 -0
  28. package/dist/server-edge.mjs +88 -0
  29. package/dist/server-edge.mjs.map +1 -0
  30. package/dist/server-next.d.mts +183 -0
  31. package/dist/server-next.d.mts.map +1 -0
  32. package/dist/server-next.mjs +1353 -0
  33. package/dist/server-next.mjs.map +1 -0
  34. package/dist/server.d.mts +70 -0
  35. package/dist/server.d.mts.map +1 -0
  36. package/dist/server.mjs +384 -0
  37. package/dist/server.mjs.map +1 -0
  38. package/dist/types.d.mts +321 -0
  39. package/dist/types.d.mts.map +1 -0
  40. package/dist/types.mjs +3 -0
  41. package/dist/validation.d.mts +101 -0
  42. package/dist/validation.d.mts.map +1 -0
  43. package/dist/validation.mjs +590 -0
  44. package/dist/validation.mjs.map +1 -0
  45. package/dist/vercel-blob-07Sx0Akn.d.mts +31 -0
  46. package/dist/vercel-blob-07Sx0Akn.d.mts.map +1 -0
  47. package/dist/vercel-blob-DA8HaYuw.mjs +158 -0
  48. package/dist/vercel-blob-DA8HaYuw.mjs.map +1 -0
  49. package/package.json +111 -0
  50. package/src/actions/blob-upload.ts +171 -0
  51. package/src/actions/index.ts +23 -0
  52. package/src/actions/mediaActions.ts +1071 -0
  53. package/src/actions/productMediaActions.ts +538 -0
  54. package/src/auth-helpers.ts +386 -0
  55. package/src/capabilities.ts +225 -0
  56. package/src/client-next.ts +184 -0
  57. package/src/client-utils.ts +292 -0
  58. package/src/client.ts +102 -0
  59. package/src/constants.ts +88 -0
  60. package/src/health-check.ts +81 -0
  61. package/src/multi-storage.ts +230 -0
  62. package/src/multipart.ts +497 -0
  63. package/src/retry-utils.test.ts +118 -0
  64. package/src/retry-utils.ts +59 -0
  65. package/src/server-edge.ts +129 -0
  66. package/src/server-next.ts +14 -0
  67. package/src/server.ts +666 -0
  68. package/src/validation.test.ts +312 -0
  69. package/src/validation.ts +827 -0
@@ -0,0 +1,386 @@
1
+ /**
2
+ * @fileoverview Authentication and Authorization Utilities for Storage Actions
3
+ *
4
+ * This module provides authentication and authorization helpers for storage server actions.
5
+ * These functions should be implemented by the consuming application to integrate with
6
+ * the authentication system.
7
+ *
8
+ * NOTE: These are placeholder functions that should be replaced with actual implementations
9
+ * from your authentication package (e.g., @od-oneapp/auth, NextAuth, etc.)
10
+ *
11
+ * @module @od-oneapp/storage/auth-helpers
12
+ */
13
+
14
+ import { logError, logWarn } from '@repo/shared/logs';
15
+
16
+ import { safeEnv } from '../env';
17
+
18
+ /**
19
+ * Retrieves the current authenticated user session
20
+ *
21
+ * This is a placeholder implementation that should be replaced with your actual
22
+ * authentication system integration (NextAuth, @od-oneapp/auth, Clerk, etc.).
23
+ *
24
+ * **Implementation Pattern:**
25
+ * ```typescript
26
+ * // Example with @od-oneapp/auth:
27
+ * const { auth } = await import('@od-oneapp/auth/server');
28
+ * return await auth();
29
+ *
30
+ * // Example with NextAuth:
31
+ * const { getServerSession } = await import('next-auth');
32
+ * return await getServerSession(authOptions);
33
+ * ```
34
+ *
35
+ * @returns Session object containing user info, or `null` if unauthenticated
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * export async function uploadMediaAction(key: string, data: Blob) {
40
+ * 'use server';
41
+ *
42
+ * const session = await getSession();
43
+ * if (!session?.user) {
44
+ * return { success: false, error: 'Unauthorized' };
45
+ * }
46
+ *
47
+ * // Proceed with authenticated upload
48
+ * const storage = getStorage();
49
+ * const result = await storage.upload(key, data);
50
+ *
51
+ * return { success: true, data: result };
52
+ * }
53
+ * ```
54
+ */
55
+ export async function getSession(): Promise<{ user: { id: string; email?: string } } | null> {
56
+ // Permissive-by-default: return null (unauthenticated)
57
+ // Consuming actions should enforce auth based on env flags
58
+ try {
59
+ // If a real auth implementation is available, prefer it (optional dynamic import pattern)
60
+ // const { auth } = await import('@od-oneapp/auth/server');
61
+ // return await auth();
62
+ return null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Checks if a user has permission to manage a specific product
70
+ *
71
+ * This is a placeholder that returns `false` by default (deny-by-default security).
72
+ * Replace with your actual authorization logic that checks:
73
+ * - Product ownership
74
+ * - Role-based permissions (admin, editor, viewer)
75
+ * - Team/organization membership
76
+ *
77
+ * **Implementation Pattern:**
78
+ * ```typescript
79
+ * // Example with database check:
80
+ * const product = await db.product.findUnique({
81
+ * where: { id: productId },
82
+ * include: { team: { members: true } }
83
+ * });
84
+ *
85
+ * return product?.ownerId === userId ||
86
+ * product?.team?.members.some(m => m.userId === userId && m.role === 'admin');
87
+ * ```
88
+ *
89
+ * @param userId - User ID to authorize
90
+ * @param productId - Product ID to check access for
91
+ * @returns `true` if user can manage the product, `false` otherwise
92
+ *
93
+ * @example
94
+ * ```typescript
95
+ * export async function deleteProductMediaAction(mediaId: string) {
96
+ * 'use server';
97
+ *
98
+ * const session = await getSession();
99
+ * if (!session?.user) {
100
+ * return { success: false, error: 'Unauthorized' };
101
+ * }
102
+ *
103
+ * const media = await db.productMedia.findUnique({ where: { id: mediaId } });
104
+ * if (!media) {
105
+ * return { success: false, error: 'Not found' };
106
+ * }
107
+ *
108
+ * const canManage = await canUserManageProduct(session.user.id, media.productId);
109
+ * if (!canManage) {
110
+ * return { success: false, error: 'Forbidden' };
111
+ * }
112
+ *
113
+ * await getStorage().delete(media.storageKey);
114
+ * return { success: true };
115
+ * }
116
+ * ```
117
+ */
118
+ export async function canUserManageProduct(_userId: string, _productId: string): Promise<boolean> {
119
+ // Deny-by-default (restrictive): returns false until replaced with actual authorization logic
120
+ // Replace with actual authorization logic in the consuming app
121
+ return false;
122
+ }
123
+
124
+ /**
125
+ * Extracts client IP address or identifier for rate limiting
126
+ *
127
+ * This placeholder returns `'unknown'`. Implement proper IP extraction based on:
128
+ * - Proxy headers (X-Forwarded-For, X-Real-IP, CF-Connecting-IP)
129
+ * - Direct connection IP
130
+ * - User ID for authenticated rate limiting
131
+ *
132
+ * **Implementation Pattern:**
133
+ * ```typescript
134
+ * export function getClientIP(_request?: Request): string {
135
+ * if (!request) return 'unknown';
136
+ *
137
+ * // Cloudflare
138
+ * const cfIP = request.headers.get('cf-connecting-ip');
139
+ * if (cfIP) return cfIP;
140
+ *
141
+ * // Behind proxy
142
+ * const forwarded = request.headers.get('x-forwarded-for');
143
+ * if (forwarded) return forwarded.split(',')[0]?.trim() || 'unknown';
144
+ *
145
+ * // Direct connection
146
+ * const realIP = request.headers.get('x-real-ip');
147
+ * return realIP || 'unknown';
148
+ * }
149
+ * ```
150
+ *
151
+ * @param request - Optional Request object to extract IP from
152
+ * @returns Client IP address or `'unknown'` if not determinable
153
+ *
154
+ * @example
155
+ * ```typescript
156
+ * export async function uploadAction(request: Request, data: FormData) {
157
+ * 'use server';
158
+ *
159
+ * const clientIP = getClientIP(request);
160
+ * const rateLimit = await checkRateLimit(
161
+ * clientIP,
162
+ * 'storage:upload',
163
+ * 100, // max requests
164
+ * 60000 // per minute
165
+ * );
166
+ *
167
+ * if (!rateLimit.allowed) {
168
+ * return {
169
+ * success: false,
170
+ * error: 'Rate limit exceeded',
171
+ * resetAt: rateLimit.resetAt
172
+ * };
173
+ * }
174
+ *
175
+ * // Process upload...
176
+ * }
177
+ * ```
178
+ */
179
+ export function getClientIP(_request?: Request): string {
180
+ // TODO: Implement IP extraction from request headers
181
+ // Example:
182
+ // if (request) {
183
+ // return request.headers.get('x-forwarded-for')?.split(',')[0] ||
184
+ // request.headers.get('x-real-ip') ||
185
+ // 'unknown';
186
+ // }
187
+
188
+ // Fallback for server actions (may need to pass request through)
189
+ return 'unknown';
190
+ }
191
+
192
+ /**
193
+ * Checks if an identifier has exceeded rate limits for a storage action
194
+ *
195
+ * This placeholder always allows requests when `STORAGE_ENABLE_RATE_LIMIT=false`.
196
+ * When enabled, implement with Redis, Upstash, or in-memory store.
197
+ *
198
+ * **Implementation Pattern (Redis):**
199
+ * ```typescript
200
+ * import { Redis } from '@upstash/redis';
201
+ *
202
+ * export async function checkRateLimit(
203
+ * identifier: string,
204
+ * action: string,
205
+ * maxRequests: number,
206
+ * windowMs: number
207
+ * ) {
208
+ * const redis = Redis.fromEnv();
209
+ * const key = `ratelimit:${action}:${identifier}`;
210
+ *
211
+ * const count = await redis.incr(key);
212
+ * if (count === 1) {
213
+ * await redis.pexpire(key, windowMs);
214
+ * }
215
+ *
216
+ * const ttl = await redis.pttl(key);
217
+ * const resetAt = new Date(Date.now() + ttl);
218
+ *
219
+ * return {
220
+ * allowed: count <= maxRequests,
221
+ * remaining: Math.max(0, maxRequests - count),
222
+ * resetAt
223
+ * };
224
+ * }
225
+ * ```
226
+ *
227
+ * @param identifier - Unique identifier (IP address, user ID, API key)
228
+ * @param action - Action key for rate limiting (e.g., `'storage:upload'`, `'storage:delete'`)
229
+ * @param maxRequests - Maximum requests allowed in the time window
230
+ * @param windowMs - Time window in milliseconds (e.g., 60000 for 1 minute)
231
+ * @returns Rate limit status with allowed flag, remaining count, and reset time
232
+ *
233
+ * @example
234
+ * ```typescript
235
+ * export async function uploadMediaAction(key: string, data: Blob) {
236
+ * 'use server';
237
+ *
238
+ * const session = await getSession();
239
+ * const identifier = session?.user.id || getClientIP();
240
+ *
241
+ * const limit = await checkRateLimit(identifier, 'storage:upload', 100, 60000);
242
+ * if (!limit.allowed) {
243
+ * return {
244
+ * success: false,
245
+ * error: `Rate limit exceeded. ${limit.remaining} requests remaining. Resets at ${limit.resetAt.toISOString()}`,
246
+ * };
247
+ * }
248
+ *
249
+ * const storage = getStorage();
250
+ * const result = await storage.upload(key, data);
251
+ * return { success: true, data: result };
252
+ * }
253
+ * ```
254
+ */
255
+ export async function checkRateLimit(
256
+ identifier: string,
257
+ action: string,
258
+ maxRequests: number,
259
+ windowMs: number,
260
+ ): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
261
+ // Check feature flag to decide behavior
262
+ const { safeEnv } = await import('../env');
263
+ const env = safeEnv();
264
+
265
+ if (!env.STORAGE_ENABLE_RATE_LIMIT) {
266
+ // Permissive: allow
267
+ return {
268
+ allowed: true,
269
+ remaining: maxRequests,
270
+ resetAt: new Date(Date.now() + windowMs),
271
+ };
272
+ }
273
+
274
+ // If enabled but no implementation, allow but with zero remaining to encourage integration
275
+ return {
276
+ allowed: true,
277
+ remaining: Math.max(0, maxRequests - 1),
278
+ resetAt: new Date(Date.now() + windowMs),
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Validate a CSRF token according to runtime enforcement settings.
284
+ *
285
+ * If the environment flag `STORAGE_ENFORCE_CSRF` is false or the environment is unavailable, validation is permissive and this function returns `true`. When enforcement is enabled, the function returns `true` only if `token` is a non-empty string.
286
+ *
287
+ * @param token - CSRF token to validate
288
+ * @param request - Optional request object that consuming applications may inspect when implementing real validation
289
+ * @returns `true` if the token is considered valid (or enforcement is disabled / env unavailable), `false` otherwise
290
+ * @deprecated Use validateCSRFOrigin() for better origin-based validation
291
+ */
292
+ export function validateCSRFToken(token: string, _request?: Request): boolean {
293
+ // Enforce behavior based on env flag
294
+ try {
295
+ const env = safeEnv();
296
+ if (!env.STORAGE_ENFORCE_CSRF) {
297
+ // Permissive-by-default: skip CSRF enforcement
298
+ return true;
299
+ }
300
+ } catch {
301
+ // If env not available, be permissive in package context
302
+ return true;
303
+ }
304
+
305
+ // If enforcement is enabled but no real validation is available, fail closed
306
+ return typeof token === 'string' && token.length > 0;
307
+ }
308
+
309
+ /**
310
+ * Validates CSRF protection by checking request origin matches host
311
+ *
312
+ * Next.js has built-in origin checking for Server Actions, but this adds
313
+ * explicit validation for defense-in-depth security.
314
+ *
315
+ * This function validates:
316
+ * - The request origin header matches the host header
317
+ * - Optional x-csrf-token header if provided
318
+ *
319
+ * @returns `true` if CSRF validation passes or is disabled, `false` if validation fails
320
+ *
321
+ * @example
322
+ * ```typescript
323
+ * export async function uploadMediaAction(...) {
324
+ * 'use server';
325
+ *
326
+ * const csrfValid = await validateCSRFOrigin();
327
+ * if (!csrfValid) {
328
+ * return { success: false, error: 'Invalid request origin' };
329
+ * }
330
+ * // ... proceed with action
331
+ * }
332
+ * ```
333
+ */
334
+ export async function validateCSRFOrigin(): Promise<boolean> {
335
+ const env = safeEnv();
336
+
337
+ if (!env.STORAGE_ENFORCE_CSRF) {
338
+ return true; // CSRF validation disabled
339
+ }
340
+
341
+ try {
342
+ const { headers } = await import('next/headers');
343
+ const headersList = await headers();
344
+ const origin = headersList.get('origin');
345
+ const host = headersList.get('host');
346
+
347
+ // Validate origin matches host
348
+ if (origin && host) {
349
+ try {
350
+ const originUrl = new URL(origin);
351
+ const expectedOrigin = `${originUrl.protocol}//${host}`;
352
+
353
+ if (origin !== expectedOrigin) {
354
+ logWarn('CSRF validation failed: origin mismatch', {
355
+ origin,
356
+ expectedOrigin,
357
+ host,
358
+ });
359
+ return false;
360
+ }
361
+ } catch (error) {
362
+ logError('CSRF validation error: invalid origin URL', {
363
+ origin,
364
+ error,
365
+ });
366
+ return false;
367
+ }
368
+ }
369
+
370
+ // Additional: Validate custom CSRF token if provided
371
+ const csrfToken = headersList.get('x-csrf-token');
372
+ if (csrfToken) {
373
+ // Basic validation - consuming apps should enhance this with session validation
374
+ // For now, just check it's a non-empty string
375
+ if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
376
+ return false;
377
+ }
378
+ }
379
+
380
+ return true;
381
+ } catch (error) {
382
+ // If headers() fails (not in Next.js context), be permissive for package usage
383
+ logWarn('CSRF validation skipped: not in Next.js context', { error });
384
+ return true;
385
+ }
386
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * @fileoverview Storage Provider Capabilities Utilities
3
+ *
4
+ * Provides utilities for checking storage provider capabilities and feature support.
5
+ * Helps determine which features are available for each provider.
6
+ *
7
+ * @module @repo/storage/capabilities
8
+ */
9
+
10
+ import { DEFAULT_STORAGE_CAPABILITIES, MULTIPART_THRESHOLDS } from './constants';
11
+
12
+ import type { StorageCapabilities, StorageProvider } from '../types';
13
+
14
+ /**
15
+ * Check if a storage provider has a specific capability
16
+ * @param provider - The storage provider to check
17
+ * @param capability - The capability to check for
18
+ * @returns True if the provider supports the capability
19
+ */
20
+ export function hasCapability(
21
+ provider: StorageProvider,
22
+ capability: keyof StorageCapabilities,
23
+ ): boolean {
24
+ const capabilities = provider.getCapabilities?.();
25
+ return capabilities?.[capability] ?? false;
26
+ }
27
+
28
+ /**
29
+ * Check if a storage provider supports multiple capabilities
30
+ * @param provider - The storage provider to check
31
+ * @param capabilities - Array of capabilities to check for
32
+ * @returns True if the provider supports all capabilities
33
+ */
34
+ export function hasAllCapabilities(
35
+ provider: StorageProvider,
36
+ capabilities: Array<keyof StorageCapabilities>,
37
+ ): boolean {
38
+ return capabilities.every(capability => hasCapability(provider, capability));
39
+ }
40
+
41
+ /**
42
+ * Check if a storage provider supports any of the specified capabilities
43
+ * @param provider - The storage provider to check
44
+ * @param capabilities - Array of capabilities to check for
45
+ * @returns True if the provider supports at least one capability
46
+ */
47
+ export function hasAnyCapability(
48
+ provider: StorageProvider,
49
+ capabilities: Array<keyof StorageCapabilities>,
50
+ ): boolean {
51
+ return capabilities.some(capability => hasCapability(provider, capability));
52
+ }
53
+
54
+ /**
55
+ * Get all capabilities supported by a storage provider
56
+ * @param provider - The storage provider to check
57
+ * @returns Object with all capabilities and their support status
58
+ */
59
+ export function getProviderCapabilities(provider: StorageProvider): StorageCapabilities {
60
+ return provider.getCapabilities?.() ?? { ...DEFAULT_STORAGE_CAPABILITIES };
61
+ }
62
+
63
+ /**
64
+ * Get a human-readable description of provider capabilities
65
+ * @param provider - The storage provider to describe
66
+ * @returns String describing the provider's capabilities
67
+ */
68
+ export function describeProviderCapabilities(provider: StorageProvider): string {
69
+ const capabilities = getProviderCapabilities(provider);
70
+ const supportedFeatures: string[] = [];
71
+ const unsupportedFeatures: string[] = [];
72
+
73
+ if (capabilities.multipart) supportedFeatures.push('multipart uploads');
74
+ else unsupportedFeatures.push('multipart uploads');
75
+
76
+ if (capabilities.presignedUrls) supportedFeatures.push('presigned URLs');
77
+ else unsupportedFeatures.push('presigned URLs');
78
+
79
+ if (capabilities.progressTracking) supportedFeatures.push('progress tracking');
80
+ else unsupportedFeatures.push('progress tracking');
81
+
82
+ if (capabilities.abortSignal) supportedFeatures.push('abort signals');
83
+ else unsupportedFeatures.push('abort signals');
84
+
85
+ if (capabilities.metadata) supportedFeatures.push('metadata');
86
+ else unsupportedFeatures.push('metadata');
87
+
88
+ if (capabilities.customDomains) supportedFeatures.push('custom domains');
89
+ else unsupportedFeatures.push('custom domains');
90
+
91
+ if (capabilities.edgeCompatible) supportedFeatures.push('edge runtime');
92
+ else unsupportedFeatures.push('edge runtime');
93
+
94
+ let description = `Provider supports: ${supportedFeatures.join(', ')}`;
95
+
96
+ if (unsupportedFeatures.length > 0) {
97
+ description += `\nProvider does not support: ${unsupportedFeatures.join(', ')}`;
98
+ }
99
+
100
+ return description;
101
+ }
102
+
103
+ /**
104
+ * Validate that a provider supports the required capabilities for an operation
105
+ * @param provider - The storage provider to validate
106
+ * @param requiredCapabilities - Capabilities required for the operation
107
+ * @throws Error if provider doesn't support required capabilities
108
+ */
109
+ export function validateProviderCapabilities(
110
+ provider: StorageProvider,
111
+ requiredCapabilities: Array<keyof StorageCapabilities>,
112
+ ): void {
113
+ const missingCapabilities = requiredCapabilities.filter(
114
+ capability => !hasCapability(provider, capability),
115
+ );
116
+
117
+ if (missingCapabilities.length > 0) {
118
+ const providerName = provider.constructor.name;
119
+ throw new Error(
120
+ `Provider ${providerName} does not support required capabilities: ${missingCapabilities.join(', ')}`,
121
+ );
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Get the best provider for a specific use case based on capabilities
127
+ * @param providers - Array of storage providers to choose from
128
+ * @param requiredCapabilities - Capabilities required for the use case
129
+ * @returns The best provider or null if none meet the requirements
130
+ */
131
+ export function getBestProvider(
132
+ providers: StorageProvider[],
133
+ requiredCapabilities: Array<keyof StorageCapabilities>,
134
+ ): StorageProvider | null {
135
+ const suitableProviders = providers.filter(provider =>
136
+ hasAllCapabilities(provider, requiredCapabilities),
137
+ );
138
+
139
+ if (suitableProviders.length === 0) {
140
+ return null;
141
+ }
142
+
143
+ // If multiple providers are suitable, prefer edge-compatible ones
144
+ const edgeCompatible = suitableProviders.filter(provider =>
145
+ hasCapability(provider, 'edgeCompatible'),
146
+ );
147
+
148
+ return edgeCompatible.length > 0 ? (edgeCompatible[0] ?? null) : (suitableProviders[0] ?? null);
149
+ }
150
+
151
+ /**
152
+ * Builds a map from provider names to their reported storage capabilities.
153
+ *
154
+ * @param providers - Array of entries each containing a `name` (used as the map key) and a `provider` instance
155
+ * @returns A mapping from each provider name to its `StorageCapabilities`
156
+ */
157
+ export function getCapabilityMatrix(
158
+ providers: Array<{ name: string; provider: StorageProvider }>,
159
+ ): Record<string, StorageCapabilities> {
160
+ const matrix: Record<string, StorageCapabilities> = {};
161
+
162
+ for (const { name, provider } of providers) {
163
+ matrix[name] = getProviderCapabilities(provider);
164
+ }
165
+
166
+ return matrix;
167
+ }
168
+
169
+ /**
170
+ * Evaluate a storage provider's suitability for a specific file and produce actionable recommendations and warnings.
171
+ *
172
+ * @param provider - The storage provider to evaluate
173
+ * @param fileSize - File size in bytes
174
+ * @param fileType - MIME type of the file
175
+ * @returns An object with `suitable` (`true` if there are no warnings, `false` otherwise), `recommendations` (suggested actions to improve handling), and `warnings` (issues that reduce suitability)
176
+ */
177
+ export function checkProviderSuitability(
178
+ provider: StorageProvider,
179
+ fileSize: number,
180
+ fileType: string,
181
+ ): {
182
+ suitable: boolean;
183
+ recommendations: string[];
184
+ warnings: string[];
185
+ } {
186
+ const capabilities = getProviderCapabilities(provider);
187
+ const recommendations: string[] = [];
188
+ const warnings: string[] = [];
189
+
190
+ // Check file size suitability
191
+ if (fileSize > MULTIPART_THRESHOLDS.SMALL_FILE) {
192
+ // > 100MB
193
+ if (!capabilities.multipart) {
194
+ warnings.push('Large file detected but provider does not support multipart uploads');
195
+ } else {
196
+ recommendations.push('Use multipart upload for this large file');
197
+ }
198
+ }
199
+
200
+ // Check file type suitability
201
+ if (fileType.startsWith('image/')) {
202
+ if (capabilities.metadata) {
203
+ recommendations.push('Consider storing image metadata for better organization');
204
+ }
205
+ }
206
+
207
+ if (fileType.startsWith('video/')) {
208
+ if (!capabilities.multipart) {
209
+ warnings.push('Video files are typically large and benefit from multipart uploads');
210
+ }
211
+ }
212
+
213
+ // Check edge compatibility
214
+ if (fileType.startsWith('text/') && !capabilities.edgeCompatible) {
215
+ recommendations.push('Text files could be processed in edge runtime for better performance');
216
+ }
217
+
218
+ const suitable = warnings.length === 0;
219
+
220
+ return {
221
+ suitable,
222
+ recommendations,
223
+ warnings,
224
+ };
225
+ }