@open-mercato/storage-s3 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

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 (66) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js +20 -5
  3. package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js.map +2 -2
  4. package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js +7 -5
  5. package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js.map +2 -2
  6. package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js +6 -4
  7. package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js.map +2 -2
  8. package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js +70 -8
  9. package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js.map +3 -3
  10. package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js +92 -15
  11. package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
  12. package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js +145 -0
  13. package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js.map +7 -0
  14. package/dist/modules/storage_s3/di.js +6 -2
  15. package/dist/modules/storage_s3/di.js.map +2 -2
  16. package/dist/modules/storage_s3/integration.js +1 -1
  17. package/dist/modules/storage_s3/integration.js.map +1 -1
  18. package/dist/modules/storage_s3/lib/endpoint-safety.js +133 -0
  19. package/dist/modules/storage_s3/lib/endpoint-safety.js.map +7 -0
  20. package/dist/modules/storage_s3/lib/health.js +18 -1
  21. package/dist/modules/storage_s3/lib/health.js.map +2 -2
  22. package/dist/modules/storage_s3/lib/key-scope.js +4 -4
  23. package/dist/modules/storage_s3/lib/key-scope.js.map +2 -2
  24. package/dist/modules/storage_s3/lib/quota-accounting.js +24 -0
  25. package/dist/modules/storage_s3/lib/quota-accounting.js.map +7 -0
  26. package/dist/modules/storage_s3/lib/quota-recovery-queue.js +9 -0
  27. package/dist/modules/storage_s3/lib/quota-recovery-queue.js.map +7 -0
  28. package/dist/modules/storage_s3/lib/s3-driver.js +44 -15
  29. package/dist/modules/storage_s3/lib/s3-driver.js.map +2 -2
  30. package/dist/modules/storage_s3/lib/storage-service.js +126 -22
  31. package/dist/modules/storage_s3/lib/storage-service.js.map +3 -3
  32. package/dist/modules/storage_s3/workers/quota-recovery.js +70 -0
  33. package/dist/modules/storage_s3/workers/quota-recovery.js.map +7 -0
  34. package/jest.config.cjs +1 -0
  35. package/package.json +13 -6
  36. package/src/modules/storage_s3/__tests__/endpoint-safety.test.ts +71 -0
  37. package/src/modules/storage_s3/__tests__/health.test.ts +38 -0
  38. package/src/modules/storage_s3/__tests__/i18nPackageExports.test.ts +13 -0
  39. package/src/modules/storage_s3/__tests__/quota-lifecycle.test.ts +216 -0
  40. package/src/modules/storage_s3/__tests__/quota-recovery.test.ts +83 -0
  41. package/src/modules/storage_s3/__tests__/s3Driver.test.ts +23 -8
  42. package/src/modules/storage_s3/__tests__/s3ListRoute.test.ts +7 -1
  43. package/src/modules/storage_s3/__tests__/s3Routes.test.ts +26 -0
  44. package/src/modules/storage_s3/__tests__/storageService.test.ts +20 -5
  45. package/src/modules/storage_s3/__tests__/upload.test.ts +93 -0
  46. package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +22 -5
  47. package/src/modules/storage_s3/api/get/storage-providers/s3/download.ts +7 -5
  48. package/src/modules/storage_s3/api/get/storage-providers/s3/list.ts +6 -4
  49. package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +77 -6
  50. package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +101 -16
  51. package/src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].ts +150 -0
  52. package/src/modules/storage_s3/di.ts +14 -2
  53. package/src/modules/storage_s3/i18n/de.json +19 -0
  54. package/src/modules/storage_s3/i18n/en.json +19 -0
  55. package/src/modules/storage_s3/i18n/es.json +19 -0
  56. package/src/modules/storage_s3/i18n/ko.json +19 -0
  57. package/src/modules/storage_s3/i18n/pl.json +19 -0
  58. package/src/modules/storage_s3/integration.ts +1 -1
  59. package/src/modules/storage_s3/lib/endpoint-safety.ts +179 -0
  60. package/src/modules/storage_s3/lib/health.ts +18 -0
  61. package/src/modules/storage_s3/lib/key-scope.ts +4 -4
  62. package/src/modules/storage_s3/lib/quota-accounting.ts +29 -0
  63. package/src/modules/storage_s3/lib/quota-recovery-queue.ts +17 -0
  64. package/src/modules/storage_s3/lib/s3-driver.ts +65 -10
  65. package/src/modules/storage_s3/lib/storage-service.ts +131 -22
  66. package/src/modules/storage_s3/workers/quota-recovery.ts +82 -0
@@ -99,6 +99,10 @@ describe('S3StorageDriver', () => {
99
99
  new S3StorageDriver(BASE_CONFIG)
100
100
  const [[arg]] = (S3Client as jest.Mock).mock.calls
101
101
  expect(arg.credentials).toBeUndefined()
102
+ expect(arg.requestHandler).toEqual(expect.objectContaining({
103
+ connectionTimeout: 10_000,
104
+ requestTimeout: 120_000,
105
+ }))
102
106
  })
103
107
 
104
108
  it('applies forcePathStyle and custom endpoint', () => {
@@ -107,6 +111,17 @@ describe('S3StorageDriver', () => {
107
111
  expect(arg.endpoint).toBe('http://minio:9000')
108
112
  expect(arg.forcePathStyle).toBe(true)
109
113
  })
114
+
115
+ it('rejects link-local custom endpoints before constructing an S3 client', () => {
116
+ expect(() => {
117
+ new S3StorageDriver({
118
+ ...BASE_CONFIG,
119
+ endpoint: 'http://169.254.169.254/latest/meta-data/',
120
+ forcePathStyle: true,
121
+ })
122
+ }).toThrow(/private or reserved IP range/i)
123
+ expect(S3Client).not.toHaveBeenCalled()
124
+ })
110
125
  })
111
126
 
112
127
  describe('store()', () => {
@@ -198,7 +213,7 @@ describe('S3StorageDriver', () => {
198
213
 
199
214
  await expect(
200
215
  driver.read('', 'docs/org_org-victim/tenant_tenant-owned/secret.txt'),
201
- ).rejects.toThrow('S3 key is not scoped to the active tenant')
216
+ ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
202
217
 
203
218
  expect(mockSend).not.toHaveBeenCalled()
204
219
  })
@@ -229,7 +244,7 @@ describe('S3StorageDriver', () => {
229
244
 
230
245
  await expect(
231
246
  driver.read('docs', 'exports/org_o/tenant_t/file.txt'),
232
- ).rejects.toThrow('S3 key is not scoped to the requested partition')
247
+ ).rejects.toThrow('[internal] S3 key is not scoped to the requested partition')
233
248
 
234
249
  expect(mockSend).not.toHaveBeenCalled()
235
250
  })
@@ -243,7 +258,7 @@ describe('S3StorageDriver', () => {
243
258
 
244
259
  await expect(
245
260
  driver.read('', 'docs/org_org-victim/tenant_tenant-owned/org_org-owned/tenant_tenant-owned/private.txt'),
246
- ).rejects.toThrow('S3 key is not scoped to the active tenant')
261
+ ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
247
262
 
248
263
  expect(mockSend).not.toHaveBeenCalled()
249
264
  })
@@ -259,7 +274,7 @@ describe('S3StorageDriver', () => {
259
274
 
260
275
  await expect(
261
276
  driver.putObject('docs/org_org-victim/tenant_tenant-owned/file.txt', Buffer.from('secret')),
262
- ).rejects.toThrow('S3 key is not scoped to the active tenant')
277
+ ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
263
278
 
264
279
  expect(mockSend).not.toHaveBeenCalled()
265
280
  })
@@ -291,7 +306,7 @@ describe('S3StorageDriver', () => {
291
306
 
292
307
  await expect(
293
308
  driver.delete('', 'docs/org_org-owned/tenant_tenant-victim/file.txt'),
294
- ).rejects.toThrow('S3 key is not scoped to the active tenant')
309
+ ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
295
310
 
296
311
  expect(mockSend).not.toHaveBeenCalled()
297
312
  })
@@ -314,7 +329,7 @@ describe('S3StorageDriver', () => {
314
329
 
315
330
  await expect(
316
331
  driver.delete('docs', 'exports/org_o/tenant_t/file.txt'),
317
- ).rejects.toThrow('S3 key is not scoped to the requested partition')
332
+ ).rejects.toThrow('[internal] S3 key is not scoped to the requested partition')
318
333
 
319
334
  expect(mockSend).not.toHaveBeenCalled()
320
335
  })
@@ -394,7 +409,7 @@ describe('S3StorageDriver', () => {
394
409
 
395
410
  await expect(
396
411
  driver.getSignedUrl('docs/org_org-victim/tenant_tenant-owned/file.pdf', 'download'),
397
- ).rejects.toThrow('S3 key is not scoped to the active tenant')
412
+ ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
398
413
 
399
414
  expect(mockGetSignedUrl).not.toHaveBeenCalled()
400
415
  })
@@ -420,7 +435,7 @@ describe('S3StorageDriver', () => {
420
435
 
421
436
  await expect(
422
437
  driver.getSignedUrl('docs/org_shared/tenant_shared/shared.pdf', 'upload'),
423
- ).rejects.toThrow('S3 key is not scoped to the active tenant')
438
+ ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
424
439
 
425
440
  expect(mockGetSignedUrl).not.toHaveBeenCalled()
426
441
  })
@@ -9,6 +9,12 @@ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
9
9
  getAuthFromRequest: async () => ({ tenantId, orgId, sub: null }),
10
10
  }))
11
11
 
12
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
13
+ resolveTranslations: async () => ({
14
+ t: (key: string) => `translated:${key}`,
15
+ }),
16
+ }))
17
+
12
18
  jest.mock('@open-mercato/shared/lib/di/container', () => ({
13
19
  createRequestContainer: async () => ({
14
20
  resolve: (token: string) => {
@@ -69,7 +75,7 @@ describe('storage_s3 list route tenant scoping', () => {
69
75
 
70
76
  expect(response.status).toBe(403)
71
77
  await expect(response.json()).resolves.toEqual({
72
- error: 'Access denied: prefix is not scoped to this tenant.',
78
+ error: 'translated:storage_s3.errors.prefixAccessDenied',
73
79
  })
74
80
  expect(listObjectsMock).not.toHaveBeenCalled()
75
81
  })
@@ -5,6 +5,12 @@ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
5
5
  getAuthFromRequest: (...args: unknown[]) => mockGetAuthFromRequest(...args),
6
6
  }))
7
7
 
8
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
9
+ resolveTranslations: async () => ({
10
+ t: (key: string) => `translated:${key}`,
11
+ }),
12
+ }))
13
+
8
14
  const mockResolveCredentials = jest.fn()
9
15
  jest.mock('@open-mercato/shared/lib/di/container', () => ({
10
16
  createRequestContainer: jest.fn(async () => ({
@@ -93,9 +99,26 @@ describe('storage_s3 standalone route key scope', () => {
93
99
  }))
94
100
 
95
101
  expect(response.status).toBe(403)
102
+ await expect(response.json()).resolves.toEqual({
103
+ error: 'translated:storage_s3.errors.keyAccessDenied',
104
+ })
96
105
  expect(mockGetSignedUrl).not.toHaveBeenCalled()
97
106
  })
98
107
 
108
+ it('localizes invalid delete payload errors', async () => {
109
+ const response = await DELETE(new Request('http://example.test/api/storage-providers/s3/delete', {
110
+ method: 'DELETE',
111
+ headers: { 'content-type': 'application/json' },
112
+ body: JSON.stringify({}),
113
+ }))
114
+
115
+ expect(response.status).toBe(400)
116
+ await expect(response.json()).resolves.toEqual({
117
+ error: 'translated:storage_s3.errors.invalidPayload',
118
+ })
119
+ expect(mockDelete).not.toHaveBeenCalled()
120
+ })
121
+
99
122
  it('deletes shared namespace objects for an authorized storage manager', async () => {
100
123
  mockDelete.mockResolvedValueOnce(undefined)
101
124
 
@@ -116,6 +139,9 @@ describe('storage_s3 standalone route key scope', () => {
116
139
  )
117
140
 
118
141
  expect(response.status).toBe(403)
142
+ await expect(response.json()).resolves.toEqual({
143
+ error: 'translated:storage_s3.errors.keyAccessDenied',
144
+ })
119
145
  }
120
146
  expect(mockRead).not.toHaveBeenCalled()
121
147
  })
@@ -27,6 +27,21 @@ beforeEach(() => {
27
27
  })
28
28
 
29
29
  describe('createStorageService', () => {
30
+ it('preserves the configured path prefix for programmatic uploads', async () => {
31
+ mockSend.mockResolvedValueOnce({})
32
+ const service = createStorageService({ ...BASE_CONFIG, pathPrefix: 'backups/' })
33
+
34
+ const result = await service.upload({
35
+ namespace: 'docs',
36
+ fileName: 'report.pdf',
37
+ buffer: Buffer.from('pdf'),
38
+ scope: TENANT_SCOPE,
39
+ })
40
+
41
+ expect(result.key).toMatch(/^backups\/docs\/org_org-owned\/tenant_tenant-owned\//)
42
+ expect(mockSend.mock.calls[0]?.[0]?.Key).toBe(result.key)
43
+ })
44
+
30
45
  it('rejects cross-tenant downloads before calling S3', async () => {
31
46
  const service = createStorageService(BASE_CONFIG)
32
47
 
@@ -35,7 +50,7 @@ describe('createStorageService', () => {
35
50
  key: 'docs/org_org-victim/tenant_tenant-owned/file.txt',
36
51
  scope: TENANT_SCOPE,
37
52
  }),
38
- ).rejects.toThrow('S3 key is not scoped to the active tenant')
53
+ ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
39
54
 
40
55
  expect(mockSend).not.toHaveBeenCalled()
41
56
  })
@@ -70,7 +85,7 @@ describe('createStorageService', () => {
70
85
  key: 'docs/org_org-victim/tenant_tenant-owned/file.txt',
71
86
  scope: TENANT_SCOPE,
72
87
  }),
73
- ).rejects.toThrow('S3 key is not scoped to the active tenant')
88
+ ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
74
89
 
75
90
  expect(mockSend).not.toHaveBeenCalled()
76
91
  })
@@ -83,7 +98,7 @@ describe('createStorageService', () => {
83
98
  key: 'docs/org_org-owned/tenant_tenant-victim/file.txt',
84
99
  scope: TENANT_SCOPE,
85
100
  }),
86
- ).rejects.toThrow('S3 key is not scoped to the active tenant')
101
+ ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
87
102
 
88
103
  expect(mockSend).not.toHaveBeenCalled()
89
104
  })
@@ -97,7 +112,7 @@ describe('createStorageService', () => {
97
112
  operation: 'download',
98
113
  scope: TENANT_SCOPE,
99
114
  }),
100
- ).rejects.toThrow('S3 key is not scoped to the active tenant')
115
+ ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
101
116
 
102
117
  expect(mockGetSignedUrl).not.toHaveBeenCalled()
103
118
  })
@@ -111,7 +126,7 @@ describe('createStorageService', () => {
111
126
  operation: 'upload',
112
127
  scope: TENANT_SCOPE,
113
128
  }),
114
- ).rejects.toThrow('S3 key is not scoped to the active tenant')
129
+ ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
115
130
 
116
131
  expect(mockGetSignedUrl).not.toHaveBeenCalled()
117
132
  })
@@ -0,0 +1,93 @@
1
+ /** @jest-environment node */
2
+
3
+ const mockGetAuthFromRequest = jest.fn()
4
+ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
5
+ getAuthFromRequest: (...args: unknown[]) => mockGetAuthFromRequest(...args),
6
+ }))
7
+
8
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
9
+ resolveTranslations: async () => ({
10
+ t: (key: string) => `translated:${key}`,
11
+ }),
12
+ }))
13
+
14
+ const mockResolveCredentials = jest.fn()
15
+ const mockCreateRequestContainer = jest.fn(async () => ({
16
+ resolve: (key: string) => {
17
+ if (key === 'integrationCredentialsService') return { resolve: mockResolveCredentials }
18
+ throw new Error(`Unexpected dependency: ${key}`)
19
+ },
20
+ }))
21
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
22
+ createRequestContainer: () => mockCreateRequestContainer(),
23
+ }))
24
+
25
+ const mockListObjects = jest.fn()
26
+ const mockPutObject = jest.fn()
27
+ jest.mock('../lib/s3-driver', () => ({
28
+ S3StorageDriver: jest.fn().mockImplementation(() => ({
29
+ getBucket: () => 'test-bucket',
30
+ listObjects: (...args: unknown[]) => mockListObjects(...args),
31
+ putObject: (...args: unknown[]) => mockPutObject(...args),
32
+ })),
33
+ }))
34
+
35
+ import { POST } from '../api/post/storage-providers/s3/upload'
36
+
37
+ describe('storage_s3 direct upload route', () => {
38
+ const originalMaxUploadMb = process.env.OM_ATTACHMENT_MAX_UPLOAD_MB
39
+
40
+ beforeEach(() => {
41
+ jest.clearAllMocks()
42
+ mockGetAuthFromRequest.mockResolvedValue({ tenantId: 'tenant-1', orgId: 'org-1' })
43
+ mockResolveCredentials.mockResolvedValue({ bucket: 'test-bucket', region: 'eu-central-1' })
44
+ mockListObjects.mockResolvedValue({ files: [], truncated: false })
45
+ mockPutObject.mockResolvedValue(undefined)
46
+ delete process.env.OM_ATTACHMENT_MAX_UPLOAD_MB
47
+ })
48
+
49
+ afterAll(() => {
50
+ if (originalMaxUploadMb === undefined) delete process.env.OM_ATTACHMENT_MAX_UPLOAD_MB
51
+ else process.env.OM_ATTACHMENT_MAX_UPLOAD_MB = originalMaxUploadMb
52
+ })
53
+
54
+ it('rejects oversized streamed metadata without trusting content length', async () => {
55
+ process.env.OM_ATTACHMENT_MAX_UPLOAD_MB = '0.000001'
56
+ const boundary = 'oversized-key'
57
+ const body = new TextEncoder().encode([
58
+ `--${boundary}\r\nContent-Disposition: form-data; name="key"\r\n\r\n`,
59
+ 'x'.repeat(1024 * 1024),
60
+ `\r\n--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="small.txt"\r\n`,
61
+ 'Content-Type: text/plain\r\n\r\ns\r\n',
62
+ `--${boundary}--\r\n`,
63
+ ].join(''))
64
+ const request = new Request('http://example.test/api/storage-providers/s3/upload', {
65
+ method: 'POST',
66
+ headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
67
+ body,
68
+ })
69
+
70
+ expect(request.headers.get('content-length')).toBeNull()
71
+ const response = await POST(request)
72
+
73
+ expect(response.status).toBe(413)
74
+ await expect(response.json()).resolves.toEqual({
75
+ error: 'translated:storage_s3.errors.maxUploadSize',
76
+ })
77
+ expect(mockCreateRequestContainer).not.toHaveBeenCalled()
78
+ expect(mockPutObject).not.toHaveBeenCalled()
79
+ })
80
+
81
+ it('preserves valid multipart parsing for a normal upload', async () => {
82
+ const form = new FormData()
83
+ form.set('file', new File([new TextEncoder().encode('safe')], 'safe.txt', { type: 'text/plain' }))
84
+ const response = await POST(new Request('http://example.test/api/storage-providers/s3/upload', {
85
+ method: 'POST',
86
+ body: form,
87
+ }))
88
+
89
+ expect(response.status).toBe(200)
90
+ await expect(response.json()).resolves.toMatchObject({ bucket: 'test-bucket', size: 4 })
91
+ expect(mockPutObject).toHaveBeenCalledTimes(1)
92
+ })
93
+ })
@@ -3,8 +3,10 @@ import { z } from 'zod'
3
3
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
4
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
7
  import { isS3KeyAddressableByScope } from '../../../../lib/key-scope'
7
8
  import { S3StorageDriver } from '../../../../lib/s3-driver'
9
+ import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
8
10
 
9
11
  export const metadata = {
10
12
  path: '/storage-providers/s3/delete',
@@ -26,27 +28,42 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
26
28
  }
27
29
 
28
30
  export async function DELETE(req: Request) {
31
+ const { t } = await resolveTranslations()
29
32
  const auth = await getAuthFromRequest(req)
30
33
  if (!auth?.tenantId || !auth.orgId) {
31
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
34
+ return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })
32
35
  }
33
36
 
34
37
  const json = await req.json().catch(() => null)
35
38
  const parsed = requestSchema.safeParse(json)
36
39
  if (!parsed.success) {
37
- return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
40
+ return NextResponse.json({ error: t('storage_s3.errors.invalidPayload', 'Invalid payload') }, { status: 400 })
38
41
  }
39
42
 
40
43
  if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {
41
- return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })
44
+ return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })
42
45
  }
43
46
 
44
47
  const driver = await resolveDriver(auth.tenantId, auth.orgId)
45
48
  if (!driver) {
46
- return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
49
+ return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
47
50
  }
48
51
 
49
- await driver.delete('', parsed.data.key)
52
+ await (driver.deleteStrict?.('', parsed.data.key) ?? driver.delete('', parsed.data.key))
53
+ const { resolve } = await createRequestContainer()
54
+ let attachmentQuotaService: AttachmentQuotaService | null = null
55
+ try {
56
+ attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService | null
57
+ } catch {
58
+ // Backward-compatible when the attachments quota service is not registered.
59
+ }
60
+ if (attachmentQuotaService) {
61
+ await attachmentQuotaService.releaseCommittedByPath({
62
+ tenantId: auth.tenantId,
63
+ storageDriver: 's3',
64
+ storagePath: parsed.data.key,
65
+ })
66
+ }
50
67
  return new NextResponse(null, { status: 204 })
51
68
  }
52
69
 
@@ -3,6 +3,7 @@ import { z } from 'zod'
3
3
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
4
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
7
  import {
7
8
  buildAttachmentContentDisposition,
8
9
  canRenderInlineAttachment,
@@ -27,23 +28,24 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
27
28
  }
28
29
 
29
30
  export async function GET(req: Request) {
31
+ const { t } = await resolveTranslations()
30
32
  const auth = await getAuthFromRequest(req)
31
33
  if (!auth?.tenantId || !auth.orgId) {
32
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
34
+ return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })
33
35
  }
34
36
 
35
37
  const key = new URL(req.url).searchParams.get('key')
36
38
  if (!key) {
37
- return NextResponse.json({ error: 'key query param is required' }, { status: 400 })
39
+ return NextResponse.json({ error: t('storage_s3.errors.keyRequired', 'key query param is required') }, { status: 400 })
38
40
  }
39
41
 
40
42
  if (!isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)) {
41
- return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })
43
+ return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })
42
44
  }
43
45
 
44
46
  const driver = await resolveDriver(auth.tenantId, auth.orgId)
45
47
  if (!driver) {
46
- return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
48
+ return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
47
49
  }
48
50
 
49
51
  let buffer: Buffer
@@ -53,7 +55,7 @@ export async function GET(req: Request) {
53
55
  buffer = result.buffer
54
56
  contentType = result.contentType
55
57
  } catch {
56
- return NextResponse.json({ error: 'File not found' }, { status: 404 })
58
+ return NextResponse.json({ error: t('storage_s3.errors.fileNotFound', 'File not found') }, { status: 404 })
57
59
  }
58
60
 
59
61
  const fileName = sanitizeUploadedFileName(key.split('/').pop() || 'download')
@@ -3,6 +3,7 @@ import { z } from 'zod'
3
3
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
4
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
7
  import { S3StorageDriver } from '../../../../lib/s3-driver'
7
8
 
8
9
  export const metadata = {
@@ -71,29 +72,30 @@ function resolveTenantScopedPrefix(prefix: string, tenantId: string, orgId: stri
71
72
  }
72
73
 
73
74
  export async function GET(req: Request) {
75
+ const { t } = await resolveTranslations()
74
76
  const auth = await getAuthFromRequest(req)
75
77
  if (!auth?.tenantId || !auth.orgId) {
76
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
78
+ return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })
77
79
  }
78
80
 
79
81
  const params = Object.fromEntries(new URL(req.url).searchParams.entries())
80
82
  const parsed = querySchema.safeParse(params)
81
83
  if (!parsed.success) {
82
- return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })
84
+ return NextResponse.json({ error: t('storage_s3.errors.invalidQuery', 'Invalid query parameters') }, { status: 400 })
83
85
  }
84
86
 
85
87
  // Always scope list operations to the tenant namespace to prevent cross-tenant enumeration.
86
88
  const effectivePrefix = resolveTenantScopedPrefix(parsed.data.prefix, auth.tenantId, auth.orgId)
87
89
  if (effectivePrefix === null) {
88
90
  return NextResponse.json(
89
- { error: 'Access denied: prefix is not scoped to this tenant.' },
91
+ { error: t('storage_s3.errors.prefixAccessDenied', 'Access denied: prefix is not scoped to this tenant.') },
90
92
  { status: 403 },
91
93
  )
92
94
  }
93
95
 
94
96
  const driver = await resolveDriver(auth.tenantId, auth.orgId)
95
97
  if (!driver) {
96
- return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
98
+ return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
97
99
  }
98
100
 
99
101
  const result = await driver.listObjects(
@@ -3,8 +3,13 @@ import { z } from 'zod'
3
3
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
4
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
7
  import { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from '../../../../lib/key-scope'
7
8
  import { S3StorageDriver } from '../../../../lib/s3-driver'
9
+ import { createHash, randomBytes } from 'node:crypto'
10
+ import { resolveAttachmentMaxBytes } from '@open-mercato/core/modules/attachments/lib/upload-limits'
11
+ import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
12
+ import { reconcileTenantS3Objects } from '../../../../lib/quota-accounting'
8
13
 
9
14
  export const metadata = {
10
15
  path: '/storage-providers/s3/signed-url',
@@ -16,11 +21,13 @@ const requestSchema = z.object({
16
21
  operation: z.enum(['upload', 'download']),
17
22
  expiresIn: z.number().int().min(60).max(604800).optional().default(3600),
18
23
  contentType: z.string().optional(),
24
+ size: z.number().int().nonnegative().optional(),
19
25
  })
20
26
 
21
27
  const responseSchema = z.object({
22
28
  url: z.string(),
23
29
  expiresAt: z.string(),
30
+ reservationId: z.string().optional(),
24
31
  })
25
32
 
26
33
  async function resolveDriver(tenantId: string, orgId: string): Promise<S3StorageDriver | null> {
@@ -34,33 +41,97 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
34
41
  }
35
42
 
36
43
  export async function POST(req: Request) {
44
+ const { t } = await resolveTranslations()
37
45
  const auth = await getAuthFromRequest(req)
38
46
  if (!auth?.tenantId || !auth.orgId) {
39
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
47
+ return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })
40
48
  }
41
49
 
42
50
  const json = await req.json().catch(() => null)
43
51
  const parsed = requestSchema.safeParse(json)
44
52
  if (!parsed.success) {
45
- return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
53
+ return NextResponse.json({ error: t('storage_s3.errors.invalidPayload', 'Invalid payload') }, { status: 400 })
46
54
  }
47
55
 
48
- const { key, operation, expiresIn, contentType } = parsed.data
56
+ const { key, operation, expiresIn, contentType, size } = parsed.data
49
57
  const canAccessKey = operation === 'download'
50
58
  ? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)
51
59
  : isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId)
52
60
  if (!canAccessKey) {
53
- return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })
61
+ return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })
54
62
  }
55
63
 
56
64
  const driver = await resolveDriver(auth.tenantId, auth.orgId)
57
65
  if (!driver) {
58
- return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
66
+ return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
59
67
  }
60
68
 
61
- const url = await driver.getSignedUrl(key, operation, expiresIn, contentType)
62
69
  const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()
63
70
 
71
+ if (operation === 'upload') {
72
+ const { resolve } = await createRequestContainer()
73
+ let attachmentQuotaService: AttachmentQuotaService | null = null
74
+ let recoveryScheduler: ((
75
+ payload: { reservationId: string; tenantId: string; organizationId: string },
76
+ delayMs: number,
77
+ ) => Promise<void>) | null = null
78
+ try {
79
+ attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService
80
+ recoveryScheduler = resolve('storageS3QuotaRecoveryScheduler') as (
81
+ payload: { reservationId: string; tenantId: string; organizationId: string },
82
+ delayMs: number,
83
+ ) => Promise<void>
84
+ } catch {
85
+ // Fail closed below when the attachments quota service is not registered.
86
+ }
87
+ if (!attachmentQuotaService) {
88
+ return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })
89
+ }
90
+ const compatibilityToken = randomBytes(32).toString('base64url')
91
+ const reservedBytes = size ?? resolveAttachmentMaxBytes()
92
+ let reservation: Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null = null
93
+ try {
94
+ await reconcileTenantS3Objects({
95
+ driver,
96
+ quotaService: attachmentQuotaService,
97
+ tenantId: auth.tenantId,
98
+ organizationId: auth.orgId,
99
+ })
100
+ reservation = await attachmentQuotaService.reserve({
101
+ tenantId: auth.tenantId,
102
+ organizationId: auth.orgId,
103
+ bytes: reservedBytes,
104
+ source: 'storage_s3_signed',
105
+ storageDriver: 's3',
106
+ storagePath: key,
107
+ uploadTokenHash: createHash('sha256').update(compatibilityToken).digest('hex'),
108
+ ttlMs: expiresIn * 1000,
109
+ })
110
+ if (!recoveryScheduler) throw new Error('Storage quota recovery is unavailable.')
111
+ await recoveryScheduler({
112
+ reservationId: reservation.id,
113
+ tenantId: auth.tenantId,
114
+ organizationId: auth.orgId,
115
+ }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))
116
+ const url = new URL(`/api/storage-providers/s3/signed-upload/${compatibilityToken}`, req.url).toString()
117
+ return NextResponse.json({ url, expiresAt, reservationId: reservation.id })
118
+ } catch (error) {
119
+ if (reservation) {
120
+ await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
121
+ }
122
+ const code = (error as { code?: unknown })?.code
123
+ if (code === 'quota_exceeded') {
124
+ return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })
125
+ }
126
+ if (code === 'quota_target_exists') {
127
+ return NextResponse.json({ error: 'The target storage key already exists.' }, { status: 409 })
128
+ }
129
+ return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })
130
+ }
131
+ }
132
+
133
+ const url = await driver.getSignedUrl(key, operation, expiresIn, contentType)
134
+
64
135
  return NextResponse.json({ url, expiresAt })
65
136
  }
66
137