@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
@@ -3,18 +3,23 @@ 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
  detectAttachmentMimeType,
8
9
  hasDangerousExecutableExtension,
9
10
  isActiveContentAttachment,
10
11
  } from '@open-mercato/core/modules/attachments/lib/security'
11
12
  import {
13
+ isMultipartUploadLimitError,
12
14
  isMultipartRequestWithinUploadLimit,
15
+ parseMultipartFormDataWithinUploadLimit,
13
16
  resolveAttachmentMaxBytes,
14
17
  willExceedAttachmentTenantQuota,
15
18
  } from '@open-mercato/core/modules/attachments/lib/upload-limits'
16
19
  import { isS3KeyScopedToTenant } from '../../../../lib/key-scope'
17
20
  import { S3StorageDriver } from '../../../../lib/s3-driver'
21
+ import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
22
+ import { reconcileTenantS3Objects } from '../../../../lib/quota-accounting'
18
23
  import { randomUUID } from 'crypto'
19
24
 
20
25
  export const metadata = {
@@ -68,66 +73,146 @@ async function readTenantStorageUsageBytes(
68
73
  }
69
74
 
70
75
  export async function POST(req: Request) {
76
+ const { t } = await resolveTranslations()
71
77
  const auth = await getAuthFromRequest(req)
72
78
  if (!auth?.tenantId || !auth.orgId) {
73
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
79
+ return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })
74
80
  }
75
81
 
76
82
  if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {
77
- return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })
83
+ return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })
78
84
  }
79
85
 
80
86
  const contentType = req.headers.get('content-type') ?? ''
81
87
  if (!contentType.includes('multipart/form-data')) {
82
- return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 })
88
+ return NextResponse.json({ error: t('storage_s3.errors.multipartRequired', 'Expected multipart/form-data') }, { status: 400 })
83
89
  }
84
90
 
85
- const form = await req.formData()
91
+ let form: FormData
92
+ try {
93
+ form = await parseMultipartFormDataWithinUploadLimit(req)
94
+ } catch (error) {
95
+ if (isMultipartUploadLimitError(error)) {
96
+ return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })
97
+ }
98
+ throw error
99
+ }
86
100
  const file = form.get('file') as File | null
87
101
  if (!file) {
88
- return NextResponse.json({ error: 'file field is required' }, { status: 400 })
102
+ return NextResponse.json({ error: t('storage_s3.errors.fileRequired', 'file field is required') }, { status: 400 })
89
103
  }
90
104
 
91
105
  const keyOverride = form.get('key') ? String(form.get('key')) : null
92
106
 
93
107
  if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {
94
108
  return NextResponse.json(
95
- { error: 'Access denied: key override is not scoped to this tenant.' },
109
+ { error: t('storage_s3.errors.keyOverrideAccessDenied', 'Access denied: key override is not scoped to this tenant.') },
96
110
  { status: 403 },
97
111
  )
98
112
  }
99
113
 
100
114
  if (hasDangerousExecutableExtension(file.name)) {
101
- return NextResponse.json({ error: 'Executable file types are not allowed as attachments.' }, { status: 400 })
115
+ return NextResponse.json({ error: t('storage_s3.errors.dangerousExecutable', 'Executable file types are not allowed as attachments.') }, { status: 400 })
102
116
  }
103
117
 
104
118
  const effectiveMaxBytes = resolveAttachmentMaxBytes()
105
119
  if (file.size > effectiveMaxBytes) {
106
- return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })
120
+ return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })
107
121
  }
108
122
 
109
123
  const driver = await resolveDriver(auth.tenantId, auth.orgId)
110
124
  if (!driver) {
111
- return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
125
+ return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
112
126
  }
113
127
 
114
128
  const buffer = Buffer.from(await file.arrayBuffer())
115
129
  const safeName = sanitizeFileName(file.name)
116
130
  const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null)
117
131
  if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {
118
- return NextResponse.json({ error: 'Active content uploads are not allowed.' }, { status: 400 })
119
- }
120
-
121
- const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId)
122
- if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {
123
- return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })
132
+ return NextResponse.json({ error: t('storage_s3.errors.activeContentBlocked', 'Active content uploads are not allowed.') }, { status: 400 })
124
133
  }
125
134
 
126
135
  const key =
127
136
  keyOverride ??
128
137
  `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`
129
138
 
130
- await driver.putObject(key, buffer, trustedMimeType)
139
+ const { resolve } = await createRequestContainer()
140
+ let attachmentQuotaService: AttachmentQuotaService | null = null
141
+ let recoveryScheduler: ((
142
+ payload: { reservationId: string; tenantId: string; organizationId: string },
143
+ delayMs: number,
144
+ ) => Promise<void>) | null = null
145
+ try {
146
+ attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService
147
+ recoveryScheduler = resolve('storageS3QuotaRecoveryScheduler') as (
148
+ payload: { reservationId: string; tenantId: string; organizationId: string },
149
+ delayMs: number,
150
+ ) => Promise<void>
151
+ } catch {
152
+ // Backward-compatible fallback below when the attachments quota service is not registered.
153
+ }
154
+ let reservation: { id: string; leaseToken: string; expiresAt: Date } | null = null
155
+ if (attachmentQuotaService) {
156
+ try {
157
+ await reconcileTenantS3Objects({
158
+ driver,
159
+ quotaService: attachmentQuotaService,
160
+ tenantId: auth.tenantId,
161
+ organizationId: auth.orgId,
162
+ })
163
+ reservation = await attachmentQuotaService.reserve({
164
+ tenantId: auth.tenantId,
165
+ organizationId: auth.orgId,
166
+ bytes: buffer.length,
167
+ source: 'storage_s3_upload',
168
+ storageDriver: 's3',
169
+ storagePath: key,
170
+ })
171
+ if (!recoveryScheduler) throw new Error('Storage quota recovery is unavailable.')
172
+ await recoveryScheduler({
173
+ reservationId: reservation.id,
174
+ tenantId: auth.tenantId,
175
+ organizationId: auth.orgId,
176
+ }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))
177
+ await attachmentQuotaService.beginStorage(reservation.id, reservation.leaseToken)
178
+ } catch (error) {
179
+ if (reservation) {
180
+ await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
181
+ reservation = null
182
+ }
183
+ const code = (error as { code?: unknown })?.code
184
+ if (code === 'quota_exceeded') {
185
+ return NextResponse.json({ error: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })
186
+ }
187
+ if (code === 'quota_target_exists') {
188
+ return NextResponse.json({ error: t('storage_s3.errors.quotaTargetExists', 'The target storage key already exists.') }, { status: 409 })
189
+ }
190
+ return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', 'Storage quota accounting is unavailable.') }, { status: 500 })
191
+ }
192
+ } else {
193
+ const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId)
194
+ if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {
195
+ return NextResponse.json({ error: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })
196
+ }
197
+ }
198
+
199
+ try {
200
+ await driver.putObject(key, buffer, trustedMimeType)
201
+ if (reservation) {
202
+ await attachmentQuotaService!.markStored(reservation.id, reservation.leaseToken)
203
+ await attachmentQuotaService!.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)
204
+ }
205
+ } catch (error) {
206
+ if (reservation) {
207
+ try {
208
+ await driver.deleteStrict('', key)
209
+ await attachmentQuotaService!.release(reservation.id, reservation.leaseToken)
210
+ } catch {
211
+ // Retain the reservation when object absence cannot be proven.
212
+ }
213
+ }
214
+ return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })
215
+ }
131
216
 
132
217
  return NextResponse.json({
133
218
  key,
@@ -0,0 +1,150 @@
1
+ import { createHash } from 'node:crypto'
2
+ import path from 'node:path'
3
+ import { NextResponse } from 'next/server'
4
+ import { z } from 'zod'
5
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
6
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
+ import {
8
+ detectAttachmentMimeType,
9
+ hasDangerousExecutableExtension,
10
+ isActiveContentAttachment,
11
+ } from '@open-mercato/core/modules/attachments/lib/security'
12
+ import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
13
+ import { S3StorageDriver } from '../../../../../lib/s3-driver'
14
+
15
+ export const metadata = {
16
+ path: '/storage-providers/s3/signed-upload/:token',
17
+ PUT: { requireAuth: false },
18
+ }
19
+
20
+ type RouteContext = { params?: Promise<{ token?: string }> | { token?: string } }
21
+
22
+ class UploadBodyTooLargeError extends Error {}
23
+
24
+ async function readBoundedBody(req: Request, maxBytes: number): Promise<Buffer> {
25
+ if (!req.body) return Buffer.alloc(0)
26
+ const reader = req.body.getReader()
27
+ const chunks: Buffer[] = []
28
+ let totalBytes = 0
29
+ try {
30
+ while (true) {
31
+ const { done, value } = await reader.read()
32
+ if (done) break
33
+ totalBytes += value.byteLength
34
+ if (totalBytes > maxBytes) {
35
+ await reader.cancel()
36
+ throw new UploadBodyTooLargeError('Attachment exceeds the reserved upload size.')
37
+ }
38
+ chunks.push(Buffer.from(value))
39
+ }
40
+ } finally {
41
+ reader.releaseLock()
42
+ }
43
+ return Buffer.concat(chunks, totalBytes)
44
+ }
45
+
46
+ export async function PUT(req: Request, ctx: RouteContext) {
47
+ const params = ctx.params instanceof Promise ? await ctx.params : ctx.params
48
+ const token = params?.token?.trim()
49
+ if (!token) return NextResponse.json({ error: 'Upload token is required.' }, { status: 400 })
50
+
51
+ const { resolve } = await createRequestContainer()
52
+ let quotaService: AttachmentQuotaService | null = null
53
+ try {
54
+ quotaService = resolve('attachmentQuotaService') as AttachmentQuotaService
55
+ } catch {
56
+ // Fail closed below when the attachments quota service is not registered.
57
+ }
58
+ if (!quotaService) return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })
59
+
60
+ const tokenHash = createHash('sha256').update(token).digest('hex')
61
+ const reservation = await quotaService.claimPendingByUploadTokenHash(tokenHash)
62
+ if (!reservation || reservation.source !== 'storage_s3_signed') {
63
+ return NextResponse.json({ error: 'Upload token is invalid or expired.' }, { status: 410 })
64
+ }
65
+
66
+ const declaredLengthHeader = req.headers.get('content-length')
67
+ const declaredLength = declaredLengthHeader == null ? null : Number(declaredLengthHeader)
68
+ if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > reservation.reservedBytes) {
69
+ await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
70
+ return NextResponse.json({ error: 'Attachment exceeds the reserved upload size.' }, { status: 413 })
71
+ }
72
+ let buffer: Buffer
73
+ try {
74
+ buffer = await readBoundedBody(req, reservation.reservedBytes)
75
+ } catch (error) {
76
+ await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
77
+ if (error instanceof UploadBodyTooLargeError) {
78
+ return NextResponse.json({ error: error.message }, { status: 413 })
79
+ }
80
+ return NextResponse.json({ error: 'Failed to read attachment.' }, { status: 400 })
81
+ }
82
+ if (buffer.length > reservation.reservedBytes) {
83
+ await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
84
+ return NextResponse.json({ error: 'Attachment exceeds the reserved upload size.' }, { status: 413 })
85
+ }
86
+ const fileName = path.posix.basename(reservation.storagePath)
87
+ if (hasDangerousExecutableExtension(fileName)) {
88
+ await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
89
+ return NextResponse.json({ error: 'Executable file types are not allowed as attachments.' }, { status: 400 })
90
+ }
91
+ const contentType = detectAttachmentMimeType(buffer, fileName, req.headers.get('content-type'))
92
+ if (isActiveContentAttachment(buffer, fileName, contentType)) {
93
+ await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
94
+ return NextResponse.json({ error: 'Active content uploads are not allowed.' }, { status: 400 })
95
+ }
96
+
97
+ const credentialsService = resolve('integrationCredentialsService') as {
98
+ resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>
99
+ }
100
+ const credentials = await credentialsService.resolve('storage_s3', {
101
+ tenantId: reservation.tenantId,
102
+ organizationId: reservation.organizationId,
103
+ })
104
+ if (!credentials) {
105
+ await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
106
+ return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
107
+ }
108
+
109
+ const driver = new S3StorageDriver({
110
+ ...credentials,
111
+ organizationId: reservation.organizationId,
112
+ tenantId: reservation.tenantId,
113
+ })
114
+ let objectStored = false
115
+ try {
116
+ await driver.putObject(reservation.storagePath, buffer, contentType)
117
+ objectStored = true
118
+ await quotaService.markStored(reservation.id, reservation.leaseToken)
119
+ await quotaService.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)
120
+ return new NextResponse(null, { status: 200 })
121
+ } catch (error) {
122
+ if (objectStored) {
123
+ try {
124
+ await driver.deleteStrict('', reservation.storagePath)
125
+ await quotaService.release(reservation.id, reservation.leaseToken)
126
+ } catch {
127
+ // Retain the reservation when object absence cannot be proven.
128
+ }
129
+ }
130
+ return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })
131
+ }
132
+ }
133
+
134
+ export default PUT
135
+
136
+ export const openApi: OpenApiRouteDoc = {
137
+ tag: 'Storage',
138
+ summary: 'Upload through a bounded one-time compatibility URL',
139
+ methods: {
140
+ PUT: {
141
+ summary: 'Upload an object using a bounded one-time token',
142
+ responses: [{ status: 200, description: 'Upload completed', schema: z.any() }],
143
+ errors: [
144
+ { status: 400, description: 'Invalid upload', schema: z.object({ error: z.string() }) },
145
+ { status: 410, description: 'Token invalid or expired', schema: z.object({ error: z.string() }) },
146
+ { status: 413, description: 'Upload exceeds reserved size', schema: z.object({ error: z.string() }) },
147
+ ],
148
+ },
149
+ },
150
+ }
@@ -6,8 +6,10 @@ import {
6
6
  } from '@open-mercato/core/modules/attachments/lib/drivers'
7
7
  import { S3StorageDriver } from './lib/s3-driver'
8
8
  import { createStorageService } from './lib/storage-service'
9
+ import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
9
10
  import { s3HealthCheck } from './lib/health'
10
11
  import { createLogger } from '@open-mercato/shared/lib/logger'
12
+ import { scheduleStorageS3QuotaRecovery } from './lib/quota-recovery-queue'
11
13
 
12
14
  const logger = createLogger('storage_s3')
13
15
 
@@ -67,14 +69,22 @@ export function register(container: AppContainer) {
67
69
 
68
70
  container.register({
69
71
  s3HealthCheck: asValue(s3HealthCheck),
72
+ storageS3QuotaRecoveryScheduler: asValue(scheduleStorageS3QuotaRecovery),
70
73
  storageService: asFunction(
71
- ({ integrationCredentialsService }: { integrationCredentialsService: IntegrationCredentialsService }) => {
74
+ ({ integrationCredentialsService, attachmentQuotaService, storageS3QuotaRecoveryScheduler }: {
75
+ integrationCredentialsService: IntegrationCredentialsService
76
+ attachmentQuotaService: AttachmentQuotaService
77
+ storageS3QuotaRecoveryScheduler: (
78
+ payload: { reservationId: string; tenantId: string; organizationId: string },
79
+ delayMs: number,
80
+ ) => Promise<void>
81
+ }) => {
72
82
  // StorageService factory — builds the service lazily using credentials
73
83
  // resolved from the Integration Marketplace per request.
74
84
  return {
75
85
  async _resolveService(scope: { tenantId: string; organizationId: string }) {
76
86
  const creds = await integrationCredentialsService.resolve('storage_s3', scope)
77
- if (!creds) throw new Error('S3 storage integration is not configured for this tenant.')
87
+ if (!creds) throw new Error('[internal] S3 storage integration is not configured for this tenant.')
78
88
  return createStorageService({
79
89
  authMode: creds.authMode === 'ambient' || creds.authMode === 'access_keys'
80
90
  ? (creds.authMode as 'ambient' | 'access_keys')
@@ -86,6 +96,8 @@ export function register(container: AppContainer) {
86
96
  accessKeyId: creds.accessKeyId ? String(creds.accessKeyId) : undefined,
87
97
  secretAccessKey: creds.secretAccessKey ? String(creds.secretAccessKey) : undefined,
88
98
  sessionToken: creds.sessionToken ? String(creds.sessionToken) : undefined,
99
+ quotaService: attachmentQuotaService,
100
+ quotaRecoveryScheduler: storageS3QuotaRecoveryScheduler,
89
101
  organizationId: scope.organizationId,
90
102
  tenantId: scope.tenantId,
91
103
  // Credentials are resolved from the Integration Marketplace (encrypted at rest)
@@ -0,0 +1,19 @@
1
+ {
2
+ "storage_s3.errors.activeContentBlocked": "Das Hochladen aktiver Inhalte ist nicht erlaubt.",
3
+ "storage_s3.errors.dangerousExecutable": "Ausführbare Dateitypen sind als Anhänge nicht erlaubt.",
4
+ "storage_s3.errors.fileNotFound": "Datei nicht gefunden",
5
+ "storage_s3.errors.fileRequired": "Das Feld „file“ ist erforderlich",
6
+ "storage_s3.errors.integrationNotConfigured": "Die S3-Integration ist nicht konfiguriert.",
7
+ "storage_s3.errors.invalidPayload": "Ungültige Anfragedaten",
8
+ "storage_s3.errors.invalidQuery": "Ungültige Abfrageparameter",
9
+ "storage_s3.errors.keyAccessDenied": "Zugriff verweigert: Der Schlüssel ist nicht auf diesen Tenant beschränkt.",
10
+ "storage_s3.errors.keyOverrideAccessDenied": "Zugriff verweigert: Die Schlüsselüberschreibung ist nicht auf diesen Tenant beschränkt.",
11
+ "storage_s3.errors.keyRequired": "Der Abfrageparameter „key“ ist erforderlich",
12
+ "storage_s3.errors.maxUploadSize": "Der Anhang überschreitet die maximal zulässige Upload-Größe.",
13
+ "storage_s3.errors.multipartRequired": "multipart/form-data wurde erwartet",
14
+ "storage_s3.errors.prefixAccessDenied": "Zugriff verweigert: Das Präfix ist nicht auf diesen Tenant beschränkt.",
15
+ "storage_s3.errors.quotaExceeded": "Das Speicherkontingent für Anhänge dieses Mandanten ist erschöpft.",
16
+ "storage_s3.errors.quotaTargetExists": "Der Ziel-Speicherschlüssel existiert bereits.",
17
+ "storage_s3.errors.quotaUnavailable": "Die Kontingentabrechnung des Speichers ist nicht verfügbar.",
18
+ "storage_s3.errors.unauthorized": "Nicht autorisiert"
19
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "storage_s3.errors.activeContentBlocked": "Active content uploads are not allowed.",
3
+ "storage_s3.errors.dangerousExecutable": "Executable file types are not allowed as attachments.",
4
+ "storage_s3.errors.fileNotFound": "File not found",
5
+ "storage_s3.errors.fileRequired": "file field is required",
6
+ "storage_s3.errors.integrationNotConfigured": "S3 integration is not configured.",
7
+ "storage_s3.errors.invalidPayload": "Invalid payload",
8
+ "storage_s3.errors.invalidQuery": "Invalid query parameters",
9
+ "storage_s3.errors.keyAccessDenied": "Access denied: key is not scoped to this tenant.",
10
+ "storage_s3.errors.keyOverrideAccessDenied": "Access denied: key override is not scoped to this tenant.",
11
+ "storage_s3.errors.keyRequired": "key query param is required",
12
+ "storage_s3.errors.maxUploadSize": "Attachment exceeds the maximum upload size.",
13
+ "storage_s3.errors.multipartRequired": "Expected multipart/form-data",
14
+ "storage_s3.errors.prefixAccessDenied": "Access denied: prefix is not scoped to this tenant.",
15
+ "storage_s3.errors.quotaExceeded": "Attachment storage quota exceeded for this tenant.",
16
+ "storage_s3.errors.quotaTargetExists": "The target storage key already exists.",
17
+ "storage_s3.errors.quotaUnavailable": "Storage quota accounting is unavailable.",
18
+ "storage_s3.errors.unauthorized": "Unauthorized"
19
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "storage_s3.errors.activeContentBlocked": "No se permite la carga de contenido activo.",
3
+ "storage_s3.errors.dangerousExecutable": "No se permiten archivos ejecutables como adjuntos.",
4
+ "storage_s3.errors.fileNotFound": "Archivo no encontrado",
5
+ "storage_s3.errors.fileRequired": "El campo file es obligatorio",
6
+ "storage_s3.errors.integrationNotConfigured": "La integración de S3 no está configurada.",
7
+ "storage_s3.errors.invalidPayload": "Contenido de solicitud no válido",
8
+ "storage_s3.errors.invalidQuery": "Parámetros de consulta no válidos",
9
+ "storage_s3.errors.keyAccessDenied": "Acceso denegado: la clave no está limitada a este tenant.",
10
+ "storage_s3.errors.keyOverrideAccessDenied": "Acceso denegado: la sobrescritura de la clave no está limitada a este tenant.",
11
+ "storage_s3.errors.keyRequired": "Se requiere el parámetro de consulta key",
12
+ "storage_s3.errors.maxUploadSize": "El archivo adjunto supera el tamaño máximo de carga permitido.",
13
+ "storage_s3.errors.multipartRequired": "Se esperaba multipart/form-data",
14
+ "storage_s3.errors.prefixAccessDenied": "Acceso denegado: el prefijo no está limitado a este tenant.",
15
+ "storage_s3.errors.quotaExceeded": "Se superó la cuota de almacenamiento de adjuntos para este inquilino.",
16
+ "storage_s3.errors.quotaTargetExists": "La clave de almacenamiento de destino ya existe.",
17
+ "storage_s3.errors.quotaUnavailable": "La contabilidad de la cuota de almacenamiento no está disponible.",
18
+ "storage_s3.errors.unauthorized": "No autorizado"
19
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "storage_s3.errors.activeContentBlocked": "활성 콘텐츠 업로드는 허용되지 않습니다.",
3
+ "storage_s3.errors.dangerousExecutable": "실행 파일 형식은 첨부가 허용되지 않습니다.",
4
+ "storage_s3.errors.fileNotFound": "파일을 찾을 수 없습니다",
5
+ "storage_s3.errors.fileRequired": "파일 필드는 필수입니다",
6
+ "storage_s3.errors.integrationNotConfigured": "S3 통합이 구성되지 않았습니다.",
7
+ "storage_s3.errors.invalidPayload": "잘못된 페이로드",
8
+ "storage_s3.errors.invalidQuery": "잘못된 쿼리 매개변수",
9
+ "storage_s3.errors.keyAccessDenied": "액세스 거부됨: 키 범위가 이 테넌트로 지정되지 않았습니다.",
10
+ "storage_s3.errors.keyOverrideAccessDenied": "액세스 거부됨: 키 재정의 범위가 이 테넌트로 지정되지 않았습니다.",
11
+ "storage_s3.errors.keyRequired": "주요 쿼리 매개변수가 필요합니다.",
12
+ "storage_s3.errors.maxUploadSize": "첨부파일이 최대 업로드 크기를 초과합니다.",
13
+ "storage_s3.errors.multipartRequired": "예상되는 멀티파트/양식 데이터",
14
+ "storage_s3.errors.prefixAccessDenied": "액세스 거부됨: 접두사의 범위가 이 테넌트로 지정되지 않았습니다.",
15
+ "storage_s3.errors.quotaExceeded": "이 테넌트의 첨부 파일 저장 용량 한도를 초과했습니다.",
16
+ "storage_s3.errors.quotaTargetExists": "대상 저장 키가 이미 존재합니다.",
17
+ "storage_s3.errors.quotaUnavailable": "저장 용량 정산을 사용할 수 없습니다.",
18
+ "storage_s3.errors.unauthorized": "승인되지 않은"
19
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "storage_s3.errors.activeContentBlocked": "Przesyłanie treści aktywnych nie jest dozwolone.",
3
+ "storage_s3.errors.dangerousExecutable": "Pliki wykonywalne nie są dozwolone jako załączniki.",
4
+ "storage_s3.errors.fileNotFound": "Nie znaleziono pliku",
5
+ "storage_s3.errors.fileRequired": "Pole file jest wymagane",
6
+ "storage_s3.errors.integrationNotConfigured": "Integracja S3 nie jest skonfigurowana.",
7
+ "storage_s3.errors.invalidPayload": "Nieprawidłowe dane żądania",
8
+ "storage_s3.errors.invalidQuery": "Nieprawidłowe parametry zapytania",
9
+ "storage_s3.errors.keyAccessDenied": "Odmowa dostępu: klucz nie jest ograniczony do tego tenantu.",
10
+ "storage_s3.errors.keyOverrideAccessDenied": "Odmowa dostępu: nadpisany klucz nie jest ograniczony do tego tenantu.",
11
+ "storage_s3.errors.keyRequired": "Wymagany jest parametr zapytania key",
12
+ "storage_s3.errors.maxUploadSize": "Załącznik przekracza maksymalny dopuszczalny rozmiar przesyłania.",
13
+ "storage_s3.errors.multipartRequired": "Oczekiwano multipart/form-data",
14
+ "storage_s3.errors.prefixAccessDenied": "Odmowa dostępu: prefiks nie jest ograniczony do tego tenantu.",
15
+ "storage_s3.errors.quotaExceeded": "Przekroczono limit przestrzeni na załączniki dla tego najemcy.",
16
+ "storage_s3.errors.quotaTargetExists": "Docelowy klucz w magazynie już istnieje.",
17
+ "storage_s3.errors.quotaUnavailable": "Rozliczanie limitu magazynu jest niedostępne.",
18
+ "storage_s3.errors.unauthorized": "Brak autoryzacji"
19
+ }
@@ -79,7 +79,7 @@ export const integration: IntegrationDefinition = {
79
79
  type: 'url',
80
80
  required: false,
81
81
  placeholder: 'https://fra1.digitaloceanspaces.com',
82
- helpText: 'Custom S3 endpoint URL. Leave empty for AWS S3. Required for MinIO, DigitalOcean Spaces, and other providers.',
82
+ helpText: 'Custom S3 endpoint URL. Leave empty for AWS S3. Internal/private endpoints require OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS=true.',
83
83
  },
84
84
  {
85
85
  key: 'forcePathStyle',