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

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.
@@ -62,7 +62,7 @@ async function POST(req) {
62
62
  } catch {
63
63
  }
64
64
  if (!attachmentQuotaService) {
65
- return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
65
+ return NextResponse.json({ error: t("storage_s3.errors.quotaUnavailable", "Storage quota accounting is unavailable.") }, { status: 500 });
66
66
  }
67
67
  const compatibilityToken = randomBytes(32).toString("base64url");
68
68
  const reservedBytes = size ?? resolveAttachmentMaxBytes();
@@ -84,7 +84,7 @@ async function POST(req) {
84
84
  uploadTokenHash: createHash("sha256").update(compatibilityToken).digest("hex"),
85
85
  ttlMs: expiresIn * 1e3
86
86
  });
87
- if (!recoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
87
+ if (!recoveryScheduler) throw new Error("[internal] Storage quota recovery is unavailable.");
88
88
  await recoveryScheduler({
89
89
  reservationId: reservation.id,
90
90
  tenantId: auth.tenantId,
@@ -99,12 +99,12 @@ async function POST(req) {
99
99
  }
100
100
  const code = error?.code;
101
101
  if (code === "quota_exceeded") {
102
- return NextResponse.json({ error: "Attachment storage quota exceeded for this tenant." }, { status: 413 });
102
+ return NextResponse.json({ error: t("storage_s3.errors.quotaExceeded", "Attachment storage quota exceeded for this tenant.") }, { status: 413 });
103
103
  }
104
104
  if (code === "quota_target_exists") {
105
- return NextResponse.json({ error: "The target storage key already exists." }, { status: 409 });
105
+ return NextResponse.json({ error: t("storage_s3.errors.quotaTargetExists", "The target storage key already exists.") }, { status: 409 });
106
106
  }
107
- return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
107
+ return NextResponse.json({ error: t("storage_s3.errors.quotaUnavailable", "Storage quota accounting is unavailable.") }, { status: 500 });
108
108
  }
109
109
  }
110
110
  const url = await driver.getSignedUrl(key, operation, expiresIn, contentType);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\nimport { createHash, randomBytes } from 'node:crypto'\nimport { resolveAttachmentMaxBytes } from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { reconcileTenantS3Objects } from '../../../../lib/quota-accounting'\n\nexport const metadata = {\n path: '/storage-providers/s3/signed-url',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\n operation: z.enum(['upload', 'download']),\n expiresIn: z.number().int().min(60).max(604800).optional().default(3600),\n contentType: z.string().optional(),\n size: z.number().int().nonnegative().optional(),\n})\n\nconst responseSchema = z.object({\n url: z.string(),\n expiresAt: z.string(),\n reservationId: z.string().optional(),\n})\n\nasync function resolveDriver(tenantId: string, orgId: string): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const json = await req.json().catch(() => null)\n const parsed = requestSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: t('storage_s3.errors.invalidPayload', 'Invalid payload') }, { status: 400 })\n }\n\n const { key, operation, expiresIn, contentType, size } = parsed.data\n const canAccessKey = operation === 'download'\n ? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)\n : isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId)\n if (!canAccessKey) {\n return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })\n }\n\n const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()\n\n if (operation === 'upload') {\n const { resolve } = await createRequestContainer()\n let attachmentQuotaService: AttachmentQuotaService | null = null\n let recoveryScheduler: ((\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>) | null = null\n try {\n attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService\n recoveryScheduler = resolve('storageS3QuotaRecoveryScheduler') as (\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>\n } catch {\n // Fail closed below when the attachments quota service is not registered.\n }\n if (!attachmentQuotaService) {\n return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })\n }\n const compatibilityToken = randomBytes(32).toString('base64url')\n const reservedBytes = size ?? resolveAttachmentMaxBytes()\n let reservation: Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null = null\n try {\n await reconcileTenantS3Objects({\n driver,\n quotaService: attachmentQuotaService,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n reservation = await attachmentQuotaService.reserve({\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n bytes: reservedBytes,\n source: 'storage_s3_signed',\n storageDriver: 's3',\n storagePath: key,\n uploadTokenHash: createHash('sha256').update(compatibilityToken).digest('hex'),\n ttlMs: expiresIn * 1000,\n })\n if (!recoveryScheduler) throw new Error('Storage quota recovery is unavailable.')\n await recoveryScheduler({\n reservationId: reservation.id,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))\n const url = new URL(`/api/storage-providers/s3/signed-upload/${compatibilityToken}`, req.url).toString()\n return NextResponse.json({ url, expiresAt, reservationId: reservation.id })\n } catch (error) {\n if (reservation) {\n await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n }\n const code = (error as { code?: unknown })?.code\n if (code === 'quota_exceeded') {\n return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })\n }\n if (code === 'quota_target_exists') {\n return NextResponse.json({ error: 'The target storage key already exists.' }, { status: 409 })\n }\n return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })\n }\n }\n\n const url = await driver.getSignedUrl(key, operation, expiresIn, contentType)\n\n return NextResponse.json({ url, expiresAt })\n}\n\nexport default POST\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Generate S3 pre-signed URL',\n methods: {\n POST: {\n summary: 'Generate a pre-signed URL for direct browser upload or download',\n description: 'Returns a time-limited URL that allows a browser to directly upload or download a file from S3.',\n requestBody: { contentType: 'application/json', schema: requestSchema },\n responses: [{ status: 200, description: 'Pre-signed URL and expiry', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload or S3 not configured', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Key not scoped to this tenant', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\nimport { createHash, randomBytes } from 'node:crypto'\nimport { resolveAttachmentMaxBytes } from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { reconcileTenantS3Objects } from '../../../../lib/quota-accounting'\n\nexport const metadata = {\n path: '/storage-providers/s3/signed-url',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\n operation: z.enum(['upload', 'download']),\n expiresIn: z.number().int().min(60).max(604800).optional().default(3600),\n contentType: z.string().optional(),\n size: z.number().int().nonnegative().optional(),\n})\n\nconst responseSchema = z.object({\n url: z.string(),\n expiresAt: z.string(),\n reservationId: z.string().optional(),\n})\n\nasync function resolveDriver(tenantId: string, orgId: string): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const json = await req.json().catch(() => null)\n const parsed = requestSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: t('storage_s3.errors.invalidPayload', 'Invalid payload') }, { status: 400 })\n }\n\n const { key, operation, expiresIn, contentType, size } = parsed.data\n const canAccessKey = operation === 'download'\n ? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)\n : isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId)\n if (!canAccessKey) {\n return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })\n }\n\n const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()\n\n if (operation === 'upload') {\n const { resolve } = await createRequestContainer()\n let attachmentQuotaService: AttachmentQuotaService | null = null\n let recoveryScheduler: ((\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>) | null = null\n try {\n attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService\n recoveryScheduler = resolve('storageS3QuotaRecoveryScheduler') as (\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>\n } catch {\n // Fail closed below when the attachments quota service is not registered.\n }\n if (!attachmentQuotaService) {\n return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', 'Storage quota accounting is unavailable.') }, { status: 500 })\n }\n const compatibilityToken = randomBytes(32).toString('base64url')\n const reservedBytes = size ?? resolveAttachmentMaxBytes()\n let reservation: Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null = null\n try {\n await reconcileTenantS3Objects({\n driver,\n quotaService: attachmentQuotaService,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n reservation = await attachmentQuotaService.reserve({\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n bytes: reservedBytes,\n source: 'storage_s3_signed',\n storageDriver: 's3',\n storagePath: key,\n uploadTokenHash: createHash('sha256').update(compatibilityToken).digest('hex'),\n ttlMs: expiresIn * 1000,\n })\n if (!recoveryScheduler) throw new Error('[internal] Storage quota recovery is unavailable.')\n await recoveryScheduler({\n reservationId: reservation.id,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))\n const url = new URL(`/api/storage-providers/s3/signed-upload/${compatibilityToken}`, req.url).toString()\n return NextResponse.json({ url, expiresAt, reservationId: reservation.id })\n } catch (error) {\n if (reservation) {\n await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n }\n const code = (error as { code?: unknown })?.code\n if (code === 'quota_exceeded') {\n return NextResponse.json({ error: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })\n }\n if (code === 'quota_target_exists') {\n return NextResponse.json({ error: t('storage_s3.errors.quotaTargetExists', 'The target storage key already exists.') }, { status: 409 })\n }\n return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', 'Storage quota accounting is unavailable.') }, { status: 500 })\n }\n }\n\n const url = await driver.getSignedUrl(key, operation, expiresIn, contentType)\n\n return NextResponse.json({ url, expiresAt })\n}\n\nexport default POST\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Generate S3 pre-signed URL',\n methods: {\n POST: {\n summary: 'Generate a pre-signed URL for direct browser upload or download',\n description: 'Returns a time-limited URL that allows a browser to directly upload or download a file from S3.',\n requestBody: { contentType: 'application/json', schema: requestSchema },\n responses: [{ status: 200, description: 'Pre-signed URL and expiry', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload or S3 not configured', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Key not scoped to this tenant', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["url"]
7
7
  }
@@ -129,7 +129,7 @@ async function POST(req) {
129
129
  storageDriver: "s3",
130
130
  storagePath: key
131
131
  });
132
- if (!recoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
132
+ if (!recoveryScheduler) throw new Error("[internal] Storage quota recovery is unavailable.");
133
133
  await recoveryScheduler({
134
134
  reservationId: reservation.id,
135
135
  tenantId: auth.tenantId,
@@ -171,7 +171,7 @@ async function POST(req) {
171
171
  } catch {
172
172
  }
173
173
  }
174
- return NextResponse.json({ error: "Failed to persist attachment." }, { status: 500 });
174
+ return NextResponse.json({ error: t("storage_s3.errors.persistFailed", "Failed to persist attachment.") }, { status: 500 });
175
175
  }
176
176
  return NextResponse.json({
177
177
  key,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../src/modules/storage_s3/api/post/storage-providers/s3/upload.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport {\n isMultipartUploadLimitError,\n isMultipartRequestWithinUploadLimit,\n parseMultipartFormDataWithinUploadLimit,\n resolveAttachmentMaxBytes,\n willExceedAttachmentTenantQuota,\n} from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport { isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { reconcileTenantS3Objects } from '../../../../lib/quota-accounting'\nimport { randomUUID } from 'crypto'\n\nexport const metadata = {\n path: '/storage-providers/s3/upload',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst responseSchema = z.object({\n key: z.string(),\n bucket: z.string(),\n size: z.number().int(),\n contentType: z.string().optional(),\n})\n\nfunction sanitizeFileName(name: string): string {\n return name.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload'\n}\n\nasync function resolveDriver(\n tenantId: string,\n orgId: string,\n): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nasync function readTenantStorageUsageBytes(\n driver: S3StorageDriver,\n tenantId: string,\n orgId: string,\n): Promise<number> {\n let totalBytes = 0\n let continuationToken: string | undefined\n\n do {\n const page = await driver.listObjects('', 1000, continuationToken)\n for (const file of page.files) {\n if (isS3KeyScopedToTenant(file.key, orgId, tenantId)) {\n totalBytes += file.size\n }\n }\n continuationToken = page.truncated ? page.nextContinuationToken : undefined\n } while (continuationToken)\n\n return totalBytes\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n\n const contentType = req.headers.get('content-type') ?? ''\n if (!contentType.includes('multipart/form-data')) {\n return NextResponse.json({ error: t('storage_s3.errors.multipartRequired', 'Expected multipart/form-data') }, { status: 400 })\n }\n\n let form: FormData\n try {\n form = await parseMultipartFormDataWithinUploadLimit(req)\n } catch (error) {\n if (isMultipartUploadLimitError(error)) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n throw error\n }\n const file = form.get('file') as File | null\n if (!file) {\n return NextResponse.json({ error: t('storage_s3.errors.fileRequired', 'file field is required') }, { status: 400 })\n }\n\n const keyOverride = form.get('key') ? String(form.get('key')) : null\n\n if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {\n return NextResponse.json(\n { error: t('storage_s3.errors.keyOverrideAccessDenied', 'Access denied: key override is not scoped to this tenant.') },\n { status: 403 },\n )\n }\n\n if (hasDangerousExecutableExtension(file.name)) {\n return NextResponse.json({ error: t('storage_s3.errors.dangerousExecutable', 'Executable file types are not allowed as attachments.') }, { status: 400 })\n }\n\n const effectiveMaxBytes = resolveAttachmentMaxBytes()\n if (file.size > effectiveMaxBytes) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })\n }\n\n const buffer = Buffer.from(await file.arrayBuffer())\n const safeName = sanitizeFileName(file.name)\n const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null)\n if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {\n return NextResponse.json({ error: t('storage_s3.errors.activeContentBlocked', 'Active content uploads are not allowed.') }, { status: 400 })\n }\n\n const key =\n keyOverride ??\n `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`\n\n const { resolve } = await createRequestContainer()\n let attachmentQuotaService: AttachmentQuotaService | null = null\n let recoveryScheduler: ((\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>) | null = null\n try {\n attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService\n recoveryScheduler = resolve('storageS3QuotaRecoveryScheduler') as (\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>\n } catch {\n // Backward-compatible fallback below when the attachments quota service is not registered.\n }\n let reservation: { id: string; leaseToken: string; expiresAt: Date } | null = null\n if (attachmentQuotaService) {\n try {\n await reconcileTenantS3Objects({\n driver,\n quotaService: attachmentQuotaService,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n reservation = await attachmentQuotaService.reserve({\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n bytes: buffer.length,\n source: 'storage_s3_upload',\n storageDriver: 's3',\n storagePath: key,\n })\n if (!recoveryScheduler) throw new Error('Storage quota recovery is unavailable.')\n await recoveryScheduler({\n reservationId: reservation.id,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))\n await attachmentQuotaService.beginStorage(reservation.id, reservation.leaseToken)\n } catch (error) {\n if (reservation) {\n await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n reservation = null\n }\n const code = (error as { code?: unknown })?.code\n if (code === 'quota_exceeded') {\n return NextResponse.json({ error: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })\n }\n if (code === 'quota_target_exists') {\n return NextResponse.json({ error: t('storage_s3.errors.quotaTargetExists', 'The target storage key already exists.') }, { status: 409 })\n }\n return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', 'Storage quota accounting is unavailable.') }, { status: 500 })\n }\n } else {\n const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId)\n if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {\n return NextResponse.json({ error: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })\n }\n }\n\n try {\n await driver.putObject(key, buffer, trustedMimeType)\n if (reservation) {\n await attachmentQuotaService!.markStored(reservation.id, reservation.leaseToken)\n await attachmentQuotaService!.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)\n }\n } catch (error) {\n if (reservation) {\n try {\n await driver.deleteStrict('', key)\n await attachmentQuotaService!.release(reservation.id, reservation.leaseToken)\n } catch {\n // Retain the reservation when object absence cannot be proven.\n }\n }\n return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })\n }\n\n return NextResponse.json({\n key,\n bucket: driver.getBucket(),\n size: buffer.length,\n contentType: trustedMimeType,\n })\n}\n\nexport default POST\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Upload file to S3',\n methods: {\n POST: {\n summary: 'Upload a file directly to S3',\n description: 'Uploads a file to the configured S3 bucket. Requires storage_providers.manage feature.',\n requestBody: {\n contentType: 'multipart/form-data',\n schema: z.object({\n file: z.any().describe('File to upload'),\n key: z.string().optional().describe('Optional S3 key override (must be scoped to org/tenant)'),\n contentType: z.string().optional().describe('Optional client-provided content-type hint; the server derives the trusted MIME type.'),\n }),\n },\n responses: [{ status: 200, description: 'Upload result', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Missing file, blocked file type, or S3 not configured', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Key override not scoped to this tenant', schema: z.object({ error: z.string() }) },\n { status: 413, description: 'Upload too large or tenant storage quota exceeded', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport {\n isMultipartUploadLimitError,\n isMultipartRequestWithinUploadLimit,\n parseMultipartFormDataWithinUploadLimit,\n resolveAttachmentMaxBytes,\n willExceedAttachmentTenantQuota,\n} from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport { isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { reconcileTenantS3Objects } from '../../../../lib/quota-accounting'\nimport { randomUUID } from 'crypto'\n\nexport const metadata = {\n path: '/storage-providers/s3/upload',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst responseSchema = z.object({\n key: z.string(),\n bucket: z.string(),\n size: z.number().int(),\n contentType: z.string().optional(),\n})\n\nfunction sanitizeFileName(name: string): string {\n return name.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload'\n}\n\nasync function resolveDriver(\n tenantId: string,\n orgId: string,\n): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nasync function readTenantStorageUsageBytes(\n driver: S3StorageDriver,\n tenantId: string,\n orgId: string,\n): Promise<number> {\n let totalBytes = 0\n let continuationToken: string | undefined\n\n do {\n const page = await driver.listObjects('', 1000, continuationToken)\n for (const file of page.files) {\n if (isS3KeyScopedToTenant(file.key, orgId, tenantId)) {\n totalBytes += file.size\n }\n }\n continuationToken = page.truncated ? page.nextContinuationToken : undefined\n } while (continuationToken)\n\n return totalBytes\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n\n const contentType = req.headers.get('content-type') ?? ''\n if (!contentType.includes('multipart/form-data')) {\n return NextResponse.json({ error: t('storage_s3.errors.multipartRequired', 'Expected multipart/form-data') }, { status: 400 })\n }\n\n let form: FormData\n try {\n form = await parseMultipartFormDataWithinUploadLimit(req)\n } catch (error) {\n if (isMultipartUploadLimitError(error)) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n throw error\n }\n const file = form.get('file') as File | null\n if (!file) {\n return NextResponse.json({ error: t('storage_s3.errors.fileRequired', 'file field is required') }, { status: 400 })\n }\n\n const keyOverride = form.get('key') ? String(form.get('key')) : null\n\n if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {\n return NextResponse.json(\n { error: t('storage_s3.errors.keyOverrideAccessDenied', 'Access denied: key override is not scoped to this tenant.') },\n { status: 403 },\n )\n }\n\n if (hasDangerousExecutableExtension(file.name)) {\n return NextResponse.json({ error: t('storage_s3.errors.dangerousExecutable', 'Executable file types are not allowed as attachments.') }, { status: 400 })\n }\n\n const effectiveMaxBytes = resolveAttachmentMaxBytes()\n if (file.size > effectiveMaxBytes) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })\n }\n\n const buffer = Buffer.from(await file.arrayBuffer())\n const safeName = sanitizeFileName(file.name)\n const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null)\n if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {\n return NextResponse.json({ error: t('storage_s3.errors.activeContentBlocked', 'Active content uploads are not allowed.') }, { status: 400 })\n }\n\n const key =\n keyOverride ??\n `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`\n\n const { resolve } = await createRequestContainer()\n let attachmentQuotaService: AttachmentQuotaService | null = null\n let recoveryScheduler: ((\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>) | null = null\n try {\n attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService\n recoveryScheduler = resolve('storageS3QuotaRecoveryScheduler') as (\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>\n } catch {\n // Backward-compatible fallback below when the attachments quota service is not registered.\n }\n let reservation: { id: string; leaseToken: string; expiresAt: Date } | null = null\n if (attachmentQuotaService) {\n try {\n await reconcileTenantS3Objects({\n driver,\n quotaService: attachmentQuotaService,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n reservation = await attachmentQuotaService.reserve({\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n bytes: buffer.length,\n source: 'storage_s3_upload',\n storageDriver: 's3',\n storagePath: key,\n })\n if (!recoveryScheduler) throw new Error('[internal] Storage quota recovery is unavailable.')\n await recoveryScheduler({\n reservationId: reservation.id,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))\n await attachmentQuotaService.beginStorage(reservation.id, reservation.leaseToken)\n } catch (error) {\n if (reservation) {\n await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n reservation = null\n }\n const code = (error as { code?: unknown })?.code\n if (code === 'quota_exceeded') {\n return NextResponse.json({ error: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })\n }\n if (code === 'quota_target_exists') {\n return NextResponse.json({ error: t('storage_s3.errors.quotaTargetExists', 'The target storage key already exists.') }, { status: 409 })\n }\n return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', 'Storage quota accounting is unavailable.') }, { status: 500 })\n }\n } else {\n const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId)\n if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {\n return NextResponse.json({ error: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })\n }\n }\n\n try {\n await driver.putObject(key, buffer, trustedMimeType)\n if (reservation) {\n await attachmentQuotaService!.markStored(reservation.id, reservation.leaseToken)\n await attachmentQuotaService!.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)\n }\n } catch (error) {\n if (reservation) {\n try {\n await driver.deleteStrict('', key)\n await attachmentQuotaService!.release(reservation.id, reservation.leaseToken)\n } catch {\n // Retain the reservation when object absence cannot be proven.\n }\n }\n return NextResponse.json({ error: t('storage_s3.errors.persistFailed', 'Failed to persist attachment.') }, { status: 500 })\n }\n\n return NextResponse.json({\n key,\n bucket: driver.getBucket(),\n size: buffer.length,\n contentType: trustedMimeType,\n })\n}\n\nexport default POST\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Upload file to S3',\n methods: {\n POST: {\n summary: 'Upload a file directly to S3',\n description: 'Uploads a file to the configured S3 bucket. Requires storage_providers.manage feature.',\n requestBody: {\n contentType: 'multipart/form-data',\n schema: z.object({\n file: z.any().describe('File to upload'),\n key: z.string().optional().describe('Optional S3 key override (must be scoped to org/tenant)'),\n contentType: z.string().optional().describe('Optional client-provided content-type hint; the server derives the trusted MIME type.'),\n }),\n },\n responses: [{ status: 200, description: 'Upload result', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Missing file, blocked file type, or S3 not configured', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Key override not scoped to this tenant', schema: z.object({ error: z.string() }) },\n { status: 413, description: 'Upload too large or tenant storage quota exceeded', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { NextResponse } from "next/server";
4
4
  import { z } from "zod";
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,
@@ -27,7 +28,7 @@ async function readBoundedBody(req, maxBytes) {
27
28
  totalBytes += value.byteLength;
28
29
  if (totalBytes > maxBytes) {
29
30
  await reader.cancel();
30
- throw new UploadBodyTooLargeError("Attachment exceeds the reserved upload size.");
31
+ throw new UploadBodyTooLargeError("[internal] Attachment exceeds the reserved upload size.");
31
32
  }
32
33
  chunks.push(Buffer.from(value));
33
34
  }
@@ -37,27 +38,28 @@ async function readBoundedBody(req, maxBytes) {
37
38
  return Buffer.concat(chunks, totalBytes);
38
39
  }
39
40
  async function PUT(req, ctx) {
41
+ const { t } = await resolveTranslations();
40
42
  const params = ctx.params instanceof Promise ? await ctx.params : ctx.params;
41
43
  const token = params?.token?.trim();
42
- if (!token) return NextResponse.json({ error: "Upload token is required." }, { status: 400 });
44
+ if (!token) return NextResponse.json({ error: t("storage_s3.errors.uploadTokenRequired", "Upload token is required.") }, { status: 400 });
43
45
  const { resolve } = await createRequestContainer();
44
46
  let quotaService = null;
45
47
  try {
46
48
  quotaService = resolve("attachmentQuotaService");
47
49
  } catch {
48
50
  }
49
- if (!quotaService) return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
51
+ if (!quotaService) return NextResponse.json({ error: t("storage_s3.errors.quotaUnavailable", "Storage quota accounting is unavailable.") }, { status: 500 });
50
52
  const tokenHash = createHash("sha256").update(token).digest("hex");
51
53
  const reservation = await quotaService.claimPendingByUploadTokenHash(tokenHash);
52
54
  if (!reservation || reservation.source !== "storage_s3_signed") {
53
- return NextResponse.json({ error: "Upload token is invalid or expired." }, { status: 410 });
55
+ return NextResponse.json({ error: t("storage_s3.errors.uploadTokenInvalid", "Upload token is invalid or expired.") }, { status: 410 });
54
56
  }
55
57
  const declaredLengthHeader = req.headers.get("content-length");
56
58
  const declaredLength = declaredLengthHeader == null ? null : Number(declaredLengthHeader);
57
59
  if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > reservation.reservedBytes) {
58
60
  await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
59
61
  });
60
- return NextResponse.json({ error: "Attachment exceeds the reserved upload size." }, { status: 413 });
62
+ return NextResponse.json({ error: t("storage_s3.errors.reservedSizeExceeded", "Attachment exceeds the reserved upload size.") }, { status: 413 });
61
63
  }
62
64
  let buffer;
63
65
  try {
@@ -66,26 +68,26 @@ async function PUT(req, ctx) {
66
68
  await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
67
69
  });
68
70
  if (error instanceof UploadBodyTooLargeError) {
69
- return NextResponse.json({ error: error.message }, { status: 413 });
71
+ return NextResponse.json({ error: t("storage_s3.errors.reservedSizeExceeded", "Attachment exceeds the reserved upload size.") }, { status: 413 });
70
72
  }
71
- return NextResponse.json({ error: "Failed to read attachment." }, { status: 400 });
73
+ return NextResponse.json({ error: t("storage_s3.errors.readFailed", "Failed to read attachment.") }, { status: 400 });
72
74
  }
73
75
  if (buffer.length > reservation.reservedBytes) {
74
76
  await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
75
77
  });
76
- return NextResponse.json({ error: "Attachment exceeds the reserved upload size." }, { status: 413 });
78
+ return NextResponse.json({ error: t("storage_s3.errors.reservedSizeExceeded", "Attachment exceeds the reserved upload size.") }, { status: 413 });
77
79
  }
78
80
  const fileName = path.posix.basename(reservation.storagePath);
79
81
  if (hasDangerousExecutableExtension(fileName)) {
80
82
  await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
81
83
  });
82
- return NextResponse.json({ error: "Executable file types are not allowed as attachments." }, { status: 400 });
84
+ return NextResponse.json({ error: t("storage_s3.errors.dangerousExecutable", "Executable file types are not allowed as attachments.") }, { status: 400 });
83
85
  }
84
86
  const contentType = detectAttachmentMimeType(buffer, fileName, req.headers.get("content-type"));
85
87
  if (isActiveContentAttachment(buffer, fileName, contentType)) {
86
88
  await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
87
89
  });
88
- return NextResponse.json({ error: "Active content uploads are not allowed." }, { status: 400 });
90
+ return NextResponse.json({ error: t("storage_s3.errors.activeContentBlocked", "Active content uploads are not allowed.") }, { status: 400 });
89
91
  }
90
92
  const credentialsService = resolve("integrationCredentialsService");
91
93
  const credentials = await credentialsService.resolve("storage_s3", {
@@ -95,7 +97,7 @@ async function PUT(req, ctx) {
95
97
  if (!credentials) {
96
98
  await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
97
99
  });
98
- return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
100
+ return NextResponse.json({ error: t("storage_s3.errors.integrationNotConfigured", "S3 integration is not configured.") }, { status: 400 });
99
101
  }
100
102
  const driver = new S3StorageDriver({
101
103
  ...credentials,
@@ -117,7 +119,7 @@ async function PUT(req, ctx) {
117
119
  } catch {
118
120
  }
119
121
  }
120
- return NextResponse.json({ error: "Failed to persist attachment." }, { status: 500 });
122
+ return NextResponse.json({ error: t("storage_s3.errors.persistFailed", "Failed to persist attachment.") }, { status: 500 });
121
123
  }
122
124
  }
123
125
  var token_default = PUT;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/%5Btoken%5D.ts"],
4
- "sourcesContent": ["import { createHash } from 'node:crypto'\nimport path from 'node:path'\nimport { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { S3StorageDriver } from '../../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/signed-upload/:token',\n PUT: { requireAuth: false },\n}\n\ntype RouteContext = { params?: Promise<{ token?: string }> | { token?: string } }\n\nclass UploadBodyTooLargeError extends Error {}\n\nasync function readBoundedBody(req: Request, maxBytes: number): Promise<Buffer> {\n if (!req.body) return Buffer.alloc(0)\n const reader = req.body.getReader()\n const chunks: Buffer[] = []\n let totalBytes = 0\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n totalBytes += value.byteLength\n if (totalBytes > maxBytes) {\n await reader.cancel()\n throw new UploadBodyTooLargeError('Attachment exceeds the reserved upload size.')\n }\n chunks.push(Buffer.from(value))\n }\n } finally {\n reader.releaseLock()\n }\n return Buffer.concat(chunks, totalBytes)\n}\n\nexport async function PUT(req: Request, ctx: RouteContext) {\n const params = ctx.params instanceof Promise ? await ctx.params : ctx.params\n const token = params?.token?.trim()\n if (!token) return NextResponse.json({ error: 'Upload token is required.' }, { status: 400 })\n\n const { resolve } = await createRequestContainer()\n let quotaService: AttachmentQuotaService | null = null\n try {\n quotaService = resolve('attachmentQuotaService') as AttachmentQuotaService\n } catch {\n // Fail closed below when the attachments quota service is not registered.\n }\n if (!quotaService) return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })\n\n const tokenHash = createHash('sha256').update(token).digest('hex')\n const reservation = await quotaService.claimPendingByUploadTokenHash(tokenHash)\n if (!reservation || reservation.source !== 'storage_s3_signed') {\n return NextResponse.json({ error: 'Upload token is invalid or expired.' }, { status: 410 })\n }\n\n const declaredLengthHeader = req.headers.get('content-length')\n const declaredLength = declaredLengthHeader == null ? null : Number(declaredLengthHeader)\n if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > reservation.reservedBytes) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: 'Attachment exceeds the reserved upload size.' }, { status: 413 })\n }\n let buffer: Buffer\n try {\n buffer = await readBoundedBody(req, reservation.reservedBytes)\n } catch (error) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n if (error instanceof UploadBodyTooLargeError) {\n return NextResponse.json({ error: error.message }, { status: 413 })\n }\n return NextResponse.json({ error: 'Failed to read attachment.' }, { status: 400 })\n }\n if (buffer.length > reservation.reservedBytes) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: 'Attachment exceeds the reserved upload size.' }, { status: 413 })\n }\n const fileName = path.posix.basename(reservation.storagePath)\n if (hasDangerousExecutableExtension(fileName)) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: 'Executable file types are not allowed as attachments.' }, { status: 400 })\n }\n const contentType = detectAttachmentMimeType(buffer, fileName, req.headers.get('content-type'))\n if (isActiveContentAttachment(buffer, fileName, contentType)) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: 'Active content uploads are not allowed.' }, { status: 400 })\n }\n\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const credentials = await credentialsService.resolve('storage_s3', {\n tenantId: reservation.tenantId,\n organizationId: reservation.organizationId,\n })\n if (!credentials) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })\n }\n\n const driver = new S3StorageDriver({\n ...credentials,\n organizationId: reservation.organizationId,\n tenantId: reservation.tenantId,\n })\n let objectStored = false\n try {\n await driver.putObject(reservation.storagePath, buffer, contentType)\n objectStored = true\n await quotaService.markStored(reservation.id, reservation.leaseToken)\n await quotaService.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)\n return new NextResponse(null, { status: 200 })\n } catch (error) {\n if (objectStored) {\n try {\n await driver.deleteStrict('', reservation.storagePath)\n await quotaService.release(reservation.id, reservation.leaseToken)\n } catch {\n // Retain the reservation when object absence cannot be proven.\n }\n }\n return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })\n }\n}\n\nexport default PUT\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Upload through a bounded one-time compatibility URL',\n methods: {\n PUT: {\n summary: 'Upload an object using a bounded one-time token',\n responses: [{ status: 200, description: 'Upload completed', schema: z.any() }],\n errors: [\n { status: 400, description: 'Invalid upload', schema: z.object({ error: z.string() }) },\n { status: 410, description: 'Token invalid or expired', schema: z.object({ error: z.string() }) },\n { status: 413, description: 'Upload exceeds reserved size', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createHash } from 'node:crypto'\nimport path from 'node:path'\nimport { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { S3StorageDriver } from '../../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/signed-upload/:token',\n PUT: { requireAuth: false },\n}\n\ntype RouteContext = { params?: Promise<{ token?: string }> | { token?: string } }\n\nclass UploadBodyTooLargeError extends Error {}\n\nasync function readBoundedBody(req: Request, maxBytes: number): Promise<Buffer> {\n if (!req.body) return Buffer.alloc(0)\n const reader = req.body.getReader()\n const chunks: Buffer[] = []\n let totalBytes = 0\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n totalBytes += value.byteLength\n if (totalBytes > maxBytes) {\n await reader.cancel()\n throw new UploadBodyTooLargeError('[internal] Attachment exceeds the reserved upload size.')\n }\n chunks.push(Buffer.from(value))\n }\n } finally {\n reader.releaseLock()\n }\n return Buffer.concat(chunks, totalBytes)\n}\n\nexport async function PUT(req: Request, ctx: RouteContext) {\n const { t } = await resolveTranslations()\n const params = ctx.params instanceof Promise ? await ctx.params : ctx.params\n const token = params?.token?.trim()\n if (!token) return NextResponse.json({ error: t('storage_s3.errors.uploadTokenRequired', 'Upload token is required.') }, { status: 400 })\n\n const { resolve } = await createRequestContainer()\n let quotaService: AttachmentQuotaService | null = null\n try {\n quotaService = resolve('attachmentQuotaService') as AttachmentQuotaService\n } catch {\n // Fail closed below when the attachments quota service is not registered.\n }\n if (!quotaService) return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', 'Storage quota accounting is unavailable.') }, { status: 500 })\n\n const tokenHash = createHash('sha256').update(token).digest('hex')\n const reservation = await quotaService.claimPendingByUploadTokenHash(tokenHash)\n if (!reservation || reservation.source !== 'storage_s3_signed') {\n return NextResponse.json({ error: t('storage_s3.errors.uploadTokenInvalid', 'Upload token is invalid or expired.') }, { status: 410 })\n }\n\n const declaredLengthHeader = req.headers.get('content-length')\n const declaredLength = declaredLengthHeader == null ? null : Number(declaredLengthHeader)\n if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > reservation.reservedBytes) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: t('storage_s3.errors.reservedSizeExceeded', 'Attachment exceeds the reserved upload size.') }, { status: 413 })\n }\n let buffer: Buffer\n try {\n buffer = await readBoundedBody(req, reservation.reservedBytes)\n } catch (error) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n if (error instanceof UploadBodyTooLargeError) {\n return NextResponse.json({ error: t('storage_s3.errors.reservedSizeExceeded', 'Attachment exceeds the reserved upload size.') }, { status: 413 })\n }\n return NextResponse.json({ error: t('storage_s3.errors.readFailed', 'Failed to read attachment.') }, { status: 400 })\n }\n if (buffer.length > reservation.reservedBytes) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: t('storage_s3.errors.reservedSizeExceeded', 'Attachment exceeds the reserved upload size.') }, { status: 413 })\n }\n const fileName = path.posix.basename(reservation.storagePath)\n if (hasDangerousExecutableExtension(fileName)) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: t('storage_s3.errors.dangerousExecutable', 'Executable file types are not allowed as attachments.') }, { status: 400 })\n }\n const contentType = detectAttachmentMimeType(buffer, fileName, req.headers.get('content-type'))\n if (isActiveContentAttachment(buffer, fileName, contentType)) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: t('storage_s3.errors.activeContentBlocked', 'Active content uploads are not allowed.') }, { status: 400 })\n }\n\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const credentials = await credentialsService.resolve('storage_s3', {\n tenantId: reservation.tenantId,\n organizationId: reservation.organizationId,\n })\n if (!credentials) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })\n }\n\n const driver = new S3StorageDriver({\n ...credentials,\n organizationId: reservation.organizationId,\n tenantId: reservation.tenantId,\n })\n let objectStored = false\n try {\n await driver.putObject(reservation.storagePath, buffer, contentType)\n objectStored = true\n await quotaService.markStored(reservation.id, reservation.leaseToken)\n await quotaService.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)\n return new NextResponse(null, { status: 200 })\n } catch (error) {\n if (objectStored) {\n try {\n await driver.deleteStrict('', reservation.storagePath)\n await quotaService.release(reservation.id, reservation.leaseToken)\n } catch {\n // Retain the reservation when object absence cannot be proven.\n }\n }\n return NextResponse.json({ error: t('storage_s3.errors.persistFailed', 'Failed to persist attachment.') }, { status: 500 })\n }\n}\n\nexport default PUT\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Upload through a bounded one-time compatibility URL',\n methods: {\n PUT: {\n summary: 'Upload an object using a bounded one-time token',\n responses: [{ status: 200, description: 'Upload completed', schema: z.any() }],\n errors: [\n { status: 400, description: 'Invalid upload', schema: z.object({ error: z.string() }) },\n { status: 410, description: 'Token invalid or expired', schema: z.object({ error: z.string() }) },\n { status: 413, description: 'Upload exceeds reserved size', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/storage-s3",
3
- "version": "0.6.8-develop.6874.1.982d6097d8",
3
+ "version": "0.6.8-develop.6876.1.c514f2eb37",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -74,15 +74,15 @@
74
74
  "dependencies": {
75
75
  "@aws-sdk/client-s3": "^3.1085.0",
76
76
  "@aws-sdk/s3-request-presigner": "^3.1085.0",
77
- "@open-mercato/core": "0.6.8-develop.6874.1.982d6097d8",
78
- "@open-mercato/queue": "0.6.8-develop.6874.1.982d6097d8"
77
+ "@open-mercato/core": "0.6.8-develop.6876.1.c514f2eb37",
78
+ "@open-mercato/queue": "0.6.8-develop.6876.1.c514f2eb37"
79
79
  },
80
80
  "peerDependencies": {
81
81
  "@mikro-orm/postgresql": "^6.6.10",
82
- "@open-mercato/shared": "0.6.8-develop.6874.1.982d6097d8"
82
+ "@open-mercato/shared": "0.6.8-develop.6876.1.c514f2eb37"
83
83
  },
84
84
  "devDependencies": {
85
- "@open-mercato/shared": "0.6.8-develop.6874.1.982d6097d8",
85
+ "@open-mercato/shared": "0.6.8-develop.6876.1.c514f2eb37",
86
86
  "@types/jest": "^30.0.0",
87
87
  "esbuild": "^0.28.1",
88
88
  "glob": "^13.0.6",
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'
4
4
  const packageRequire = createRequire(__filename)
5
5
 
6
6
  describe('storage_s3 locale package exports', () => {
7
- it.each(['en', 'de', 'es', 'pl'] as const)('resolves the %s locale through the package export map', (locale) => {
7
+ it.each(['en', 'de', 'es', 'ko', 'pl'] as const)('resolves the %s locale through the package export map', (locale) => {
8
8
  const localePath = packageRequire.resolve(`@open-mercato/storage-s3/modules/storage_s3/i18n/${locale}.json`)
9
9
  const translations = JSON.parse(readFileSync(localePath, 'utf8')) as Record<string, unknown>
10
10
 
@@ -0,0 +1,111 @@
1
+ /** @jest-environment node */
2
+
3
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
4
+ resolveTranslations: jest.fn(async () => ({
5
+ t: (key: string) => `translated:${key}`,
6
+ })),
7
+ }))
8
+
9
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
10
+ createRequestContainer: jest.fn(async () => ({ resolve: () => null })),
11
+ }))
12
+
13
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
14
+ import path from 'node:path'
15
+
16
+ const LOCALES = ['en', 'de', 'es', 'ko', 'pl'] as const
17
+
18
+ const apiDir = path.join(__dirname, '..', 'api')
19
+ const i18nDir = path.join(__dirname, '..', 'i18n')
20
+
21
+ function routeFiles(dir: string): string[] {
22
+ return readdirSync(dir).flatMap((entry) => {
23
+ const full = path.join(dir, entry)
24
+ if (statSync(full).isDirectory()) return routeFiles(full)
25
+ return full.endsWith('.ts') ? [full] : []
26
+ })
27
+ }
28
+
29
+ function localeDictionary(locale: string): Record<string, string> {
30
+ return JSON.parse(readFileSync(path.join(i18nDir, `${locale}.json`), 'utf8')) as Record<string, string>
31
+ }
32
+
33
+ const sources = routeFiles(apiDir).map((file) => ({
34
+ file: path.relative(apiDir, file),
35
+ source: readFileSync(file, 'utf8'),
36
+ }))
37
+
38
+ const dictionaries = Object.fromEntries(LOCALES.map((locale) => [locale, localeDictionary(locale)]))
39
+
40
+ describe('storage_s3 route error localization (#4830)', () => {
41
+ const usages = sources.flatMap(({ file, source }) =>
42
+ [...source.matchAll(/t\('(storage_s3\.errors\.[a-zA-Z]+)', '([^']*)'\)/g)].map((match) => ({
43
+ file,
44
+ key: match[1],
45
+ fallback: match[2],
46
+ })),
47
+ )
48
+
49
+ it('uses translation keys in every route that reports an error', () => {
50
+ expect(usages.length).toBeGreaterThan(0)
51
+ })
52
+
53
+ it('ships every key the routes use in all five supported locales', () => {
54
+ const missing = usages.flatMap(({ key }) =>
55
+ LOCALES.filter((locale) => dictionaries[locale][key] === undefined).map((locale) => `${locale}:${key}`),
56
+ )
57
+ expect(missing).toEqual([])
58
+ })
59
+
60
+ it('keeps each inline English fallback identical to en.json', () => {
61
+ const drifted = usages
62
+ .filter(({ key, fallback }) => dictionaries.en[key] !== fallback)
63
+ .map(({ file, key }) => `${file}:${key}`)
64
+ expect(drifted).toEqual([])
65
+ })
66
+
67
+ it('never returns a hardcoded user-facing error string from a route response', () => {
68
+ // #4076 added the atomic quota-admission paths on top of an already localized
69
+ // module and reintroduced hardcoded English, which is how this issue came back.
70
+ const hardcoded = sources.flatMap(({ file, source }) =>
71
+ [...source.matchAll(/error: '([^']+)'/g)].map((match) => `${file}: ${match[1]}`),
72
+ )
73
+ expect(hardcoded).toEqual([])
74
+ })
75
+
76
+ it('marks diagnostic-only thrown errors with the [internal] opt-out marker', () => {
77
+ const unmarked = sources.flatMap(({ file, source }) =>
78
+ [...source.matchAll(/throw new \w*Error\('([^']+)'\)/g)]
79
+ .map((match) => match[1])
80
+ .filter((message) => !message.startsWith('[internal] '))
81
+ .map((message) => `${file}: ${message}`),
82
+ )
83
+ expect(unmarked).toEqual([])
84
+ })
85
+
86
+ it('returns the translated string, not the English literal, when the upload token is missing', async () => {
87
+ const { PUT } = await import('../api/put/storage-providers/s3/signed-upload/[token]')
88
+ const response = await PUT(
89
+ new Request('http://localhost/api/storage-providers/s3/signed-upload/', { method: 'PUT' }),
90
+ { params: { token: ' ' } },
91
+ )
92
+
93
+ expect(response.status).toBe(400)
94
+ await expect(response.json()).resolves.toEqual({
95
+ error: 'translated:storage_s3.errors.uploadTokenRequired',
96
+ })
97
+ })
98
+
99
+ it('returns the translated string when the quota service is not registered', async () => {
100
+ const { PUT } = await import('../api/put/storage-providers/s3/signed-upload/[token]')
101
+ const response = await PUT(
102
+ new Request('http://localhost/api/storage-providers/s3/signed-upload/tok', { method: 'PUT' }),
103
+ { params: { token: 'tok' } },
104
+ )
105
+
106
+ expect(response.status).toBe(500)
107
+ await expect(response.json()).resolves.toEqual({
108
+ error: 'translated:storage_s3.errors.quotaUnavailable',
109
+ })
110
+ })
111
+ })
@@ -12,12 +12,25 @@ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
12
12
  }))
13
13
 
14
14
  const mockResolveCredentials = jest.fn()
15
+ const mockReserve = jest.fn()
16
+ const mockReconcileStandaloneObjects = jest.fn()
17
+ let quotaServiceRegistered = false
15
18
  jest.mock('@open-mercato/shared/lib/di/container', () => ({
16
19
  createRequestContainer: jest.fn(async () => ({
17
20
  resolve: (name: string) => {
18
21
  if (name === 'integrationCredentialsService') {
19
22
  return { resolve: mockResolveCredentials }
20
23
  }
24
+ if (quotaServiceRegistered && name === 'attachmentQuotaService') {
25
+ return {
26
+ reserve: (...args: unknown[]) => mockReserve(...args),
27
+ reconcileStandaloneObjects: (...args: unknown[]) => mockReconcileStandaloneObjects(...args),
28
+ release: jest.fn(),
29
+ }
30
+ }
31
+ if (quotaServiceRegistered && name === 'storageS3QuotaRecoveryScheduler') {
32
+ return jest.fn()
33
+ }
21
34
  throw new Error(`Unexpected dependency: ${name}`)
22
35
  },
23
36
  })),
@@ -26,11 +39,13 @@ jest.mock('@open-mercato/shared/lib/di/container', () => ({
26
39
  const mockRead = jest.fn()
27
40
  const mockDelete = jest.fn()
28
41
  const mockGetSignedUrl = jest.fn()
42
+ const mockListObjects = jest.fn()
29
43
  jest.mock('../lib/s3-driver', () => ({
30
44
  S3StorageDriver: jest.fn().mockImplementation(() => ({
31
45
  read: (...args: unknown[]) => mockRead(...args),
32
46
  delete: (...args: unknown[]) => mockDelete(...args),
33
47
  getSignedUrl: (...args: unknown[]) => mockGetSignedUrl(...args),
48
+ listObjects: (...args: unknown[]) => mockListObjects(...args),
34
49
  })),
35
50
  }))
36
51
 
@@ -58,6 +73,11 @@ describe('storage_s3 standalone route key scope', () => {
58
73
  mockRead.mockReset()
59
74
  mockDelete.mockReset()
60
75
  mockGetSignedUrl.mockReset()
76
+ mockReserve.mockReset()
77
+ mockReconcileStandaloneObjects.mockReset()
78
+ mockReconcileStandaloneObjects.mockResolvedValue(undefined)
79
+ mockListObjects.mockResolvedValue({ files: [], truncated: false })
80
+ quotaServiceRegistered = false
61
81
  })
62
82
 
63
83
  it('downloads shared namespace objects for an authorized storage manager', async () => {
@@ -105,6 +125,65 @@ describe('storage_s3 standalone route key scope', () => {
105
125
  expect(mockGetSignedUrl).not.toHaveBeenCalled()
106
126
  })
107
127
 
128
+ // Regression guards for the quota-admission responses #4076 shipped unlocalized
129
+ // (#4926, #4995, #5000 — the same shape three times). This suite mocks
130
+ // `resolveTranslations`, so a localized body reads `translated:<key>` while a
131
+ // hardcoded English string does not, which is what lets the guard live next to
132
+ // the route instead of only in the app-level consumer suite one layer up.
133
+ describe('signed upload URL quota-admission responses', () => {
134
+ const OWN_KEY = 'docs/org_org-1/tenant_tenant-1/mine.txt'
135
+
136
+ function uploadRequest(): Request {
137
+ return jsonRequest({ key: OWN_KEY, operation: 'upload', expiresIn: 600 })
138
+ }
139
+
140
+ it('localizes the missing quota service response', async () => {
141
+ const response = await POST(uploadRequest())
142
+
143
+ expect(response.status).toBe(500)
144
+ await expect(response.json()).resolves.toEqual({
145
+ error: 'translated:storage_s3.errors.quotaUnavailable',
146
+ })
147
+ expect(mockReserve).not.toHaveBeenCalled()
148
+ })
149
+
150
+ it('localizes the exceeded tenant quota response', async () => {
151
+ quotaServiceRegistered = true
152
+ mockReserve.mockRejectedValueOnce(Object.assign(new Error('quota'), { code: 'quota_exceeded' }))
153
+
154
+ const response = await POST(uploadRequest())
155
+
156
+ expect(response.status).toBe(413)
157
+ await expect(response.json()).resolves.toEqual({
158
+ error: 'translated:storage_s3.errors.quotaExceeded',
159
+ })
160
+ })
161
+
162
+ it('localizes the existing target key response', async () => {
163
+ quotaServiceRegistered = true
164
+ mockReserve.mockRejectedValueOnce(Object.assign(new Error('exists'), { code: 'quota_target_exists' }))
165
+
166
+ const response = await POST(uploadRequest())
167
+
168
+ expect(response.status).toBe(409)
169
+ await expect(response.json()).resolves.toEqual({
170
+ error: 'translated:storage_s3.errors.quotaTargetExists',
171
+ })
172
+ })
173
+
174
+ it('localizes the reservation failure response', async () => {
175
+ quotaServiceRegistered = true
176
+ mockReserve.mockRejectedValueOnce(new Error('reservation backend down'))
177
+
178
+ const response = await POST(uploadRequest())
179
+
180
+ expect(response.status).toBe(500)
181
+ await expect(response.json()).resolves.toEqual({
182
+ error: 'translated:storage_s3.errors.quotaUnavailable',
183
+ })
184
+ })
185
+ })
186
+
108
187
  it('localizes invalid delete payload errors', async () => {
109
188
  const response = await DELETE(new Request('http://example.test/api/storage-providers/s3/delete', {
110
189
  method: 'DELETE',
@@ -12,9 +12,21 @@ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
12
12
  }))
13
13
 
14
14
  const mockResolveCredentials = jest.fn()
15
+ const mockReserve = jest.fn()
16
+ const mockReconcileStandaloneObjects = jest.fn()
17
+ const mockRelease = jest.fn()
18
+ let quotaServiceRegistered = false
15
19
  const mockCreateRequestContainer = jest.fn(async () => ({
16
20
  resolve: (key: string) => {
17
21
  if (key === 'integrationCredentialsService') return { resolve: mockResolveCredentials }
22
+ if (quotaServiceRegistered && key === 'attachmentQuotaService') {
23
+ return {
24
+ reserve: (...args: unknown[]) => mockReserve(...args),
25
+ reconcileStandaloneObjects: (...args: unknown[]) => mockReconcileStandaloneObjects(...args),
26
+ release: (...args: unknown[]) => mockRelease(...args),
27
+ }
28
+ }
29
+ if (quotaServiceRegistered && key === 'storageS3QuotaRecoveryScheduler') return jest.fn()
18
30
  throw new Error(`Unexpected dependency: ${key}`)
19
31
  },
20
32
  }))
@@ -43,6 +55,8 @@ describe('storage_s3 direct upload route', () => {
43
55
  mockResolveCredentials.mockResolvedValue({ bucket: 'test-bucket', region: 'eu-central-1' })
44
56
  mockListObjects.mockResolvedValue({ files: [], truncated: false })
45
57
  mockPutObject.mockResolvedValue(undefined)
58
+ mockReconcileStandaloneObjects.mockResolvedValue(undefined)
59
+ quotaServiceRegistered = false
46
60
  delete process.env.OM_ATTACHMENT_MAX_UPLOAD_MB
47
61
  })
48
62
 
@@ -90,4 +104,63 @@ describe('storage_s3 direct upload route', () => {
90
104
  await expect(response.json()).resolves.toMatchObject({ bucket: 'test-bucket', size: 4 })
91
105
  expect(mockPutObject).toHaveBeenCalledTimes(1)
92
106
  })
107
+
108
+ // Regression guards for the quota-admission responses #4076 shipped unlocalized
109
+ // (#4926, #4995, #5000). The suite mocks `resolveTranslations`, so a localized
110
+ // body reads `translated:<key>` while a hardcoded English string does not.
111
+ describe('quota-admission error responses', () => {
112
+ function uploadRequest(): Request {
113
+ const form = new FormData()
114
+ form.set('file', new File([new TextEncoder().encode('safe')], 'safe.txt', { type: 'text/plain' }))
115
+ return new Request('http://example.test/api/storage-providers/s3/upload', { method: 'POST', body: form })
116
+ }
117
+
118
+ it('localizes the persist failure response', async () => {
119
+ mockPutObject.mockRejectedValueOnce(new Error('s3 unreachable'))
120
+
121
+ const response = await POST(uploadRequest())
122
+
123
+ expect(response.status).toBe(500)
124
+ await expect(response.json()).resolves.toEqual({
125
+ error: 'translated:storage_s3.errors.persistFailed',
126
+ })
127
+ })
128
+
129
+ it('localizes the exceeded tenant quota response', async () => {
130
+ quotaServiceRegistered = true
131
+ mockReserve.mockRejectedValueOnce(Object.assign(new Error('quota'), { code: 'quota_exceeded' }))
132
+
133
+ const response = await POST(uploadRequest())
134
+
135
+ expect(response.status).toBe(413)
136
+ await expect(response.json()).resolves.toEqual({
137
+ error: 'translated:storage_s3.errors.quotaExceeded',
138
+ })
139
+ expect(mockPutObject).not.toHaveBeenCalled()
140
+ })
141
+
142
+ it('localizes the existing target key response', async () => {
143
+ quotaServiceRegistered = true
144
+ mockReserve.mockRejectedValueOnce(Object.assign(new Error('exists'), { code: 'quota_target_exists' }))
145
+
146
+ const response = await POST(uploadRequest())
147
+
148
+ expect(response.status).toBe(409)
149
+ await expect(response.json()).resolves.toEqual({
150
+ error: 'translated:storage_s3.errors.quotaTargetExists',
151
+ })
152
+ })
153
+
154
+ it('localizes the reservation failure response', async () => {
155
+ quotaServiceRegistered = true
156
+ mockReserve.mockRejectedValueOnce(new Error('reservation backend down'))
157
+
158
+ const response = await POST(uploadRequest())
159
+
160
+ expect(response.status).toBe(500)
161
+ await expect(response.json()).resolves.toEqual({
162
+ error: 'translated:storage_s3.errors.quotaUnavailable',
163
+ })
164
+ })
165
+ })
93
166
  })
@@ -85,7 +85,7 @@ export async function POST(req: Request) {
85
85
  // Fail closed below when the attachments quota service is not registered.
86
86
  }
87
87
  if (!attachmentQuotaService) {
88
- return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })
88
+ return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', 'Storage quota accounting is unavailable.') }, { status: 500 })
89
89
  }
90
90
  const compatibilityToken = randomBytes(32).toString('base64url')
91
91
  const reservedBytes = size ?? resolveAttachmentMaxBytes()
@@ -107,7 +107,7 @@ export async function POST(req: Request) {
107
107
  uploadTokenHash: createHash('sha256').update(compatibilityToken).digest('hex'),
108
108
  ttlMs: expiresIn * 1000,
109
109
  })
110
- if (!recoveryScheduler) throw new Error('Storage quota recovery is unavailable.')
110
+ if (!recoveryScheduler) throw new Error('[internal] Storage quota recovery is unavailable.')
111
111
  await recoveryScheduler({
112
112
  reservationId: reservation.id,
113
113
  tenantId: auth.tenantId,
@@ -121,12 +121,12 @@ export async function POST(req: Request) {
121
121
  }
122
122
  const code = (error as { code?: unknown })?.code
123
123
  if (code === 'quota_exceeded') {
124
- return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })
124
+ return NextResponse.json({ error: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })
125
125
  }
126
126
  if (code === 'quota_target_exists') {
127
- return NextResponse.json({ error: 'The target storage key already exists.' }, { status: 409 })
127
+ return NextResponse.json({ error: t('storage_s3.errors.quotaTargetExists', 'The target storage key already exists.') }, { status: 409 })
128
128
  }
129
- return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })
129
+ return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', 'Storage quota accounting is unavailable.') }, { status: 500 })
130
130
  }
131
131
  }
132
132
 
@@ -168,7 +168,7 @@ export async function POST(req: Request) {
168
168
  storageDriver: 's3',
169
169
  storagePath: key,
170
170
  })
171
- if (!recoveryScheduler) throw new Error('Storage quota recovery is unavailable.')
171
+ if (!recoveryScheduler) throw new Error('[internal] Storage quota recovery is unavailable.')
172
172
  await recoveryScheduler({
173
173
  reservationId: reservation.id,
174
174
  tenantId: auth.tenantId,
@@ -211,7 +211,7 @@ export async function POST(req: Request) {
211
211
  // Retain the reservation when object absence cannot be proven.
212
212
  }
213
213
  }
214
- return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })
214
+ return NextResponse.json({ error: t('storage_s3.errors.persistFailed', 'Failed to persist attachment.') }, { status: 500 })
215
215
  }
216
216
 
217
217
  return NextResponse.json({
@@ -4,6 +4,7 @@ import { NextResponse } from 'next/server'
4
4
  import { z } from 'zod'
5
5
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
6
6
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
7
8
  import {
8
9
  detectAttachmentMimeType,
9
10
  hasDangerousExecutableExtension,
@@ -33,7 +34,7 @@ async function readBoundedBody(req: Request, maxBytes: number): Promise<Buffer>
33
34
  totalBytes += value.byteLength
34
35
  if (totalBytes > maxBytes) {
35
36
  await reader.cancel()
36
- throw new UploadBodyTooLargeError('Attachment exceeds the reserved upload size.')
37
+ throw new UploadBodyTooLargeError('[internal] Attachment exceeds the reserved upload size.')
37
38
  }
38
39
  chunks.push(Buffer.from(value))
39
40
  }
@@ -44,9 +45,10 @@ async function readBoundedBody(req: Request, maxBytes: number): Promise<Buffer>
44
45
  }
45
46
 
46
47
  export async function PUT(req: Request, ctx: RouteContext) {
48
+ const { t } = await resolveTranslations()
47
49
  const params = ctx.params instanceof Promise ? await ctx.params : ctx.params
48
50
  const token = params?.token?.trim()
49
- if (!token) return NextResponse.json({ error: 'Upload token is required.' }, { status: 400 })
51
+ if (!token) return NextResponse.json({ error: t('storage_s3.errors.uploadTokenRequired', 'Upload token is required.') }, { status: 400 })
50
52
 
51
53
  const { resolve } = await createRequestContainer()
52
54
  let quotaService: AttachmentQuotaService | null = null
@@ -55,19 +57,19 @@ export async function PUT(req: Request, ctx: RouteContext) {
55
57
  } catch {
56
58
  // Fail closed below when the attachments quota service is not registered.
57
59
  }
58
- if (!quotaService) return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })
60
+ if (!quotaService) return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', 'Storage quota accounting is unavailable.') }, { status: 500 })
59
61
 
60
62
  const tokenHash = createHash('sha256').update(token).digest('hex')
61
63
  const reservation = await quotaService.claimPendingByUploadTokenHash(tokenHash)
62
64
  if (!reservation || reservation.source !== 'storage_s3_signed') {
63
- return NextResponse.json({ error: 'Upload token is invalid or expired.' }, { status: 410 })
65
+ return NextResponse.json({ error: t('storage_s3.errors.uploadTokenInvalid', 'Upload token is invalid or expired.') }, { status: 410 })
64
66
  }
65
67
 
66
68
  const declaredLengthHeader = req.headers.get('content-length')
67
69
  const declaredLength = declaredLengthHeader == null ? null : Number(declaredLengthHeader)
68
70
  if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > reservation.reservedBytes) {
69
71
  await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
70
- return NextResponse.json({ error: 'Attachment exceeds the reserved upload size.' }, { status: 413 })
72
+ return NextResponse.json({ error: t('storage_s3.errors.reservedSizeExceeded', 'Attachment exceeds the reserved upload size.') }, { status: 413 })
71
73
  }
72
74
  let buffer: Buffer
73
75
  try {
@@ -75,23 +77,23 @@ export async function PUT(req: Request, ctx: RouteContext) {
75
77
  } catch (error) {
76
78
  await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
77
79
  if (error instanceof UploadBodyTooLargeError) {
78
- return NextResponse.json({ error: error.message }, { status: 413 })
80
+ return NextResponse.json({ error: t('storage_s3.errors.reservedSizeExceeded', 'Attachment exceeds the reserved upload size.') }, { status: 413 })
79
81
  }
80
- return NextResponse.json({ error: 'Failed to read attachment.' }, { status: 400 })
82
+ return NextResponse.json({ error: t('storage_s3.errors.readFailed', 'Failed to read attachment.') }, { status: 400 })
81
83
  }
82
84
  if (buffer.length > reservation.reservedBytes) {
83
85
  await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
84
- return NextResponse.json({ error: 'Attachment exceeds the reserved upload size.' }, { status: 413 })
86
+ return NextResponse.json({ error: t('storage_s3.errors.reservedSizeExceeded', 'Attachment exceeds the reserved upload size.') }, { status: 413 })
85
87
  }
86
88
  const fileName = path.posix.basename(reservation.storagePath)
87
89
  if (hasDangerousExecutableExtension(fileName)) {
88
90
  await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
89
- return NextResponse.json({ error: 'Executable file types are not allowed as attachments.' }, { status: 400 })
91
+ return NextResponse.json({ error: t('storage_s3.errors.dangerousExecutable', 'Executable file types are not allowed as attachments.') }, { status: 400 })
90
92
  }
91
93
  const contentType = detectAttachmentMimeType(buffer, fileName, req.headers.get('content-type'))
92
94
  if (isActiveContentAttachment(buffer, fileName, contentType)) {
93
95
  await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
94
- return NextResponse.json({ error: 'Active content uploads are not allowed.' }, { status: 400 })
96
+ return NextResponse.json({ error: t('storage_s3.errors.activeContentBlocked', 'Active content uploads are not allowed.') }, { status: 400 })
95
97
  }
96
98
 
97
99
  const credentialsService = resolve('integrationCredentialsService') as {
@@ -103,7 +105,7 @@ export async function PUT(req: Request, ctx: RouteContext) {
103
105
  })
104
106
  if (!credentials) {
105
107
  await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
106
- return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
108
+ return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
107
109
  }
108
110
 
109
111
  const driver = new S3StorageDriver({
@@ -127,7 +129,7 @@ export async function PUT(req: Request, ctx: RouteContext) {
127
129
  // Retain the reservation when object absence cannot be proven.
128
130
  }
129
131
  }
130
- return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })
132
+ return NextResponse.json({ error: t('storage_s3.errors.persistFailed', 'Failed to persist attachment.') }, { status: 500 })
131
133
  }
132
134
  }
133
135
 
@@ -11,9 +11,14 @@
11
11
  "storage_s3.errors.keyRequired": "Der Abfrageparameter „key“ ist erforderlich",
12
12
  "storage_s3.errors.maxUploadSize": "Der Anhang überschreitet die maximal zulässige Upload-Größe.",
13
13
  "storage_s3.errors.multipartRequired": "multipart/form-data wurde erwartet",
14
+ "storage_s3.errors.persistFailed": "Der Anhang konnte nicht gespeichert werden.",
14
15
  "storage_s3.errors.prefixAccessDenied": "Zugriff verweigert: Das Präfix ist nicht auf diesen Tenant beschränkt.",
15
16
  "storage_s3.errors.quotaExceeded": "Das Speicherkontingent für Anhänge dieses Mandanten ist erschöpft.",
16
17
  "storage_s3.errors.quotaTargetExists": "Der Ziel-Speicherschlüssel existiert bereits.",
17
18
  "storage_s3.errors.quotaUnavailable": "Die Kontingentabrechnung des Speichers ist nicht verfügbar.",
18
- "storage_s3.errors.unauthorized": "Nicht autorisiert"
19
+ "storage_s3.errors.readFailed": "Der Anhang konnte nicht gelesen werden.",
20
+ "storage_s3.errors.reservedSizeExceeded": "Der Anhang überschreitet die reservierte Upload-Größe.",
21
+ "storage_s3.errors.unauthorized": "Nicht autorisiert",
22
+ "storage_s3.errors.uploadTokenInvalid": "Das Upload-Token ist ungültig oder abgelaufen.",
23
+ "storage_s3.errors.uploadTokenRequired": "Ein Upload-Token ist erforderlich."
19
24
  }
@@ -11,9 +11,14 @@
11
11
  "storage_s3.errors.keyRequired": "key query param is required",
12
12
  "storage_s3.errors.maxUploadSize": "Attachment exceeds the maximum upload size.",
13
13
  "storage_s3.errors.multipartRequired": "Expected multipart/form-data",
14
+ "storage_s3.errors.persistFailed": "Failed to persist attachment.",
14
15
  "storage_s3.errors.prefixAccessDenied": "Access denied: prefix is not scoped to this tenant.",
15
16
  "storage_s3.errors.quotaExceeded": "Attachment storage quota exceeded for this tenant.",
16
17
  "storage_s3.errors.quotaTargetExists": "The target storage key already exists.",
17
18
  "storage_s3.errors.quotaUnavailable": "Storage quota accounting is unavailable.",
18
- "storage_s3.errors.unauthorized": "Unauthorized"
19
+ "storage_s3.errors.readFailed": "Failed to read attachment.",
20
+ "storage_s3.errors.reservedSizeExceeded": "Attachment exceeds the reserved upload size.",
21
+ "storage_s3.errors.unauthorized": "Unauthorized",
22
+ "storage_s3.errors.uploadTokenInvalid": "Upload token is invalid or expired.",
23
+ "storage_s3.errors.uploadTokenRequired": "Upload token is required."
19
24
  }
@@ -11,9 +11,14 @@
11
11
  "storage_s3.errors.keyRequired": "Se requiere el parámetro de consulta key",
12
12
  "storage_s3.errors.maxUploadSize": "El archivo adjunto supera el tamaño máximo de carga permitido.",
13
13
  "storage_s3.errors.multipartRequired": "Se esperaba multipart/form-data",
14
+ "storage_s3.errors.persistFailed": "No se pudo guardar el archivo adjunto.",
14
15
  "storage_s3.errors.prefixAccessDenied": "Acceso denegado: el prefijo no está limitado a este tenant.",
15
16
  "storage_s3.errors.quotaExceeded": "Se superó la cuota de almacenamiento de adjuntos para este inquilino.",
16
17
  "storage_s3.errors.quotaTargetExists": "La clave de almacenamiento de destino ya existe.",
17
18
  "storage_s3.errors.quotaUnavailable": "La contabilidad de la cuota de almacenamiento no está disponible.",
18
- "storage_s3.errors.unauthorized": "No autorizado"
19
+ "storage_s3.errors.readFailed": "No se pudo leer el archivo adjunto.",
20
+ "storage_s3.errors.reservedSizeExceeded": "El archivo adjunto supera el tamaño de carga reservado.",
21
+ "storage_s3.errors.unauthorized": "No autorizado",
22
+ "storage_s3.errors.uploadTokenInvalid": "El token de carga no es válido o ha caducado.",
23
+ "storage_s3.errors.uploadTokenRequired": "Se requiere un token de carga."
19
24
  }
@@ -11,9 +11,14 @@
11
11
  "storage_s3.errors.keyRequired": "주요 쿼리 매개변수가 필요합니다.",
12
12
  "storage_s3.errors.maxUploadSize": "첨부파일이 최대 업로드 크기를 초과합니다.",
13
13
  "storage_s3.errors.multipartRequired": "예상되는 멀티파트/양식 데이터",
14
+ "storage_s3.errors.persistFailed": "첨부파일을 저장하지 못했습니다.",
14
15
  "storage_s3.errors.prefixAccessDenied": "액세스 거부됨: 접두사의 범위가 이 테넌트로 지정되지 않았습니다.",
15
16
  "storage_s3.errors.quotaExceeded": "이 테넌트의 첨부 파일 저장 용량 한도를 초과했습니다.",
16
17
  "storage_s3.errors.quotaTargetExists": "대상 저장 키가 이미 존재합니다.",
17
18
  "storage_s3.errors.quotaUnavailable": "저장 용량 정산을 사용할 수 없습니다.",
18
- "storage_s3.errors.unauthorized": "승인되지 않은"
19
+ "storage_s3.errors.readFailed": "첨부파일을 읽지 못했습니다.",
20
+ "storage_s3.errors.reservedSizeExceeded": "첨부파일이 예약된 업로드 크기를 초과합니다.",
21
+ "storage_s3.errors.unauthorized": "승인되지 않은",
22
+ "storage_s3.errors.uploadTokenInvalid": "업로드 토큰이 유효하지 않거나 만료되었습니다.",
23
+ "storage_s3.errors.uploadTokenRequired": "업로드 토큰이 필요합니다."
19
24
  }
@@ -11,9 +11,14 @@
11
11
  "storage_s3.errors.keyRequired": "Wymagany jest parametr zapytania key",
12
12
  "storage_s3.errors.maxUploadSize": "Załącznik przekracza maksymalny dopuszczalny rozmiar przesyłania.",
13
13
  "storage_s3.errors.multipartRequired": "Oczekiwano multipart/form-data",
14
+ "storage_s3.errors.persistFailed": "Nie udało się zapisać załącznika.",
14
15
  "storage_s3.errors.prefixAccessDenied": "Odmowa dostępu: prefiks nie jest ograniczony do tego tenantu.",
15
16
  "storage_s3.errors.quotaExceeded": "Przekroczono limit przestrzeni na załączniki dla tego najemcy.",
16
17
  "storage_s3.errors.quotaTargetExists": "Docelowy klucz w magazynie już istnieje.",
17
18
  "storage_s3.errors.quotaUnavailable": "Rozliczanie limitu magazynu jest niedostępne.",
18
- "storage_s3.errors.unauthorized": "Brak autoryzacji"
19
+ "storage_s3.errors.readFailed": "Nie udało się odczytać załącznika.",
20
+ "storage_s3.errors.reservedSizeExceeded": "Załącznik przekracza zarezerwowany rozmiar przesyłania.",
21
+ "storage_s3.errors.unauthorized": "Brak autoryzacji",
22
+ "storage_s3.errors.uploadTokenInvalid": "Token przesyłania jest nieprawidłowy lub wygasł.",
23
+ "storage_s3.errors.uploadTokenRequired": "Token przesyłania jest wymagany."
19
24
  }