@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
@@ -1,2 +1,2 @@
1
- Found 24 entry points
1
+ Found 19 entry points
2
2
  storage-s3 built successfully
@@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
3
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
4
4
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
5
- import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
6
5
  import { isS3KeyAddressableByScope } from "../../../../lib/key-scope.js";
7
6
  import { S3StorageDriver } from "../../../../lib/s3-driver.js";
8
7
  const metadata = {
@@ -20,37 +19,23 @@ async function resolveDriver(tenantId, orgId) {
20
19
  return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId });
21
20
  }
22
21
  async function DELETE(req) {
23
- const { t } = await resolveTranslations();
24
22
  const auth = await getAuthFromRequest(req);
25
23
  if (!auth?.tenantId || !auth.orgId) {
26
- return NextResponse.json({ error: t("storage_s3.errors.unauthorized", "Unauthorized") }, { status: 401 });
24
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
27
25
  }
28
26
  const json = await req.json().catch(() => null);
29
27
  const parsed = requestSchema.safeParse(json);
30
28
  if (!parsed.success) {
31
- return NextResponse.json({ error: t("storage_s3.errors.invalidPayload", "Invalid payload") }, { status: 400 });
29
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
32
30
  }
33
31
  if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {
34
- return NextResponse.json({ error: t("storage_s3.errors.keyAccessDenied", "Access denied: key is not scoped to this tenant.") }, { status: 403 });
32
+ return NextResponse.json({ error: "Access denied: key is not scoped to this tenant." }, { status: 403 });
35
33
  }
36
34
  const driver = await resolveDriver(auth.tenantId, auth.orgId);
37
35
  if (!driver) {
38
- return NextResponse.json({ error: t("storage_s3.errors.integrationNotConfigured", "S3 integration is not configured.") }, { status: 400 });
39
- }
40
- await (driver.deleteStrict?.("", parsed.data.key) ?? driver.delete("", parsed.data.key));
41
- const { resolve } = await createRequestContainer();
42
- let attachmentQuotaService = null;
43
- try {
44
- attachmentQuotaService = resolve("attachmentQuotaService");
45
- } catch {
46
- }
47
- if (attachmentQuotaService) {
48
- await attachmentQuotaService.releaseCommittedByPath({
49
- tenantId: auth.tenantId,
50
- storageDriver: "s3",
51
- storagePath: parsed.data.key
52
- });
36
+ return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
53
37
  }
38
+ await driver.delete("", parsed.data.key);
54
39
  return new NextResponse(null, { status: 204 });
55
40
  }
56
41
  var delete_default = DELETE;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../src/modules/storage_s3/api/delete/storage-providers/s3/delete.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 } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\n\nexport const metadata = {\n path: '/storage-providers/s3/delete',\n DELETE: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\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 DELETE(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 if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {\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 await (driver.deleteStrict?.('', parsed.data.key) ?? driver.delete('', parsed.data.key))\n const { resolve } = await createRequestContainer()\n let attachmentQuotaService: AttachmentQuotaService | null = null\n try {\n attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService | null\n } catch {\n // Backward-compatible when the attachments quota service is not registered.\n }\n if (attachmentQuotaService) {\n await attachmentQuotaService.releaseCommittedByPath({\n tenantId: auth.tenantId,\n storageDriver: 's3',\n storagePath: parsed.data.key,\n })\n }\n return new NextResponse(null, { status: 204 })\n}\n\nexport default DELETE\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Delete file from S3',\n methods: {\n DELETE: {\n summary: 'Delete a file from S3 by key',\n description: 'Permanently removes the object at the given key from the configured S3 bucket.',\n requestBody: { contentType: 'application/json', schema: requestSchema },\n responses: [{ status: 204, description: 'File deleted', schema: z.null() }],\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 { isS3KeyAddressableByScope } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/delete',\n DELETE: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\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 DELETE(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: '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: 'Invalid payload' }, { status: 400 })\n }\n\n if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {\n return NextResponse.json({ error: '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: 'S3 integration is not configured.' }, { status: 400 })\n }\n\n await driver.delete('', parsed.data.key)\n return new NextResponse(null, { status: 204 })\n}\n\nexport default DELETE\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Delete file from S3',\n methods: {\n DELETE: {\n summary: 'Delete a file from S3 by key',\n description: 'Permanently removes the object at the given key from the configured S3 bucket.',\n requestBody: { contentType: 'application/json', schema: requestSchema },\n responses: [{ status: 204, description: 'File deleted', schema: z.null() }],\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": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,iCAAiC;AAC1C,SAAS,uBAAuB;AAEzB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,QAAQ,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC7E;AAEA,MAAM,gBAAgB,EAAE,OAAO;AAAA,EAC7B,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC;AACvB,CAAC;AAED,eAAe,cAAc,UAAkB,OAAgD;AAC7F,QAAM,EAAE,QAAQ,IAAI,MAAM,uBAAuB;AACjD,QAAM,qBAAqB,QAAQ,+BAA+B;AAGlE,QAAM,QAAQ,MAAM,mBAAmB,QAAQ,cAAc,EAAE,UAAU,gBAAgB,MAAM,CAAC;AAChG,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,IAAI,gBAAgB,EAAE,GAAG,OAAO,gBAAgB,OAAO,SAAS,CAAC;AAC1E;AAEA,eAAsB,OAAO,KAAc;AACzC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,YAAY,CAAC,KAAK,OAAO;AAClC,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,QAAM,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI;AAC9C,QAAM,SAAS,cAAc,UAAU,IAAI;AAC3C,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,kBAAkB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACxE;AAEA,MAAI,CAAC,0BAA0B,OAAO,KAAK,KAAK,KAAK,OAAO,KAAK,QAAQ,GAAG;AAC1E,WAAO,aAAa,KAAK,EAAE,OAAO,mDAAmD,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACzG;AAEA,QAAM,SAAS,MAAM,cAAc,KAAK,UAAU,KAAK,KAAK;AAC5D,MAAI,CAAC,QAAQ;AACX,WAAO,aAAa,KAAK,EAAE,OAAO,oCAAoC,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC1F;AAEA,QAAM,OAAO,OAAO,IAAI,OAAO,KAAK,GAAG;AACvC,SAAO,IAAI,aAAa,MAAM,EAAE,QAAQ,IAAI,CAAC;AAC/C;AAEA,IAAO,iBAAQ;AAER,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,QAAQ;AAAA,MACN,SAAS;AAAA,MACT,aAAa;AAAA,MACb,aAAa,EAAE,aAAa,oBAAoB,QAAQ,cAAc;AAAA,MACtE,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,EAAE,KAAK,EAAE,CAAC;AAAA,MAC1E,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,wCAAwC,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QAC5G,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QACpF,EAAE,QAAQ,KAAK,aAAa,iCAAiC,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,MACvG;AAAA,IACF;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
@@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
3
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
4
4
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
5
- import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
6
5
  import {
7
6
  buildAttachmentContentDisposition,
8
7
  canRenderInlineAttachment,
@@ -22,21 +21,20 @@ async function resolveDriver(tenantId, orgId) {
22
21
  return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId });
23
22
  }
24
23
  async function GET(req) {
25
- const { t } = await resolveTranslations();
26
24
  const auth = await getAuthFromRequest(req);
27
25
  if (!auth?.tenantId || !auth.orgId) {
28
- return NextResponse.json({ error: t("storage_s3.errors.unauthorized", "Unauthorized") }, { status: 401 });
26
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
29
27
  }
30
28
  const key = new URL(req.url).searchParams.get("key");
31
29
  if (!key) {
32
- return NextResponse.json({ error: t("storage_s3.errors.keyRequired", "key query param is required") }, { status: 400 });
30
+ return NextResponse.json({ error: "key query param is required" }, { status: 400 });
33
31
  }
34
32
  if (!isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)) {
35
- return NextResponse.json({ error: t("storage_s3.errors.keyAccessDenied", "Access denied: key is not scoped to this tenant.") }, { status: 403 });
33
+ return NextResponse.json({ error: "Access denied: key is not scoped to this tenant." }, { status: 403 });
36
34
  }
37
35
  const driver = await resolveDriver(auth.tenantId, auth.orgId);
38
36
  if (!driver) {
39
- return NextResponse.json({ error: t("storage_s3.errors.integrationNotConfigured", "S3 integration is not configured.") }, { status: 400 });
37
+ return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
40
38
  }
41
39
  let buffer;
42
40
  let contentType;
@@ -45,7 +43,7 @@ async function GET(req) {
45
43
  buffer = result.buffer;
46
44
  contentType = result.contentType;
47
45
  } catch {
48
- return NextResponse.json({ error: t("storage_s3.errors.fileNotFound", "File not found") }, { status: 404 });
46
+ return NextResponse.json({ error: "File not found" }, { status: 404 });
49
47
  }
50
48
  const fileName = sanitizeUploadedFileName(key.split("/").pop() || "download");
51
49
  const renderInline = canRenderInlineAttachment(contentType);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../src/modules/storage_s3/api/get/storage-providers/s3/download.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 buildAttachmentContentDisposition,\n canRenderInlineAttachment,\n sanitizeUploadedFileName,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport { isS3KeyAddressableByScope } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/download',\n GET: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\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 GET(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 key = new URL(req.url).searchParams.get('key')\n if (!key) {\n return NextResponse.json({ error: t('storage_s3.errors.keyRequired', 'key query param is required') }, { status: 400 })\n }\n\n if (!isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)) {\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 let buffer: Buffer\n let contentType: string | undefined\n try {\n const result = await driver.read('', key)\n buffer = result.buffer\n contentType = result.contentType\n } catch {\n return NextResponse.json({ error: t('storage_s3.errors.fileNotFound', 'File not found') }, { status: 404 })\n }\n\n const fileName = sanitizeUploadedFileName(key.split('/').pop() || 'download')\n const renderInline = canRenderInlineAttachment(contentType)\n const responseContentType = renderInline ? (contentType ?? 'application/octet-stream') : 'application/octet-stream'\n\n return new NextResponse(new Uint8Array(buffer), {\n status: 200,\n headers: {\n 'Content-Security-Policy': \"default-src 'none'; sandbox\",\n 'Content-Disposition': buildAttachmentContentDisposition(fileName, renderInline ? 'inline' : 'attachment'),\n 'Content-Length': String(buffer.length),\n 'Content-Type': responseContentType,\n 'X-Content-Type-Options': 'nosniff',\n },\n })\n}\n\nexport default GET\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Download file from S3',\n methods: {\n GET: {\n summary: 'Download a file from S3 by key',\n description: 'Streams the file content for the given S3 key. Requires storage_providers.manage feature.',\n query: z.object({ key: z.string().describe('S3 object key') }),\n responses: [{ status: 200, description: 'File content stream', schema: z.any() }],\n errors: [\n { status: 400, description: 'Missing key 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 { status: 404, description: 'File not found', 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 {\n buildAttachmentContentDisposition,\n canRenderInlineAttachment,\n sanitizeUploadedFileName,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport { isS3KeyAddressableByScope } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/download',\n GET: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\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 GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const key = new URL(req.url).searchParams.get('key')\n if (!key) {\n return NextResponse.json({ error: 'key query param is required' }, { status: 400 })\n }\n\n if (!isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)) {\n return NextResponse.json({ error: '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: 'S3 integration is not configured.' }, { status: 400 })\n }\n\n let buffer: Buffer\n let contentType: string | undefined\n try {\n const result = await driver.read('', key)\n buffer = result.buffer\n contentType = result.contentType\n } catch {\n return NextResponse.json({ error: 'File not found' }, { status: 404 })\n }\n\n const fileName = sanitizeUploadedFileName(key.split('/').pop() || 'download')\n const renderInline = canRenderInlineAttachment(contentType)\n const responseContentType = renderInline ? (contentType ?? 'application/octet-stream') : 'application/octet-stream'\n\n return new NextResponse(new Uint8Array(buffer), {\n status: 200,\n headers: {\n 'Content-Security-Policy': \"default-src 'none'; sandbox\",\n 'Content-Disposition': buildAttachmentContentDisposition(fileName, renderInline ? 'inline' : 'attachment'),\n 'Content-Length': String(buffer.length),\n 'Content-Type': responseContentType,\n 'X-Content-Type-Options': 'nosniff',\n },\n })\n}\n\nexport default GET\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Download file from S3',\n methods: {\n GET: {\n summary: 'Download a file from S3 by key',\n description: 'Streams the file content for the given S3 key. Requires storage_providers.manage feature.',\n query: z.object({ key: z.string().describe('S3 object key') }),\n responses: [{ status: 200, description: 'File content stream', schema: z.any() }],\n errors: [\n { status: 400, description: 'Missing key 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 { status: 404, description: 'File not found', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
3
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
4
4
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
5
- import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
6
5
  import { S3StorageDriver } from "../../../../lib/s3-driver.js";
7
6
  const metadata = {
8
7
  path: "/storage-providers/s3/list",
@@ -52,26 +51,25 @@ function resolveTenantScopedPrefix(prefix, tenantId, orgId) {
52
51
  return tenantPrefix + namespaceRelativePrefix;
53
52
  }
54
53
  async function GET(req) {
55
- const { t } = await resolveTranslations();
56
54
  const auth = await getAuthFromRequest(req);
57
55
  if (!auth?.tenantId || !auth.orgId) {
58
- return NextResponse.json({ error: t("storage_s3.errors.unauthorized", "Unauthorized") }, { status: 401 });
56
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
59
57
  }
60
58
  const params = Object.fromEntries(new URL(req.url).searchParams.entries());
61
59
  const parsed = querySchema.safeParse(params);
62
60
  if (!parsed.success) {
63
- return NextResponse.json({ error: t("storage_s3.errors.invalidQuery", "Invalid query parameters") }, { status: 400 });
61
+ return NextResponse.json({ error: "Invalid query parameters" }, { status: 400 });
64
62
  }
65
63
  const effectivePrefix = resolveTenantScopedPrefix(parsed.data.prefix, auth.tenantId, auth.orgId);
66
64
  if (effectivePrefix === null) {
67
65
  return NextResponse.json(
68
- { error: t("storage_s3.errors.prefixAccessDenied", "Access denied: prefix is not scoped to this tenant.") },
66
+ { error: "Access denied: prefix is not scoped to this tenant." },
69
67
  { status: 403 }
70
68
  );
71
69
  }
72
70
  const driver = await resolveDriver(auth.tenantId, auth.orgId);
73
71
  if (!driver) {
74
- return NextResponse.json({ error: t("storage_s3.errors.integrationNotConfigured", "S3 integration is not configured.") }, { status: 400 });
72
+ return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
75
73
  }
76
74
  const result = await driver.listObjects(
77
75
  effectivePrefix,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../src/modules/storage_s3/api/get/storage-providers/s3/list.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 { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/list',\n GET: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst querySchema = z.object({\n prefix: z.string().optional().default(''),\n maxKeys: z.coerce.number().int().min(1).max(1000).optional().default(100),\n continuationToken: z.string().optional(),\n})\n\nconst fileSchema = z.object({\n key: z.string(),\n size: z.number().int(),\n lastModified: z.string(),\n})\n\nconst responseSchema = z.object({\n files: z.array(fileSchema),\n truncated: z.boolean(),\n nextContinuationToken: z.string().optional(),\n})\n\nconst DEFAULT_LIST_NAMESPACE = 'uploads'\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\nfunction buildTenantPrefix(namespace: string, tenantId: string, orgId: string): string {\n return `${namespace}/org_${orgId}/tenant_${tenantId}/`\n}\n\nfunction resolveTenantScopedPrefix(prefix: string, tenantId: string, orgId: string): string | null {\n const normalized = prefix.replace(/^\\/+/, '')\n const parts = normalized.split('/')\n const namespace = parts[0] || DEFAULT_LIST_NAMESPACE\n const tenantPrefix = buildTenantPrefix(namespace, tenantId, orgId)\n\n if (!normalized || normalized === tenantPrefix.slice(0, -1)) {\n return tenantPrefix\n }\n\n if (normalized.startsWith(tenantPrefix)) {\n return normalized\n }\n\n if (\n parts[1]?.startsWith('org_') ||\n parts[2]?.startsWith('tenant_') ||\n normalized.includes(`org_${orgId}/tenant_${tenantId}`)\n ) {\n return null\n }\n\n const namespaceRelativePrefix = normalized.slice(namespace.length).replace(/^\\/+/, '')\n return tenantPrefix + namespaceRelativePrefix\n}\n\nexport async function GET(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 params = Object.fromEntries(new URL(req.url).searchParams.entries())\n const parsed = querySchema.safeParse(params)\n if (!parsed.success) {\n return NextResponse.json({ error: t('storage_s3.errors.invalidQuery', 'Invalid query parameters') }, { status: 400 })\n }\n\n // Always scope list operations to the tenant namespace to prevent cross-tenant enumeration.\n const effectivePrefix = resolveTenantScopedPrefix(parsed.data.prefix, auth.tenantId, auth.orgId)\n if (effectivePrefix === null) {\n return NextResponse.json(\n { error: t('storage_s3.errors.prefixAccessDenied', 'Access denied: prefix is not scoped to this tenant.') },\n { status: 403 },\n )\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 result = await driver.listObjects(\n effectivePrefix,\n parsed.data.maxKeys,\n parsed.data.continuationToken,\n )\n\n return NextResponse.json({\n files: result.files.map((f) => ({\n key: f.key,\n size: f.size,\n lastModified: f.lastModified.toISOString(),\n })),\n truncated: result.truncated,\n nextContinuationToken: result.nextContinuationToken,\n })\n}\n\nexport default GET\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'List S3 objects',\n methods: {\n GET: {\n summary: 'List files in S3 by prefix',\n description: 'Returns a paginated list of S3 objects scoped to the authenticated tenant namespace.',\n query: querySchema,\n responses: [{ status: 200, description: 'File listing', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Invalid params 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: 'Prefix not scoped to this tenant', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,2BAA2B;AACpC,SAAS,uBAAuB;AAEzB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC1E;AAEA,MAAM,cAAc,EAAE,OAAO;AAAA,EAC3B,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE;AAAA,EACxC,SAAS,EAAE,OAAO,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,GAAI,EAAE,SAAS,EAAE,QAAQ,GAAG;AAAA,EACxE,mBAAmB,EAAE,OAAO,EAAE,SAAS;AACzC,CAAC;AAED,MAAM,aAAa,EAAE,OAAO;AAAA,EAC1B,KAAK,EAAE,OAAO;AAAA,EACd,MAAM,EAAE,OAAO,EAAE,IAAI;AAAA,EACrB,cAAc,EAAE,OAAO;AACzB,CAAC;AAED,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,OAAO,EAAE,MAAM,UAAU;AAAA,EACzB,WAAW,EAAE,QAAQ;AAAA,EACrB,uBAAuB,EAAE,OAAO,EAAE,SAAS;AAC7C,CAAC;AAED,MAAM,yBAAyB;AAE/B,eAAe,cAAc,UAAkB,OAAgD;AAC7F,QAAM,EAAE,QAAQ,IAAI,MAAM,uBAAuB;AACjD,QAAM,qBAAqB,QAAQ,+BAA+B;AAGlE,QAAM,QAAQ,MAAM,mBAAmB,QAAQ,cAAc,EAAE,UAAU,gBAAgB,MAAM,CAAC;AAChG,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,IAAI,gBAAgB,EAAE,GAAG,OAAO,gBAAgB,OAAO,SAAS,CAAC;AAC1E;AAEA,SAAS,kBAAkB,WAAmB,UAAkB,OAAuB;AACrF,SAAO,GAAG,SAAS,QAAQ,KAAK,WAAW,QAAQ;AACrD;AAEA,SAAS,0BAA0B,QAAgB,UAAkB,OAA8B;AACjG,QAAM,aAAa,OAAO,QAAQ,QAAQ,EAAE;AAC5C,QAAM,QAAQ,WAAW,MAAM,GAAG;AAClC,QAAM,YAAY,MAAM,CAAC,KAAK;AAC9B,QAAM,eAAe,kBAAkB,WAAW,UAAU,KAAK;AAEjE,MAAI,CAAC,cAAc,eAAe,aAAa,MAAM,GAAG,EAAE,GAAG;AAC3D,WAAO;AAAA,EACT;AAEA,MAAI,WAAW,WAAW,YAAY,GAAG;AACvC,WAAO;AAAA,EACT;AAEA,MACE,MAAM,CAAC,GAAG,WAAW,MAAM,KAC3B,MAAM,CAAC,GAAG,WAAW,SAAS,KAC9B,WAAW,SAAS,OAAO,KAAK,WAAW,QAAQ,EAAE,GACrD;AACA,WAAO;AAAA,EACT;AAEA,QAAM,0BAA0B,WAAW,MAAM,UAAU,MAAM,EAAE,QAAQ,QAAQ,EAAE;AACrF,SAAO,eAAe;AACxB;AAEA,eAAsB,IAAI,KAAc;AACtC,QAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,YAAY,CAAC,KAAK,OAAO;AAClC,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,kCAAkC,cAAc,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC1G;AAEA,QAAM,SAAS,OAAO,YAAY,IAAI,IAAI,IAAI,GAAG,EAAE,aAAa,QAAQ,CAAC;AACzE,QAAM,SAAS,YAAY,UAAU,MAAM;AAC3C,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,kCAAkC,0BAA0B,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACtH;AAGA,QAAM,kBAAkB,0BAA0B,OAAO,KAAK,QAAQ,KAAK,UAAU,KAAK,KAAK;AAC/F,MAAI,oBAAoB,MAAM;AAC5B,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,EAAE,wCAAwC,qDAAqD,EAAE;AAAA,MAC1G,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AAEA,QAAM,SAAS,MAAM,cAAc,KAAK,UAAU,KAAK,KAAK;AAC5D,MAAI,CAAC,QAAQ;AACX,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,8CAA8C,mCAAmC,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC3I;AAEA,QAAM,SAAS,MAAM,OAAO;AAAA,IAC1B;AAAA,IACA,OAAO,KAAK;AAAA,IACZ,OAAO,KAAK;AAAA,EACd;AAEA,SAAO,aAAa,KAAK;AAAA,IACvB,OAAO,OAAO,MAAM,IAAI,CAAC,OAAO;AAAA,MAC9B,KAAK,EAAE;AAAA,MACP,MAAM,EAAE;AAAA,MACR,cAAc,EAAE,aAAa,YAAY;AAAA,IAC3C,EAAE;AAAA,IACF,WAAW,OAAO;AAAA,IAClB,uBAAuB,OAAO;AAAA,EAChC,CAAC;AACH;AAEA,IAAO,eAAQ;AAER,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aAAa;AAAA,MACb,OAAO;AAAA,MACP,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,eAAe,CAAC;AAAA,MAChF,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,uCAAuC,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QAC3G,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QACpF,EAAE,QAAQ,KAAK,aAAa,oCAAoC,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,MAC1G;AAAA,IACF;AAAA,EACF;AACF;",
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 { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/list',\n GET: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst querySchema = z.object({\n prefix: z.string().optional().default(''),\n maxKeys: z.coerce.number().int().min(1).max(1000).optional().default(100),\n continuationToken: z.string().optional(),\n})\n\nconst fileSchema = z.object({\n key: z.string(),\n size: z.number().int(),\n lastModified: z.string(),\n})\n\nconst responseSchema = z.object({\n files: z.array(fileSchema),\n truncated: z.boolean(),\n nextContinuationToken: z.string().optional(),\n})\n\nconst DEFAULT_LIST_NAMESPACE = 'uploads'\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\nfunction buildTenantPrefix(namespace: string, tenantId: string, orgId: string): string {\n return `${namespace}/org_${orgId}/tenant_${tenantId}/`\n}\n\nfunction resolveTenantScopedPrefix(prefix: string, tenantId: string, orgId: string): string | null {\n const normalized = prefix.replace(/^\\/+/, '')\n const parts = normalized.split('/')\n const namespace = parts[0] || DEFAULT_LIST_NAMESPACE\n const tenantPrefix = buildTenantPrefix(namespace, tenantId, orgId)\n\n if (!normalized || normalized === tenantPrefix.slice(0, -1)) {\n return tenantPrefix\n }\n\n if (normalized.startsWith(tenantPrefix)) {\n return normalized\n }\n\n if (\n parts[1]?.startsWith('org_') ||\n parts[2]?.startsWith('tenant_') ||\n normalized.includes(`org_${orgId}/tenant_${tenantId}`)\n ) {\n return null\n }\n\n const namespaceRelativePrefix = normalized.slice(namespace.length).replace(/^\\/+/, '')\n return tenantPrefix + namespaceRelativePrefix\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const params = Object.fromEntries(new URL(req.url).searchParams.entries())\n const parsed = querySchema.safeParse(params)\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })\n }\n\n // Always scope list operations to the tenant namespace to prevent cross-tenant enumeration.\n const effectivePrefix = resolveTenantScopedPrefix(parsed.data.prefix, auth.tenantId, auth.orgId)\n if (effectivePrefix === null) {\n return NextResponse.json(\n { error: 'Access denied: prefix is not scoped to this tenant.' },\n { status: 403 },\n )\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })\n }\n\n const result = await driver.listObjects(\n effectivePrefix,\n parsed.data.maxKeys,\n parsed.data.continuationToken,\n )\n\n return NextResponse.json({\n files: result.files.map((f) => ({\n key: f.key,\n size: f.size,\n lastModified: f.lastModified.toISOString(),\n })),\n truncated: result.truncated,\n nextContinuationToken: result.nextContinuationToken,\n })\n}\n\nexport default GET\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'List S3 objects',\n methods: {\n GET: {\n summary: 'List files in S3 by prefix',\n description: 'Returns a paginated list of S3 objects scoped to the authenticated tenant namespace.',\n query: querySchema,\n responses: [{ status: 200, description: 'File listing', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Invalid params 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: 'Prefix 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": []
7
7
  }
@@ -2,12 +2,8 @@ import { NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
3
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
4
4
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
5
- import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
6
5
  import { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from "../../../../lib/key-scope.js";
7
6
  import { S3StorageDriver } from "../../../../lib/s3-driver.js";
8
- import { createHash, randomBytes } from "node:crypto";
9
- import { resolveAttachmentMaxBytes } from "@open-mercato/core/modules/attachments/lib/upload-limits";
10
- import { reconcileTenantS3Objects } from "../../../../lib/quota-accounting.js";
11
7
  const metadata = {
12
8
  path: "/storage-providers/s3/signed-url",
13
9
  POST: { requireAuth: true, requireFeatures: ["storage_providers.manage"] }
@@ -16,13 +12,11 @@ const requestSchema = z.object({
16
12
  key: z.string().min(1),
17
13
  operation: z.enum(["upload", "download"]),
18
14
  expiresIn: z.number().int().min(60).max(604800).optional().default(3600),
19
- contentType: z.string().optional(),
20
- size: z.number().int().nonnegative().optional()
15
+ contentType: z.string().optional()
21
16
  });
22
17
  const responseSchema = z.object({
23
18
  url: z.string(),
24
- expiresAt: z.string(),
25
- reservationId: z.string().optional()
19
+ expiresAt: z.string()
26
20
  });
27
21
  async function resolveDriver(tenantId, orgId) {
28
22
  const { resolve } = await createRequestContainer();
@@ -32,82 +26,26 @@ async function resolveDriver(tenantId, orgId) {
32
26
  return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId });
33
27
  }
34
28
  async function POST(req) {
35
- const { t } = await resolveTranslations();
36
29
  const auth = await getAuthFromRequest(req);
37
30
  if (!auth?.tenantId || !auth.orgId) {
38
- return NextResponse.json({ error: t("storage_s3.errors.unauthorized", "Unauthorized") }, { status: 401 });
31
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
39
32
  }
40
33
  const json = await req.json().catch(() => null);
41
34
  const parsed = requestSchema.safeParse(json);
42
35
  if (!parsed.success) {
43
- return NextResponse.json({ error: t("storage_s3.errors.invalidPayload", "Invalid payload") }, { status: 400 });
36
+ return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
44
37
  }
45
- const { key, operation, expiresIn, contentType, size } = parsed.data;
38
+ const { key, operation, expiresIn, contentType } = parsed.data;
46
39
  const canAccessKey = operation === "download" ? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId) : isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId);
47
40
  if (!canAccessKey) {
48
- 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 });
49
42
  }
50
43
  const driver = await resolveDriver(auth.tenantId, auth.orgId);
51
44
  if (!driver) {
52
- return NextResponse.json({ error: t("storage_s3.errors.integrationNotConfigured", "S3 integration is not configured.") }, { status: 400 });
53
- }
54
- const expiresAt = new Date(Date.now() + expiresIn * 1e3).toISOString();
55
- if (operation === "upload") {
56
- const { resolve } = await createRequestContainer();
57
- let attachmentQuotaService = null;
58
- let recoveryScheduler = null;
59
- try {
60
- attachmentQuotaService = resolve("attachmentQuotaService");
61
- recoveryScheduler = resolve("storageS3QuotaRecoveryScheduler");
62
- } catch {
63
- }
64
- if (!attachmentQuotaService) {
65
- return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
66
- }
67
- const compatibilityToken = randomBytes(32).toString("base64url");
68
- const reservedBytes = size ?? resolveAttachmentMaxBytes();
69
- let reservation = null;
70
- try {
71
- await reconcileTenantS3Objects({
72
- driver,
73
- quotaService: attachmentQuotaService,
74
- tenantId: auth.tenantId,
75
- organizationId: auth.orgId
76
- });
77
- reservation = await attachmentQuotaService.reserve({
78
- tenantId: auth.tenantId,
79
- organizationId: auth.orgId,
80
- bytes: reservedBytes,
81
- source: "storage_s3_signed",
82
- storageDriver: "s3",
83
- storagePath: key,
84
- uploadTokenHash: createHash("sha256").update(compatibilityToken).digest("hex"),
85
- ttlMs: expiresIn * 1e3
86
- });
87
- if (!recoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
88
- await recoveryScheduler({
89
- reservationId: reservation.id,
90
- tenantId: auth.tenantId,
91
- organizationId: auth.orgId
92
- }, Math.max(1e3, reservation.expiresAt.getTime() - Date.now()));
93
- const url2 = new URL(`/api/storage-providers/s3/signed-upload/${compatibilityToken}`, req.url).toString();
94
- return NextResponse.json({ url: url2, expiresAt, reservationId: reservation.id });
95
- } catch (error) {
96
- if (reservation) {
97
- await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {
98
- });
99
- }
100
- const code = error?.code;
101
- if (code === "quota_exceeded") {
102
- return NextResponse.json({ error: "Attachment storage quota exceeded for this tenant." }, { status: 413 });
103
- }
104
- if (code === "quota_target_exists") {
105
- return NextResponse.json({ error: "The target storage key already exists." }, { status: 409 });
106
- }
107
- return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
108
- }
45
+ return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
109
46
  }
110
47
  const url = await driver.getSignedUrl(key, operation, expiresIn, contentType);
48
+ const expiresAt = new Date(Date.now() + expiresIn * 1e3).toISOString();
111
49
  return NextResponse.json({ url, expiresAt });
112
50
  }
113
51
  var signed_url_default = POST;
@@ -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": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,2BAA2B;AACpC,SAAS,2BAA2B,6BAA6B;AACjE,SAAS,uBAAuB;AAChC,SAAS,YAAY,mBAAmB;AACxC,SAAS,iCAAiC;AAE1C,SAAS,gCAAgC;AAElC,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC3E;AAEA,MAAM,gBAAgB,EAAE,OAAO;AAAA,EAC7B,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EACrB,WAAW,EAAE,KAAK,CAAC,UAAU,UAAU,CAAC;AAAA,EACxC,WAAW,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,IAAI,MAAM,EAAE,SAAS,EAAE,QAAQ,IAAI;AAAA,EACvE,aAAa,EAAE,OAAO,EAAE,SAAS;AAAA,EACjC,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,SAAS;AAChD,CAAC;AAED,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,KAAK,EAAE,OAAO;AAAA,EACd,WAAW,EAAE,OAAO;AAAA,EACpB,eAAe,EAAE,OAAO,EAAE,SAAS;AACrC,CAAC;AAED,eAAe,cAAc,UAAkB,OAAgD;AAC7F,QAAM,EAAE,QAAQ,IAAI,MAAM,uBAAuB;AACjD,QAAM,qBAAqB,QAAQ,+BAA+B;AAGlE,QAAM,QAAQ,MAAM,mBAAmB,QAAQ,cAAc,EAAE,UAAU,gBAAgB,MAAM,CAAC;AAChG,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,IAAI,gBAAgB,EAAE,GAAG,OAAO,gBAAgB,OAAO,SAAS,CAAC;AAC1E;AAEA,eAAsB,KAAK,KAAc;AACvC,QAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,YAAY,CAAC,KAAK,OAAO;AAClC,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,kCAAkC,cAAc,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC1G;AAEA,QAAM,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI;AAC9C,QAAM,SAAS,cAAc,UAAU,IAAI;AAC3C,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,oCAAoC,iBAAiB,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC/G;AAEA,QAAM,EAAE,KAAK,WAAW,WAAW,aAAa,KAAK,IAAI,OAAO;AAChE,QAAM,eAAe,cAAc,aAC/B,0BAA0B,KAAK,KAAK,OAAO,KAAK,QAAQ,IACxD,sBAAsB,KAAK,KAAK,OAAO,KAAK,QAAQ;AACxD,MAAI,CAAC,cAAc;AACjB,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,qCAAqC,kDAAkD,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACjJ;AAEA,QAAM,SAAS,MAAM,cAAc,KAAK,UAAU,KAAK,KAAK;AAC5D,MAAI,CAAC,QAAQ;AACX,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,8CAA8C,mCAAmC,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC3I;AAEA,QAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,YAAY,GAAI,EAAE,YAAY;AAEtE,MAAI,cAAc,UAAU;AAC1B,UAAM,EAAE,QAAQ,IAAI,MAAM,uBAAuB;AACjD,QAAI,yBAAwD;AAC5D,QAAI,oBAGyB;AAC7B,QAAI;AACF,+BAAyB,QAAQ,wBAAwB;AACzD,0BAAoB,QAAQ,iCAAiC;AAAA,IAI/D,QAAQ;AAAA,IAER;AACA,QAAI,CAAC,wBAAwB;AAC3B,aAAO,aAAa,KAAK,EAAE,OAAO,2CAA2C,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IACjG;AACA,UAAM,qBAAqB,YAAY,EAAE,EAAE,SAAS,WAAW;AAC/D,UAAM,gBAAgB,QAAQ,0BAA0B;AACxD,QAAI,cAA6E;AACjF,QAAI;AACF,YAAM,yBAAyB;AAAA,QAC7B;AAAA,QACA,cAAc;AAAA,QACd,UAAU,KAAK;AAAA,QACf,gBAAgB,KAAK;AAAA,MACvB,CAAC;AACD,oBAAc,MAAM,uBAAuB,QAAQ;AAAA,QACjD,UAAU,KAAK;AAAA,QACf,gBAAgB,KAAK;AAAA,QACrB,OAAO;AAAA,QACP,QAAQ;AAAA,QACR,eAAe;AAAA,QACf,aAAa;AAAA,QACb,iBAAiB,WAAW,QAAQ,EAAE,OAAO,kBAAkB,EAAE,OAAO,KAAK;AAAA,QAC7E,OAAO,YAAY;AAAA,MACrB,CAAC;AACD,UAAI,CAAC,kBAAmB,OAAM,IAAI,MAAM,wCAAwC;AAChF,YAAM,kBAAkB;AAAA,QACtB,eAAe,YAAY;AAAA,QAC3B,UAAU,KAAK;AAAA,QACf,gBAAgB,KAAK;AAAA,MACvB,GAAG,KAAK,IAAI,KAAO,YAAY,UAAU,QAAQ,IAAI,KAAK,IAAI,CAAC,CAAC;AAChE,YAAMA,OAAM,IAAI,IAAI,2CAA2C,kBAAkB,IAAI,IAAI,GAAG,EAAE,SAAS;AACvG,aAAO,aAAa,KAAK,EAAE,KAAAA,MAAK,WAAW,eAAe,YAAY,GAAG,CAAC;AAAA,IAC5E,SAAS,OAAO;AACd,UAAI,aAAa;AACf,cAAM,uBAAuB,QAAQ,YAAY,IAAI,YAAY,UAAU,EAAE,MAAM,MAAM;AAAA,QAAC,CAAC;AAAA,MAC7F;AACA,YAAM,OAAQ,OAA8B;AAC5C,UAAI,SAAS,kBAAkB;AAC7B,eAAO,aAAa,KAAK,EAAE,OAAO,qDAAqD,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,MAC3G;AACA,UAAI,SAAS,uBAAuB;AAClC,eAAO,aAAa,KAAK,EAAE,OAAO,yCAAyC,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,MAC/F;AACA,aAAO,aAAa,KAAK,EAAE,OAAO,2CAA2C,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IACjG;AAAA,EACF;AAEA,QAAM,MAAM,MAAM,OAAO,aAAa,KAAK,WAAW,WAAW,WAAW;AAE5E,SAAO,aAAa,KAAK,EAAE,KAAK,UAAU,CAAC;AAC7C;AAEA,IAAO,qBAAQ;AAER,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aAAa;AAAA,MACb,aAAa,EAAE,aAAa,oBAAoB,QAAQ,cAAc;AAAA,MACtE,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,6BAA6B,QAAQ,eAAe,CAAC;AAAA,MAC7F,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,wCAAwC,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QAC5G,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QACpF,EAAE,QAAQ,KAAK,aAAa,iCAAiC,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,MACvG;AAAA,IACF;AAAA,EACF;AACF;",
6
- "names": ["url"]
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 { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\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})\n\nconst responseSchema = z.object({\n url: z.string(),\n expiresAt: z.string(),\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 auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: '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: 'Invalid payload' }, { status: 400 })\n }\n\n const { key, operation, expiresIn, contentType } = 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: '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: 'S3 integration is not configured.' }, { status: 400 })\n }\n\n const url = await driver.getSignedUrl(key, operation, expiresIn, contentType)\n const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()\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
+ "names": []
7
7
  }
@@ -2,22 +2,18 @@ import { NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
3
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
4
4
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
5
- import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
6
5
  import {
7
6
  detectAttachmentMimeType,
8
7
  hasDangerousExecutableExtension,
9
8
  isActiveContentAttachment
10
9
  } from "@open-mercato/core/modules/attachments/lib/security";
11
10
  import {
12
- isMultipartUploadLimitError,
13
11
  isMultipartRequestWithinUploadLimit,
14
- parseMultipartFormDataWithinUploadLimit,
15
12
  resolveAttachmentMaxBytes,
16
13
  willExceedAttachmentTenantQuota
17
14
  } from "@open-mercato/core/modules/attachments/lib/upload-limits";
18
15
  import { isS3KeyScopedToTenant } from "../../../../lib/key-scope.js";
19
16
  import { S3StorageDriver } from "../../../../lib/s3-driver.js";
20
- import { reconcileTenantS3Objects } from "../../../../lib/quota-accounting.js";
21
17
  import { randomUUID } from "crypto";
22
18
  const metadata = {
23
19
  path: "/storage-providers/s3/upload",
@@ -54,125 +50,52 @@ async function readTenantStorageUsageBytes(driver, tenantId, orgId) {
54
50
  return totalBytes;
55
51
  }
56
52
  async function POST(req) {
57
- const { t } = await resolveTranslations();
58
53
  const auth = await getAuthFromRequest(req);
59
54
  if (!auth?.tenantId || !auth.orgId) {
60
- return NextResponse.json({ error: t("storage_s3.errors.unauthorized", "Unauthorized") }, { status: 401 });
55
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
61
56
  }
62
57
  if (!isMultipartRequestWithinUploadLimit(req.headers.get("content-length"))) {
63
- return NextResponse.json({ error: t("storage_s3.errors.maxUploadSize", "Attachment exceeds the maximum upload size.") }, { status: 413 });
58
+ return NextResponse.json({ error: "Attachment exceeds the maximum upload size." }, { status: 413 });
64
59
  }
65
60
  const contentType = req.headers.get("content-type") ?? "";
66
61
  if (!contentType.includes("multipart/form-data")) {
67
- return NextResponse.json({ error: t("storage_s3.errors.multipartRequired", "Expected multipart/form-data") }, { status: 400 });
68
- }
69
- let form;
70
- try {
71
- form = await parseMultipartFormDataWithinUploadLimit(req);
72
- } catch (error) {
73
- if (isMultipartUploadLimitError(error)) {
74
- return NextResponse.json({ error: t("storage_s3.errors.maxUploadSize", "Attachment exceeds the maximum upload size.") }, { status: 413 });
75
- }
76
- throw error;
62
+ return NextResponse.json({ error: "Expected multipart/form-data" }, { status: 400 });
77
63
  }
64
+ const form = await req.formData();
78
65
  const file = form.get("file");
79
66
  if (!file) {
80
- return NextResponse.json({ error: t("storage_s3.errors.fileRequired", "file field is required") }, { status: 400 });
67
+ return NextResponse.json({ error: "file field is required" }, { status: 400 });
81
68
  }
82
69
  const keyOverride = form.get("key") ? String(form.get("key")) : null;
83
70
  if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {
84
71
  return NextResponse.json(
85
- { error: t("storage_s3.errors.keyOverrideAccessDenied", "Access denied: key override is not scoped to this tenant.") },
72
+ { error: "Access denied: key override is not scoped to this tenant." },
86
73
  { status: 403 }
87
74
  );
88
75
  }
89
76
  if (hasDangerousExecutableExtension(file.name)) {
90
- return NextResponse.json({ error: t("storage_s3.errors.dangerousExecutable", "Executable file types are not allowed as attachments.") }, { status: 400 });
77
+ return NextResponse.json({ error: "Executable file types are not allowed as attachments." }, { status: 400 });
91
78
  }
92
79
  const effectiveMaxBytes = resolveAttachmentMaxBytes();
93
80
  if (file.size > effectiveMaxBytes) {
94
- return NextResponse.json({ error: t("storage_s3.errors.maxUploadSize", "Attachment exceeds the maximum upload size.") }, { status: 413 });
81
+ return NextResponse.json({ error: "Attachment exceeds the maximum upload size." }, { status: 413 });
95
82
  }
96
83
  const driver = await resolveDriver(auth.tenantId, auth.orgId);
97
84
  if (!driver) {
98
- return NextResponse.json({ error: t("storage_s3.errors.integrationNotConfigured", "S3 integration is not configured.") }, { status: 400 });
85
+ return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
99
86
  }
100
87
  const buffer = Buffer.from(await file.arrayBuffer());
101
88
  const safeName = sanitizeFileName(file.name);
102
89
  const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null);
103
90
  if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {
104
- return NextResponse.json({ error: t("storage_s3.errors.activeContentBlocked", "Active content uploads are not allowed.") }, { status: 400 });
91
+ return NextResponse.json({ error: "Active content uploads are not allowed." }, { status: 400 });
105
92
  }
106
- const key = keyOverride ?? `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
107
- const { resolve } = await createRequestContainer();
108
- let attachmentQuotaService = null;
109
- let recoveryScheduler = null;
110
- try {
111
- attachmentQuotaService = resolve("attachmentQuotaService");
112
- recoveryScheduler = resolve("storageS3QuotaRecoveryScheduler");
113
- } catch {
114
- }
115
- let reservation = null;
116
- if (attachmentQuotaService) {
117
- try {
118
- await reconcileTenantS3Objects({
119
- driver,
120
- quotaService: attachmentQuotaService,
121
- tenantId: auth.tenantId,
122
- organizationId: auth.orgId
123
- });
124
- reservation = await attachmentQuotaService.reserve({
125
- tenantId: auth.tenantId,
126
- organizationId: auth.orgId,
127
- bytes: buffer.length,
128
- source: "storage_s3_upload",
129
- storageDriver: "s3",
130
- storagePath: key
131
- });
132
- if (!recoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
133
- await recoveryScheduler({
134
- reservationId: reservation.id,
135
- tenantId: auth.tenantId,
136
- organizationId: auth.orgId
137
- }, Math.max(1e3, reservation.expiresAt.getTime() - Date.now()));
138
- await attachmentQuotaService.beginStorage(reservation.id, reservation.leaseToken);
139
- } catch (error) {
140
- if (reservation) {
141
- await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {
142
- });
143
- reservation = null;
144
- }
145
- const code = error?.code;
146
- if (code === "quota_exceeded") {
147
- return NextResponse.json({ error: "Attachment storage quota exceeded for this tenant." }, { status: 413 });
148
- }
149
- if (code === "quota_target_exists") {
150
- return NextResponse.json({ error: "The target storage key already exists." }, { status: 409 });
151
- }
152
- return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
153
- }
154
- } else {
155
- const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId);
156
- if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {
157
- return NextResponse.json({ error: "Attachment storage quota exceeded for this tenant." }, { status: 413 });
158
- }
159
- }
160
- try {
161
- await driver.putObject(key, buffer, trustedMimeType);
162
- if (reservation) {
163
- await attachmentQuotaService.markStored(reservation.id, reservation.leaseToken);
164
- await attachmentQuotaService.completeStandalone(reservation.id, reservation.leaseToken, buffer.length);
165
- }
166
- } catch (error) {
167
- if (reservation) {
168
- try {
169
- await driver.deleteStrict("", key);
170
- await attachmentQuotaService.release(reservation.id, reservation.leaseToken);
171
- } catch {
172
- }
173
- }
174
- return NextResponse.json({ error: "Failed to persist attachment." }, { status: 500 });
93
+ const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId);
94
+ if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {
95
+ return NextResponse.json({ error: "Attachment storage quota exceeded for this tenant." }, { status: 413 });
175
96
  }
97
+ const key = keyOverride ?? `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
98
+ await driver.putObject(key, buffer, trustedMimeType);
176
99
  return NextResponse.json({
177
100
  key,
178
101
  bucket: driver.getBucket(),