@open-mercato/storage-s3 0.6.7-develop.6860.1.f796b22e80 → 0.6.7

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 +5 -20
  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 +5 -7
  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 +4 -6
  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 +8 -70
  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 +15 -92
  11. package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
  12. package/dist/modules/storage_s3/di.js +2 -6
  13. package/dist/modules/storage_s3/di.js.map +2 -2
  14. package/dist/modules/storage_s3/integration.js +1 -1
  15. package/dist/modules/storage_s3/integration.js.map +1 -1
  16. package/dist/modules/storage_s3/lib/health.js +1 -18
  17. package/dist/modules/storage_s3/lib/health.js.map +2 -2
  18. package/dist/modules/storage_s3/lib/key-scope.js +4 -4
  19. package/dist/modules/storage_s3/lib/key-scope.js.map +2 -2
  20. package/dist/modules/storage_s3/lib/s3-driver.js +15 -44
  21. package/dist/modules/storage_s3/lib/s3-driver.js.map +2 -2
  22. package/dist/modules/storage_s3/lib/storage-service.js +22 -126
  23. package/dist/modules/storage_s3/lib/storage-service.js.map +3 -3
  24. package/jest.config.cjs +0 -1
  25. package/package.json +6 -13
  26. package/src/modules/storage_s3/__tests__/s3Driver.test.ts +8 -23
  27. package/src/modules/storage_s3/__tests__/s3ListRoute.test.ts +1 -7
  28. package/src/modules/storage_s3/__tests__/s3Routes.test.ts +0 -26
  29. package/src/modules/storage_s3/__tests__/storageService.test.ts +5 -20
  30. package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +5 -22
  31. package/src/modules/storage_s3/api/get/storage-providers/s3/download.ts +5 -7
  32. package/src/modules/storage_s3/api/get/storage-providers/s3/list.ts +4 -6
  33. package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +6 -77
  34. package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +16 -101
  35. package/src/modules/storage_s3/di.ts +2 -14
  36. package/src/modules/storage_s3/integration.ts +1 -1
  37. package/src/modules/storage_s3/lib/health.ts +0 -18
  38. package/src/modules/storage_s3/lib/key-scope.ts +4 -4
  39. package/src/modules/storage_s3/lib/s3-driver.ts +10 -65
  40. package/src/modules/storage_s3/lib/storage-service.ts +22 -131
  41. package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js +0 -145
  42. package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js.map +0 -7
  43. package/dist/modules/storage_s3/lib/endpoint-safety.js +0 -133
  44. package/dist/modules/storage_s3/lib/endpoint-safety.js.map +0 -7
  45. package/dist/modules/storage_s3/lib/quota-accounting.js +0 -24
  46. package/dist/modules/storage_s3/lib/quota-accounting.js.map +0 -7
  47. package/dist/modules/storage_s3/lib/quota-recovery-queue.js +0 -9
  48. package/dist/modules/storage_s3/lib/quota-recovery-queue.js.map +0 -7
  49. package/dist/modules/storage_s3/workers/quota-recovery.js +0 -70
  50. package/dist/modules/storage_s3/workers/quota-recovery.js.map +0 -7
  51. package/src/modules/storage_s3/__tests__/endpoint-safety.test.ts +0 -71
  52. package/src/modules/storage_s3/__tests__/health.test.ts +0 -38
  53. package/src/modules/storage_s3/__tests__/i18nPackageExports.test.ts +0 -13
  54. package/src/modules/storage_s3/__tests__/quota-lifecycle.test.ts +0 -216
  55. package/src/modules/storage_s3/__tests__/quota-recovery.test.ts +0 -83
  56. package/src/modules/storage_s3/__tests__/upload.test.ts +0 -93
  57. package/src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].ts +0 -150
  58. package/src/modules/storage_s3/i18n/de.json +0 -17
  59. package/src/modules/storage_s3/i18n/en.json +0 -17
  60. package/src/modules/storage_s3/i18n/es.json +0 -17
  61. package/src/modules/storage_s3/i18n/ko.json +0 -17
  62. package/src/modules/storage_s3/i18n/pl.json +0 -17
  63. package/src/modules/storage_s3/lib/endpoint-safety.ts +0 -179
  64. package/src/modules/storage_s3/lib/quota-accounting.ts +0 -29
  65. package/src/modules/storage_s3/lib/quota-recovery-queue.ts +0 -17
  66. package/src/modules/storage_s3/workers/quota-recovery.ts +0 -82
@@ -3,7 +3,6 @@ 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'
7
6
  import {
8
7
  buildAttachmentContentDisposition,
9
8
  canRenderInlineAttachment,
@@ -28,24 +27,23 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
28
27
  }
29
28
 
30
29
  export async function GET(req: Request) {
31
- const { t } = await resolveTranslations()
32
30
  const auth = await getAuthFromRequest(req)
33
31
  if (!auth?.tenantId || !auth.orgId) {
34
- return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })
32
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
35
33
  }
36
34
 
37
35
  const key = new URL(req.url).searchParams.get('key')
38
36
  if (!key) {
39
- return NextResponse.json({ error: t('storage_s3.errors.keyRequired', 'key query param is required') }, { status: 400 })
37
+ return NextResponse.json({ error: 'key query param is required' }, { status: 400 })
40
38
  }
41
39
 
42
40
  if (!isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)) {
43
- return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })
41
+ return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })
44
42
  }
45
43
 
46
44
  const driver = await resolveDriver(auth.tenantId, auth.orgId)
47
45
  if (!driver) {
48
- return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
46
+ return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
49
47
  }
50
48
 
51
49
  let buffer: Buffer
@@ -55,7 +53,7 @@ export async function GET(req: Request) {
55
53
  buffer = result.buffer
56
54
  contentType = result.contentType
57
55
  } catch {
58
- return NextResponse.json({ error: t('storage_s3.errors.fileNotFound', 'File not found') }, { status: 404 })
56
+ return NextResponse.json({ error: 'File not found' }, { status: 404 })
59
57
  }
60
58
 
61
59
  const fileName = sanitizeUploadedFileName(key.split('/').pop() || 'download')
@@ -3,7 +3,6 @@ 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'
7
6
  import { S3StorageDriver } from '../../../../lib/s3-driver'
8
7
 
9
8
  export const metadata = {
@@ -72,30 +71,29 @@ function resolveTenantScopedPrefix(prefix: string, tenantId: string, orgId: stri
72
71
  }
73
72
 
74
73
  export async function GET(req: Request) {
75
- const { t } = await resolveTranslations()
76
74
  const auth = await getAuthFromRequest(req)
77
75
  if (!auth?.tenantId || !auth.orgId) {
78
- return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })
76
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
79
77
  }
80
78
 
81
79
  const params = Object.fromEntries(new URL(req.url).searchParams.entries())
82
80
  const parsed = querySchema.safeParse(params)
83
81
  if (!parsed.success) {
84
- return NextResponse.json({ error: t('storage_s3.errors.invalidQuery', 'Invalid query parameters') }, { status: 400 })
82
+ return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })
85
83
  }
86
84
 
87
85
  // Always scope list operations to the tenant namespace to prevent cross-tenant enumeration.
88
86
  const effectivePrefix = resolveTenantScopedPrefix(parsed.data.prefix, auth.tenantId, auth.orgId)
89
87
  if (effectivePrefix === null) {
90
88
  return NextResponse.json(
91
- { error: t('storage_s3.errors.prefixAccessDenied', 'Access denied: prefix is not scoped to this tenant.') },
89
+ { error: 'Access denied: prefix is not scoped to this tenant.' },
92
90
  { status: 403 },
93
91
  )
94
92
  }
95
93
 
96
94
  const driver = await resolveDriver(auth.tenantId, auth.orgId)
97
95
  if (!driver) {
98
- return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
96
+ return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
99
97
  }
100
98
 
101
99
  const result = await driver.listObjects(
@@ -3,13 +3,8 @@ 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'
7
6
  import { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from '../../../../lib/key-scope'
8
7
  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'
13
8
 
14
9
  export const metadata = {
15
10
  path: '/storage-providers/s3/signed-url',
@@ -21,13 +16,11 @@ const requestSchema = z.object({
21
16
  operation: z.enum(['upload', 'download']),
22
17
  expiresIn: z.number().int().min(60).max(604800).optional().default(3600),
23
18
  contentType: z.string().optional(),
24
- size: z.number().int().nonnegative().optional(),
25
19
  })
26
20
 
27
21
  const responseSchema = z.object({
28
22
  url: z.string(),
29
23
  expiresAt: z.string(),
30
- reservationId: z.string().optional(),
31
24
  })
32
25
 
33
26
  async function resolveDriver(tenantId: string, orgId: string): Promise<S3StorageDriver | null> {
@@ -41,96 +34,32 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
41
34
  }
42
35
 
43
36
  export async function POST(req: Request) {
44
- const { t } = await resolveTranslations()
45
37
  const auth = await getAuthFromRequest(req)
46
38
  if (!auth?.tenantId || !auth.orgId) {
47
- return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })
39
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
48
40
  }
49
41
 
50
42
  const json = await req.json().catch(() => null)
51
43
  const parsed = requestSchema.safeParse(json)
52
44
  if (!parsed.success) {
53
- return NextResponse.json({ error: t('storage_s3.errors.invalidPayload', 'Invalid payload') }, { status: 400 })
45
+ return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
54
46
  }
55
47
 
56
- const { key, operation, expiresIn, contentType, size } = parsed.data
48
+ const { key, operation, expiresIn, contentType } = parsed.data
57
49
  const canAccessKey = operation === 'download'
58
50
  ? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)
59
51
  : isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId)
60
52
  if (!canAccessKey) {
61
- return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })
53
+ return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })
62
54
  }
63
55
 
64
56
  const driver = await resolveDriver(auth.tenantId, auth.orgId)
65
57
  if (!driver) {
66
- return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
67
- }
68
-
69
- const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()
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
- }
58
+ return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
131
59
  }
132
60
 
133
61
  const url = await driver.getSignedUrl(key, operation, expiresIn, contentType)
62
+ const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()
134
63
 
135
64
  return NextResponse.json({ url, expiresAt })
136
65
  }
@@ -3,23 +3,18 @@ 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'
7
6
  import {
8
7
  detectAttachmentMimeType,
9
8
  hasDangerousExecutableExtension,
10
9
  isActiveContentAttachment,
11
10
  } from '@open-mercato/core/modules/attachments/lib/security'
12
11
  import {
13
- isMultipartUploadLimitError,
14
12
  isMultipartRequestWithinUploadLimit,
15
- parseMultipartFormDataWithinUploadLimit,
16
13
  resolveAttachmentMaxBytes,
17
14
  willExceedAttachmentTenantQuota,
18
15
  } from '@open-mercato/core/modules/attachments/lib/upload-limits'
19
16
  import { isS3KeyScopedToTenant } from '../../../../lib/key-scope'
20
17
  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'
23
18
  import { randomUUID } from 'crypto'
24
19
 
25
20
  export const metadata = {
@@ -73,146 +68,66 @@ async function readTenantStorageUsageBytes(
73
68
  }
74
69
 
75
70
  export async function POST(req: Request) {
76
- const { t } = await resolveTranslations()
77
71
  const auth = await getAuthFromRequest(req)
78
72
  if (!auth?.tenantId || !auth.orgId) {
79
- return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })
73
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
80
74
  }
81
75
 
82
76
  if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {
83
- return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })
77
+ return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })
84
78
  }
85
79
 
86
80
  const contentType = req.headers.get('content-type') ?? ''
87
81
  if (!contentType.includes('multipart/form-data')) {
88
- return NextResponse.json({ error: t('storage_s3.errors.multipartRequired', 'Expected multipart/form-data') }, { status: 400 })
82
+ return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 })
89
83
  }
90
84
 
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
- }
85
+ const form = await req.formData()
100
86
  const file = form.get('file') as File | null
101
87
  if (!file) {
102
- return NextResponse.json({ error: t('storage_s3.errors.fileRequired', 'file field is required') }, { status: 400 })
88
+ return NextResponse.json({ error: 'file field is required' }, { status: 400 })
103
89
  }
104
90
 
105
91
  const keyOverride = form.get('key') ? String(form.get('key')) : null
106
92
 
107
93
  if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {
108
94
  return NextResponse.json(
109
- { error: t('storage_s3.errors.keyOverrideAccessDenied', 'Access denied: key override is not scoped to this tenant.') },
95
+ { error: 'Access denied: key override is not scoped to this tenant.' },
110
96
  { status: 403 },
111
97
  )
112
98
  }
113
99
 
114
100
  if (hasDangerousExecutableExtension(file.name)) {
115
- return NextResponse.json({ error: t('storage_s3.errors.dangerousExecutable', 'Executable file types are not allowed as attachments.') }, { status: 400 })
101
+ return NextResponse.json({ error: 'Executable file types are not allowed as attachments.' }, { status: 400 })
116
102
  }
117
103
 
118
104
  const effectiveMaxBytes = resolveAttachmentMaxBytes()
119
105
  if (file.size > effectiveMaxBytes) {
120
- return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })
106
+ return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })
121
107
  }
122
108
 
123
109
  const driver = await resolveDriver(auth.tenantId, auth.orgId)
124
110
  if (!driver) {
125
- return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
111
+ return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
126
112
  }
127
113
 
128
114
  const buffer = Buffer.from(await file.arrayBuffer())
129
115
  const safeName = sanitizeFileName(file.name)
130
116
  const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null)
131
117
  if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {
132
- return NextResponse.json({ error: t('storage_s3.errors.activeContentBlocked', 'Active content uploads are not allowed.') }, { status: 400 })
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 })
133
124
  }
134
125
 
135
126
  const key =
136
127
  keyOverride ??
137
128
  `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`
138
129
 
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: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })
186
- }
187
- if (code === 'quota_target_exists') {
188
- return NextResponse.json({ error: 'The target storage key already exists.' }, { status: 409 })
189
- }
190
- return NextResponse.json({ error: '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: '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
- }
130
+ await driver.putObject(key, buffer, trustedMimeType)
216
131
 
217
132
  return NextResponse.json({
218
133
  key,
@@ -6,10 +6,8 @@ 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'
10
9
  import { s3HealthCheck } from './lib/health'
11
10
  import { createLogger } from '@open-mercato/shared/lib/logger'
12
- import { scheduleStorageS3QuotaRecovery } from './lib/quota-recovery-queue'
13
11
 
14
12
  const logger = createLogger('storage_s3')
15
13
 
@@ -69,22 +67,14 @@ export function register(container: AppContainer) {
69
67
 
70
68
  container.register({
71
69
  s3HealthCheck: asValue(s3HealthCheck),
72
- storageS3QuotaRecoveryScheduler: asValue(scheduleStorageS3QuotaRecovery),
73
70
  storageService: asFunction(
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
- }) => {
71
+ ({ integrationCredentialsService }: { integrationCredentialsService: IntegrationCredentialsService }) => {
82
72
  // StorageService factory — builds the service lazily using credentials
83
73
  // resolved from the Integration Marketplace per request.
84
74
  return {
85
75
  async _resolveService(scope: { tenantId: string; organizationId: string }) {
86
76
  const creds = await integrationCredentialsService.resolve('storage_s3', scope)
87
- if (!creds) throw new Error('[internal] S3 storage integration is not configured for this tenant.')
77
+ if (!creds) throw new Error('S3 storage integration is not configured for this tenant.')
88
78
  return createStorageService({
89
79
  authMode: creds.authMode === 'ambient' || creds.authMode === 'access_keys'
90
80
  ? (creds.authMode as 'ambient' | 'access_keys')
@@ -96,8 +86,6 @@ export function register(container: AppContainer) {
96
86
  accessKeyId: creds.accessKeyId ? String(creds.accessKeyId) : undefined,
97
87
  secretAccessKey: creds.secretAccessKey ? String(creds.secretAccessKey) : undefined,
98
88
  sessionToken: creds.sessionToken ? String(creds.sessionToken) : undefined,
99
- quotaService: attachmentQuotaService,
100
- quotaRecoveryScheduler: storageS3QuotaRecoveryScheduler,
101
89
  organizationId: scope.organizationId,
102
90
  tenantId: scope.tenantId,
103
91
  // Credentials are resolved from the Integration Marketplace (encrypted at rest)
@@ -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. Internal/private endpoints require OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS=true.',
82
+ helpText: 'Custom S3 endpoint URL. Leave empty for AWS S3. Required for MinIO, DigitalOcean Spaces, and other providers.',
83
83
  },
84
84
  {
85
85
  key: 'forcePathStyle',
@@ -1,5 +1,4 @@
1
1
  import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3'
2
- import { assertSafeS3Endpoint, createSafeS3RequestHandler } from './endpoint-safety'
3
2
  import type { S3DriverConfig } from './s3-driver'
4
3
 
5
4
  export const s3HealthCheck = {
@@ -68,28 +67,11 @@ export const s3HealthCheck = {
68
67
  }
69
68
  }
70
69
 
71
- try {
72
- await assertSafeS3Endpoint(cfg.endpoint)
73
- } catch (err: unknown) {
74
- const message = err instanceof Error ? err.message : 'Unsafe S3 endpoint'
75
- const reason = err && typeof err === 'object' && 'reason' in err ? (err as { reason?: unknown }).reason : undefined
76
- return {
77
- status: 'unhealthy',
78
- message,
79
- details: {
80
- ...(typeof reason === 'string' ? { reason } : {}),
81
- },
82
- checkedAt,
83
- }
84
- }
85
-
86
- const requestHandler = createSafeS3RequestHandler(cfg.endpoint)
87
70
  const client = new S3Client({
88
71
  region: cfg.region ?? 'us-east-1',
89
72
  endpoint: cfg.endpoint,
90
73
  forcePathStyle: cfg.forcePathStyle ?? false,
91
74
  credentials: credentialConfig,
92
- ...(requestHandler ? { requestHandler } : {}),
93
75
  })
94
76
 
95
77
  try {
@@ -113,7 +113,7 @@ export function assertS3KeyScopedToTenant(
113
113
  pathPrefix?: string,
114
114
  ): void {
115
115
  if (!isS3KeyScopedToTenant(storagePath, scope, pathPrefix)) {
116
- throw new Error('[internal] S3 key is not scoped to the active tenant')
116
+ throw new Error('S3 key is not scoped to the active tenant')
117
117
  }
118
118
  }
119
119
 
@@ -123,7 +123,7 @@ export function assertS3KeyAddressableByTenantScope(
123
123
  pathPrefix?: string,
124
124
  ): void {
125
125
  if (!isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix)) {
126
- throw new Error('[internal] S3 key is not scoped to the active tenant')
126
+ throw new Error('S3 key is not scoped to the active tenant')
127
127
  }
128
128
  }
129
129
 
@@ -140,7 +140,7 @@ export function assertS3ListPrefixScopedToTenant(
140
140
 
141
141
  if (parts[0]?.startsWith('org_') || parts[1]?.startsWith('tenant_')) {
142
142
  if (parts[0] !== segments.orgSegment || parts[1] !== segments.tenantSegment) {
143
- throw new Error('[internal] S3 prefix is not scoped to the active tenant')
143
+ throw new Error('S3 prefix is not scoped to the active tenant')
144
144
  }
145
145
  return
146
146
  }
@@ -149,7 +149,7 @@ export function assertS3ListPrefixScopedToTenant(
149
149
  (parts[1]?.startsWith('org_') && parts[1] !== segments.orgSegment)
150
150
  || (parts[2]?.startsWith('tenant_') && parts[2] !== segments.tenantSegment)
151
151
  ) {
152
- throw new Error('[internal] S3 prefix is not scoped to the active tenant')
152
+ throw new Error('S3 prefix is not scoped to the active tenant')
153
153
  }
154
154
  }
155
155