@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,7 +1,5 @@
1
- import { createHash, randomBytes } from "crypto";
1
+ import { randomUUID } from "crypto";
2
2
  import { S3StorageDriver } from "./s3-driver.js";
3
- import { resolveAttachmentMaxBytes } from "@open-mercato/core/modules/attachments/lib/upload-limits";
4
- import { reconcileTenantS3Objects } from "./quota-accounting.js";
5
3
  import {
6
4
  assertS3KeyAddressableByTenantScope,
7
5
  assertS3KeyScopedToTenant,
@@ -10,89 +8,32 @@ import {
10
8
  function sanitizeSegment(value) {
11
9
  return value.replace(/[^a-zA-Z0-9._-]/g, "_");
12
10
  }
11
+ function buildKey(namespace, scope, fileName) {
12
+ const orgSeg = scope.organizationId ? `org_${scope.organizationId}` : "org_shared";
13
+ const tenantSeg = scope.tenantId ? `tenant_${scope.tenantId}` : "tenant_shared";
14
+ const uniqueSuffix = randomUUID().replace(/-/g, "").slice(0, 12);
15
+ const safeName = sanitizeSegment(fileName || "file");
16
+ return `${sanitizeSegment(namespace)}/${orgSeg}/${tenantSeg}/${Date.now()}_${uniqueSuffix}_${safeName}`;
17
+ }
13
18
  function createStorageService(config) {
14
19
  const driver = new S3StorageDriver(config);
15
- const quotaService = config.quotaService;
16
- const quotaRecoveryScheduler = config.quotaRecoveryScheduler;
17
20
  const pathPrefix = config.pathPrefix;
18
21
  return {
19
22
  async upload({ namespace, fileName, buffer, contentType, scope }) {
20
- const storagePath = driver.prepareStoragePath({
21
- partitionCode: sanitizeSegment(namespace),
23
+ const stored = await driver.store({
24
+ partitionCode: namespace,
22
25
  orgId: scope.organizationId,
23
26
  tenantId: scope.tenantId,
24
- fileName
27
+ fileName,
28
+ buffer
25
29
  });
26
- assertS3KeyScopedToTenant(storagePath, scope, pathPrefix);
27
- let reservation = null;
28
- let storageStarted = false;
29
- let objectStored = false;
30
- try {
31
- if (quotaService && scope.tenantId && scope.organizationId) {
32
- await reconcileTenantS3Objects({
33
- driver,
34
- quotaService,
35
- tenantId: scope.tenantId,
36
- organizationId: scope.organizationId
37
- });
38
- reservation = await quotaService.reserve({
39
- tenantId: scope.tenantId,
40
- organizationId: scope.organizationId,
41
- bytes: buffer.length,
42
- source: "storage_service",
43
- storageDriver: "s3",
44
- storagePath,
45
- partitionCode: namespace
46
- });
47
- }
48
- if (reservation) {
49
- if (!quotaRecoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
50
- await quotaRecoveryScheduler(
51
- {
52
- reservationId: reservation.id,
53
- tenantId: scope.tenantId,
54
- organizationId: scope.organizationId
55
- },
56
- Math.max(1e3, reservation.expiresAt.getTime() - Date.now())
57
- );
58
- await quotaService.beginStorage(reservation.id, reservation.leaseToken);
59
- storageStarted = true;
60
- }
61
- const stored = await driver.store({
62
- partitionCode: namespace,
63
- orgId: scope.organizationId,
64
- tenantId: scope.tenantId,
65
- fileName,
66
- buffer,
67
- storagePath
68
- });
69
- objectStored = true;
70
- if (reservation) {
71
- await quotaService.markStored(reservation.id, reservation.leaseToken);
72
- await quotaService.completeStandalone(reservation.id, reservation.leaseToken, buffer.length);
73
- }
74
- return {
75
- key: stored.storagePath,
76
- namespace,
77
- fileName,
78
- size: buffer.length,
79
- contentType
80
- };
81
- } catch (error) {
82
- if (reservation) {
83
- if (objectStored) {
84
- try {
85
- await driver.deleteStrict("", storagePath);
86
- await quotaService.release(reservation.id, reservation.leaseToken);
87
- } catch {
88
- }
89
- } else if (!storageStarted) {
90
- await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
91
- });
92
- }
93
- }
94
- throw error;
95
- }
30
+ return {
31
+ key: stored.storagePath,
32
+ namespace,
33
+ fileName,
34
+ size: buffer.length,
35
+ contentType
36
+ };
96
37
  },
97
38
  async download({ key, scope }) {
98
39
  assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
@@ -100,61 +41,16 @@ function createStorageService(config) {
100
41
  },
101
42
  async delete({ key, scope }) {
102
43
  assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
103
- return driver.deleteStrict("", key).then(async () => {
104
- if (quotaService && scope.tenantId) {
105
- await quotaService.releaseCommittedByPath({
106
- tenantId: scope.tenantId,
107
- storageDriver: "s3",
108
- storagePath: key
109
- });
110
- }
111
- });
44
+ return driver.delete("", key);
112
45
  },
113
- async getSignedUrl({ key, operation, expiresIn = 3600, contentType, size, scope }) {
46
+ async getSignedUrl({ key, operation, expiresIn = 3600, contentType, scope }) {
114
47
  if (operation === "upload") {
115
48
  assertS3KeyScopedToTenant(key, scope, pathPrefix);
116
49
  } else {
117
50
  assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
118
51
  }
119
- const expiresAt = new Date(Date.now() + expiresIn * 1e3);
120
- if (operation === "upload" && quotaService && scope.tenantId && scope.organizationId) {
121
- const compatibilityToken = randomBytes(32).toString("base64url");
122
- let reservation = null;
123
- try {
124
- await reconcileTenantS3Objects({
125
- driver,
126
- quotaService,
127
- tenantId: scope.tenantId,
128
- organizationId: scope.organizationId
129
- });
130
- reservation = await quotaService.reserve({
131
- tenantId: scope.tenantId,
132
- organizationId: scope.organizationId,
133
- bytes: size ?? resolveAttachmentMaxBytes(),
134
- source: "storage_s3_signed",
135
- storageDriver: "s3",
136
- storagePath: key,
137
- uploadTokenHash: createHash("sha256").update(compatibilityToken).digest("hex"),
138
- ttlMs: expiresIn * 1e3
139
- });
140
- if (!quotaRecoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
141
- await quotaRecoveryScheduler({
142
- reservationId: reservation.id,
143
- tenantId: scope.tenantId,
144
- organizationId: scope.organizationId
145
- }, Math.max(1e3, reservation.expiresAt.getTime() - Date.now()));
146
- const url2 = `/api/storage-providers/s3/signed-upload/${compatibilityToken}`;
147
- return { url: url2, expiresAt, reservationId: reservation.id };
148
- } catch (error) {
149
- if (reservation) {
150
- await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
151
- });
152
- }
153
- throw error;
154
- }
155
- }
156
52
  const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope);
157
- return { url, expiresAt };
53
+ return { url, expiresAt: new Date(Date.now() + expiresIn * 1e3) };
158
54
  },
159
55
  async list({ prefix, maxKeys = 100, continuationToken, scope }) {
160
56
  assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/storage_s3/lib/storage-service.ts"],
4
- "sourcesContent": ["import { createHash, randomBytes } from 'crypto'\nimport { S3StorageDriver } from './s3-driver'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { resolveAttachmentMaxBytes } from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport { reconcileTenantS3Objects } from './quota-accounting'\nimport {\n assertS3KeyAddressableByTenantScope,\n assertS3KeyScopedToTenant,\n assertS3ListPrefixScopedToTenant,\n} from './key-scope'\n\ntype TenantScope = {\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n}\n\ntype UploadInput = {\n namespace: string\n fileName: string\n buffer: Buffer\n contentType?: string\n scope: TenantScope\n}\n\ntype StorageResult = {\n key: string\n namespace: string\n fileName: string\n size: number\n contentType?: string\n}\n\ntype DownloadInput = { key: string; scope: TenantScope }\ntype DeleteInput = { key: string; scope: TenantScope }\ntype SignedUrlInput = {\n key: string\n operation: 'upload' | 'download'\n expiresIn?: number\n contentType?: string\n size?: number\n scope: TenantScope\n}\ntype ListInput = {\n prefix: string\n maxKeys?: number\n continuationToken?: string\n scope: TenantScope\n}\n\ntype S3Config = {\n bucket: string\n region?: string\n endpoint?: string\n forcePathStyle?: boolean\n authMode?: 'access_keys' | 'ambient'\n credentialsEnvPrefix?: string\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n quotaService?: AttachmentQuotaService\n quotaRecoveryScheduler?: (\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>\n pathPrefix?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\nfunction sanitizeSegment(value: string): string {\n return value.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nexport interface StorageService {\n upload(input: UploadInput): Promise<StorageResult>\n download(input: DownloadInput): Promise<{ buffer: Buffer; contentType?: string }>\n delete(input: DeleteInput): Promise<void>\n getSignedUrl(input: SignedUrlInput): Promise<{ url: string; expiresAt: Date; reservationId?: string }>\n list(input: ListInput): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }>\n toLocalPath(input: { key: string; scope: TenantScope }): Promise<{ filePath: string; cleanup: () => Promise<void> }>\n}\n\nexport function createStorageService(config: S3Config): StorageService {\n const driver = new S3StorageDriver(config as Record<string, unknown>)\n const quotaService = config.quotaService\n const quotaRecoveryScheduler = config.quotaRecoveryScheduler\n const pathPrefix = config.pathPrefix\n\n return {\n async upload({ namespace, fileName, buffer, contentType, scope }): Promise<StorageResult> {\n const storagePath = driver.prepareStoragePath({\n partitionCode: sanitizeSegment(namespace),\n orgId: scope.organizationId,\n tenantId: scope.tenantId,\n fileName,\n })\n assertS3KeyScopedToTenant(storagePath, scope, pathPrefix)\n let reservation = null as Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null\n let storageStarted = false\n let objectStored = false\n try {\n if (quotaService && scope.tenantId && scope.organizationId) {\n await reconcileTenantS3Objects({\n driver,\n quotaService,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n })\n reservation = await quotaService.reserve({\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n bytes: buffer.length,\n source: 'storage_service',\n storageDriver: 's3',\n storagePath,\n partitionCode: namespace,\n })\n }\n if (reservation) {\n if (!quotaRecoveryScheduler) throw new Error('Storage quota recovery is unavailable.')\n await quotaRecoveryScheduler({\n reservationId: reservation.id,\n tenantId: scope.tenantId!,\n organizationId: scope.organizationId!,\n },\n Math.max(1_000, reservation.expiresAt.getTime() - Date.now()),\n )\n await quotaService!.beginStorage(reservation.id, reservation.leaseToken)\n storageStarted = true\n }\n const stored = await driver.store({\n partitionCode: namespace,\n orgId: scope.organizationId,\n tenantId: scope.tenantId,\n fileName,\n buffer,\n storagePath,\n })\n objectStored = true\n if (reservation) {\n await quotaService!.markStored(reservation.id, reservation.leaseToken)\n await quotaService!.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)\n }\n return {\n key: stored.storagePath,\n namespace,\n fileName,\n size: buffer.length,\n contentType,\n }\n } catch (error) {\n if (reservation) {\n if (objectStored) {\n try {\n await driver.deleteStrict('', storagePath)\n await quotaService!.release(reservation.id, reservation.leaseToken)\n } catch {\n // Keep the reservation counted for recovery when absence is ambiguous.\n }\n } else if (!storageStarted) {\n await quotaService!.release(reservation.id, reservation.leaseToken).catch(() => {})\n }\n }\n throw error\n }\n },\n\n async download({ key, scope }): Promise<{ buffer: Buffer; contentType?: string }> {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.read('', key)\n },\n\n async delete({ key, scope }): Promise<void> {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.deleteStrict('', key)\n .then(async () => {\n if (quotaService && scope.tenantId) {\n await quotaService.releaseCommittedByPath({\n tenantId: scope.tenantId,\n storageDriver: 's3',\n storagePath: key,\n })\n }\n })\n },\n\n async getSignedUrl({ key, operation, expiresIn = 3600, contentType, size, scope }) {\n if (operation === 'upload') {\n assertS3KeyScopedToTenant(key, scope, pathPrefix)\n } else {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n }\n const expiresAt = new Date(Date.now() + expiresIn * 1000)\n if (operation === 'upload' && quotaService && scope.tenantId && scope.organizationId) {\n const compatibilityToken = randomBytes(32).toString('base64url')\n let reservation: Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null = null\n try {\n await reconcileTenantS3Objects({\n driver,\n quotaService,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n })\n reservation = await quotaService.reserve({\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n bytes: size ?? resolveAttachmentMaxBytes(),\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 (!quotaRecoveryScheduler) throw new Error('Storage quota recovery is unavailable.')\n await quotaRecoveryScheduler({\n reservationId: reservation.id,\n tenantId: scope.tenantId!,\n organizationId: scope.organizationId!,\n }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))\n const url = `/api/storage-providers/s3/signed-upload/${compatibilityToken}`\n return { url, expiresAt, reservationId: reservation.id }\n } catch (error) {\n if (reservation) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n }\n throw error\n }\n }\n const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope)\n return { url, expiresAt }\n },\n\n async list({ prefix, maxKeys = 100, continuationToken, scope }): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\n assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix)\n return driver.listObjects(prefix, maxKeys, continuationToken, scope)\n },\n\n async toLocalPath({ key, scope }) {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.toLocalPath('', key)\n },\n }\n}\n"],
5
- "mappings": "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",
6
- "names": ["url"]
4
+ "sourcesContent": ["import { randomUUID } from 'crypto'\nimport { S3StorageDriver } from './s3-driver'\nimport {\n assertS3KeyAddressableByTenantScope,\n assertS3KeyScopedToTenant,\n assertS3ListPrefixScopedToTenant,\n} from './key-scope'\n\ntype TenantScope = {\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n}\n\ntype UploadInput = {\n namespace: string\n fileName: string\n buffer: Buffer\n contentType?: string\n scope: TenantScope\n}\n\ntype StorageResult = {\n key: string\n namespace: string\n fileName: string\n size: number\n contentType?: string\n}\n\ntype DownloadInput = { key: string; scope: TenantScope }\ntype DeleteInput = { key: string; scope: TenantScope }\ntype SignedUrlInput = {\n key: string\n operation: 'upload' | 'download'\n expiresIn?: number\n contentType?: string\n scope: TenantScope\n}\ntype ListInput = {\n prefix: string\n maxKeys?: number\n continuationToken?: string\n scope: TenantScope\n}\n\ntype S3Config = {\n bucket: string\n region?: string\n endpoint?: string\n forcePathStyle?: boolean\n authMode?: 'access_keys' | 'ambient'\n credentialsEnvPrefix?: string\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n pathPrefix?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\nfunction sanitizeSegment(value: string): string {\n return value.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction buildKey(namespace: string, scope: TenantScope, fileName: string): string {\n const orgSeg = scope.organizationId ? `org_${scope.organizationId}` : 'org_shared'\n const tenantSeg = scope.tenantId ? `tenant_${scope.tenantId}` : 'tenant_shared'\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const safeName = sanitizeSegment(fileName || 'file')\n return `${sanitizeSegment(namespace)}/${orgSeg}/${tenantSeg}/${Date.now()}_${uniqueSuffix}_${safeName}`\n}\n\nexport interface StorageService {\n upload(input: UploadInput): Promise<StorageResult>\n download(input: DownloadInput): Promise<{ buffer: Buffer; contentType?: string }>\n delete(input: DeleteInput): Promise<void>\n getSignedUrl(input: SignedUrlInput): Promise<{ url: string; expiresAt: Date }>\n list(input: ListInput): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }>\n toLocalPath(input: { key: string; scope: TenantScope }): Promise<{ filePath: string; cleanup: () => Promise<void> }>\n}\n\nexport function createStorageService(config: S3Config): StorageService {\n const driver = new S3StorageDriver(config as Record<string, unknown>)\n const pathPrefix = config.pathPrefix\n\n return {\n async upload({ namespace, fileName, buffer, contentType, scope }): Promise<StorageResult> {\n const stored = await driver.store({\n partitionCode: namespace,\n orgId: scope.organizationId,\n tenantId: scope.tenantId,\n fileName,\n buffer,\n })\n return {\n key: stored.storagePath,\n namespace,\n fileName,\n size: buffer.length,\n contentType,\n }\n },\n\n async download({ key, scope }): Promise<{ buffer: Buffer; contentType?: string }> {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.read('', key)\n },\n\n async delete({ key, scope }): Promise<void> {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.delete('', key)\n },\n\n async getSignedUrl({ key, operation, expiresIn = 3600, contentType, scope }): Promise<{ url: string; expiresAt: Date }> {\n if (operation === 'upload') {\n assertS3KeyScopedToTenant(key, scope, pathPrefix)\n } else {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n }\n const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope)\n return { url, expiresAt: new Date(Date.now() + expiresIn * 1000) }\n },\n\n async list({ prefix, maxKeys = 100, continuationToken, scope }): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\n assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix)\n return driver.listObjects(prefix, maxKeys, continuationToken, scope)\n },\n\n async toLocalPath({ key, scope }) {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.toLocalPath('', key)\n },\n }\n}\n"],
5
+ "mappings": "AAAA,SAAS,kBAAkB;AAC3B,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAsDP,SAAS,gBAAgB,OAAuB;AAC9C,SAAO,MAAM,QAAQ,oBAAoB,GAAG;AAC9C;AAEA,SAAS,SAAS,WAAmB,OAAoB,UAA0B;AACjF,QAAM,SAAS,MAAM,iBAAiB,OAAO,MAAM,cAAc,KAAK;AACtE,QAAM,YAAY,MAAM,WAAW,UAAU,MAAM,QAAQ,KAAK;AAChE,QAAM,eAAe,WAAW,EAAE,QAAQ,MAAM,EAAE,EAAE,MAAM,GAAG,EAAE;AAC/D,QAAM,WAAW,gBAAgB,YAAY,MAAM;AACnD,SAAO,GAAG,gBAAgB,SAAS,CAAC,IAAI,MAAM,IAAI,SAAS,IAAI,KAAK,IAAI,CAAC,IAAI,YAAY,IAAI,QAAQ;AACvG;AAeO,SAAS,qBAAqB,QAAkC;AACrE,QAAM,SAAS,IAAI,gBAAgB,MAAiC;AACpE,QAAM,aAAa,OAAO;AAE1B,SAAO;AAAA,IACL,MAAM,OAAO,EAAE,WAAW,UAAU,QAAQ,aAAa,MAAM,GAA2B;AACxF,YAAM,SAAS,MAAM,OAAO,MAAM;AAAA,QAChC,eAAe;AAAA,QACf,OAAO,MAAM;AAAA,QACb,UAAU,MAAM;AAAA,QAChB;AAAA,QACA;AAAA,MACF,CAAC;AACD,aAAO;AAAA,QACL,KAAK,OAAO;AAAA,QACZ;AAAA,QACA;AAAA,QACA,MAAM,OAAO;AAAA,QACb;AAAA,MACF;AAAA,IACF;AAAA,IAEA,MAAM,SAAS,EAAE,KAAK,MAAM,GAAsD;AAChF,0CAAoC,KAAK,OAAO,UAAU;AAC1D,aAAO,OAAO,KAAK,IAAI,GAAG;AAAA,IAC5B;AAAA,IAEA,MAAM,OAAO,EAAE,KAAK,MAAM,GAAkB;AAC1C,0CAAoC,KAAK,OAAO,UAAU;AAC1D,aAAO,OAAO,OAAO,IAAI,GAAG;AAAA,IAC9B;AAAA,IAEA,MAAM,aAAa,EAAE,KAAK,WAAW,YAAY,MAAM,aAAa,MAAM,GAA8C;AACtH,UAAI,cAAc,UAAU;AAC1B,kCAA0B,KAAK,OAAO,UAAU;AAAA,MAClD,OAAO;AACL,4CAAoC,KAAK,OAAO,UAAU;AAAA,MAC5D;AACA,YAAM,MAAM,MAAM,OAAO,aAAa,KAAK,WAAW,WAAW,aAAa,KAAK;AACnF,aAAO,EAAE,KAAK,WAAW,IAAI,KAAK,KAAK,IAAI,IAAI,YAAY,GAAI,EAAE;AAAA,IACnE;AAAA,IAEA,MAAM,KAAK,EAAE,QAAQ,UAAU,KAAK,mBAAmB,MAAM,GAI1D;AACD,uCAAiC,QAAQ,OAAO,UAAU;AAC1D,aAAO,OAAO,YAAY,QAAQ,SAAS,mBAAmB,KAAK;AAAA,IACrE;AAAA,IAEA,MAAM,YAAY,EAAE,KAAK,MAAM,GAAG;AAChC,0CAAoC,KAAK,OAAO,UAAU;AAC1D,aAAO,OAAO,YAAY,IAAI,GAAG;AAAA,IACnC;AAAA,EACF;AACF;",
6
+ "names": []
7
7
  }
package/jest.config.cjs CHANGED
@@ -9,7 +9,6 @@ module.exports = {
9
9
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
10
10
  moduleNameMapper: {
11
11
  '^@open-mercato/core/(.*)$': '<rootDir>/../core/src/$1',
12
- '^@open-mercato/queue$': '<rootDir>/../queue/src/index.ts',
13
12
  '^@open-mercato/shared/(.*)$': '<rootDir>/../shared/src/$1',
14
13
  },
15
14
  transform: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/storage-s3",
3
- "version": "0.6.7-develop.6860.1.f796b22e80",
3
+ "version": "0.6.7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -16,7 +16,6 @@
16
16
  "types": "./src/*.ts",
17
17
  "default": "./dist/*.js"
18
18
  },
19
- "./*.json": "./src/*.json",
20
19
  "./*": {
21
20
  "types": [
22
21
  "./src/*.ts",
@@ -24,7 +23,6 @@
24
23
  ],
25
24
  "default": "./dist/*.js"
26
25
  },
27
- "./*/*.json": "./src/*/*.json",
28
26
  "./*/*": {
29
27
  "types": [
30
28
  "./src/*/*.ts",
@@ -32,7 +30,6 @@
32
30
  ],
33
31
  "default": "./dist/*/*.js"
34
32
  },
35
- "./*/*/*.json": "./src/*/*/*.json",
36
33
  "./*/*/*": {
37
34
  "types": [
38
35
  "./src/*/*/*.ts",
@@ -40,7 +37,6 @@
40
37
  ],
41
38
  "default": "./dist/*/*/*.js"
42
39
  },
43
- "./*/*/*/*.json": "./src/*/*/*/*.json",
44
40
  "./*/*/*/*": {
45
41
  "types": [
46
42
  "./src/*/*/*/*.ts",
@@ -48,7 +44,6 @@
48
44
  ],
49
45
  "default": "./dist/*/*/*/*.js"
50
46
  },
51
- "./*/*/*/*/*.json": "./src/*/*/*/*/*.json",
52
47
  "./*/*/*/*/*": {
53
48
  "types": [
54
49
  "./src/*/*/*/*/*.ts",
@@ -74,20 +69,19 @@
74
69
  "dependencies": {
75
70
  "@aws-sdk/client-s3": "^3.1085.0",
76
71
  "@aws-sdk/s3-request-presigner": "^3.1085.0",
77
- "@open-mercato/core": "0.6.7-develop.6860.1.f796b22e80",
78
- "@open-mercato/queue": "0.6.7-develop.6860.1.f796b22e80"
72
+ "@open-mercato/core": "0.6.7"
79
73
  },
80
74
  "peerDependencies": {
81
75
  "@mikro-orm/postgresql": "^6.6.10",
82
- "@open-mercato/shared": "0.6.7-develop.6860.1.f796b22e80"
76
+ "@open-mercato/shared": "0.6.7"
83
77
  },
84
78
  "devDependencies": {
85
- "@open-mercato/shared": "0.6.7-develop.6860.1.f796b22e80",
79
+ "@open-mercato/shared": "0.6.7",
86
80
  "@types/jest": "^30.0.0",
87
81
  "esbuild": "^0.28.1",
88
82
  "glob": "^13.0.6",
89
83
  "jest": "^30.4.2",
90
- "ts-jest": "^29.4.12",
84
+ "ts-jest": "^29.4.11",
91
85
  "typescript": "7.0.2"
92
86
  },
93
87
  "publishConfig": {
@@ -97,6 +91,5 @@
97
91
  "type": "git",
98
92
  "url": "https://github.com/open-mercato/open-mercato",
99
93
  "directory": "packages/storage-s3"
100
- },
101
- "stableVersion": "0.6.6"
94
+ }
102
95
  }
@@ -99,10 +99,6 @@ describe('S3StorageDriver', () => {
99
99
  new S3StorageDriver(BASE_CONFIG)
100
100
  const [[arg]] = (S3Client as jest.Mock).mock.calls
101
101
  expect(arg.credentials).toBeUndefined()
102
- expect(arg.requestHandler).toEqual(expect.objectContaining({
103
- connectionTimeout: 10_000,
104
- requestTimeout: 120_000,
105
- }))
106
102
  })
107
103
 
108
104
  it('applies forcePathStyle and custom endpoint', () => {
@@ -111,17 +107,6 @@ describe('S3StorageDriver', () => {
111
107
  expect(arg.endpoint).toBe('http://minio:9000')
112
108
  expect(arg.forcePathStyle).toBe(true)
113
109
  })
114
-
115
- it('rejects link-local custom endpoints before constructing an S3 client', () => {
116
- expect(() => {
117
- new S3StorageDriver({
118
- ...BASE_CONFIG,
119
- endpoint: 'http://169.254.169.254/latest/meta-data/',
120
- forcePathStyle: true,
121
- })
122
- }).toThrow(/private or reserved IP range/i)
123
- expect(S3Client).not.toHaveBeenCalled()
124
- })
125
110
  })
126
111
 
127
112
  describe('store()', () => {
@@ -213,7 +198,7 @@ describe('S3StorageDriver', () => {
213
198
 
214
199
  await expect(
215
200
  driver.read('', 'docs/org_org-victim/tenant_tenant-owned/secret.txt'),
216
- ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
201
+ ).rejects.toThrow('S3 key is not scoped to the active tenant')
217
202
 
218
203
  expect(mockSend).not.toHaveBeenCalled()
219
204
  })
@@ -244,7 +229,7 @@ describe('S3StorageDriver', () => {
244
229
 
245
230
  await expect(
246
231
  driver.read('docs', 'exports/org_o/tenant_t/file.txt'),
247
- ).rejects.toThrow('[internal] S3 key is not scoped to the requested partition')
232
+ ).rejects.toThrow('S3 key is not scoped to the requested partition')
248
233
 
249
234
  expect(mockSend).not.toHaveBeenCalled()
250
235
  })
@@ -258,7 +243,7 @@ describe('S3StorageDriver', () => {
258
243
 
259
244
  await expect(
260
245
  driver.read('', 'docs/org_org-victim/tenant_tenant-owned/org_org-owned/tenant_tenant-owned/private.txt'),
261
- ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
246
+ ).rejects.toThrow('S3 key is not scoped to the active tenant')
262
247
 
263
248
  expect(mockSend).not.toHaveBeenCalled()
264
249
  })
@@ -274,7 +259,7 @@ describe('S3StorageDriver', () => {
274
259
 
275
260
  await expect(
276
261
  driver.putObject('docs/org_org-victim/tenant_tenant-owned/file.txt', Buffer.from('secret')),
277
- ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
262
+ ).rejects.toThrow('S3 key is not scoped to the active tenant')
278
263
 
279
264
  expect(mockSend).not.toHaveBeenCalled()
280
265
  })
@@ -306,7 +291,7 @@ describe('S3StorageDriver', () => {
306
291
 
307
292
  await expect(
308
293
  driver.delete('', 'docs/org_org-owned/tenant_tenant-victim/file.txt'),
309
- ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
294
+ ).rejects.toThrow('S3 key is not scoped to the active tenant')
310
295
 
311
296
  expect(mockSend).not.toHaveBeenCalled()
312
297
  })
@@ -329,7 +314,7 @@ describe('S3StorageDriver', () => {
329
314
 
330
315
  await expect(
331
316
  driver.delete('docs', 'exports/org_o/tenant_t/file.txt'),
332
- ).rejects.toThrow('[internal] S3 key is not scoped to the requested partition')
317
+ ).rejects.toThrow('S3 key is not scoped to the requested partition')
333
318
 
334
319
  expect(mockSend).not.toHaveBeenCalled()
335
320
  })
@@ -409,7 +394,7 @@ describe('S3StorageDriver', () => {
409
394
 
410
395
  await expect(
411
396
  driver.getSignedUrl('docs/org_org-victim/tenant_tenant-owned/file.pdf', 'download'),
412
- ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
397
+ ).rejects.toThrow('S3 key is not scoped to the active tenant')
413
398
 
414
399
  expect(mockGetSignedUrl).not.toHaveBeenCalled()
415
400
  })
@@ -435,7 +420,7 @@ describe('S3StorageDriver', () => {
435
420
 
436
421
  await expect(
437
422
  driver.getSignedUrl('docs/org_shared/tenant_shared/shared.pdf', 'upload'),
438
- ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
423
+ ).rejects.toThrow('S3 key is not scoped to the active tenant')
439
424
 
440
425
  expect(mockGetSignedUrl).not.toHaveBeenCalled()
441
426
  })
@@ -9,12 +9,6 @@ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
9
9
  getAuthFromRequest: async () => ({ tenantId, orgId, sub: null }),
10
10
  }))
11
11
 
12
- jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
13
- resolveTranslations: async () => ({
14
- t: (key: string) => `translated:${key}`,
15
- }),
16
- }))
17
-
18
12
  jest.mock('@open-mercato/shared/lib/di/container', () => ({
19
13
  createRequestContainer: async () => ({
20
14
  resolve: (token: string) => {
@@ -75,7 +69,7 @@ describe('storage_s3 list route tenant scoping', () => {
75
69
 
76
70
  expect(response.status).toBe(403)
77
71
  await expect(response.json()).resolves.toEqual({
78
- error: 'translated:storage_s3.errors.prefixAccessDenied',
72
+ error: 'Access denied: prefix is not scoped to this tenant.',
79
73
  })
80
74
  expect(listObjectsMock).not.toHaveBeenCalled()
81
75
  })
@@ -5,12 +5,6 @@ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
5
5
  getAuthFromRequest: (...args: unknown[]) => mockGetAuthFromRequest(...args),
6
6
  }))
7
7
 
8
- jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
9
- resolveTranslations: async () => ({
10
- t: (key: string) => `translated:${key}`,
11
- }),
12
- }))
13
-
14
8
  const mockResolveCredentials = jest.fn()
15
9
  jest.mock('@open-mercato/shared/lib/di/container', () => ({
16
10
  createRequestContainer: jest.fn(async () => ({
@@ -99,26 +93,9 @@ describe('storage_s3 standalone route key scope', () => {
99
93
  }))
100
94
 
101
95
  expect(response.status).toBe(403)
102
- await expect(response.json()).resolves.toEqual({
103
- error: 'translated:storage_s3.errors.keyAccessDenied',
104
- })
105
96
  expect(mockGetSignedUrl).not.toHaveBeenCalled()
106
97
  })
107
98
 
108
- it('localizes invalid delete payload errors', async () => {
109
- const response = await DELETE(new Request('http://example.test/api/storage-providers/s3/delete', {
110
- method: 'DELETE',
111
- headers: { 'content-type': 'application/json' },
112
- body: JSON.stringify({}),
113
- }))
114
-
115
- expect(response.status).toBe(400)
116
- await expect(response.json()).resolves.toEqual({
117
- error: 'translated:storage_s3.errors.invalidPayload',
118
- })
119
- expect(mockDelete).not.toHaveBeenCalled()
120
- })
121
-
122
99
  it('deletes shared namespace objects for an authorized storage manager', async () => {
123
100
  mockDelete.mockResolvedValueOnce(undefined)
124
101
 
@@ -139,9 +116,6 @@ describe('storage_s3 standalone route key scope', () => {
139
116
  )
140
117
 
141
118
  expect(response.status).toBe(403)
142
- await expect(response.json()).resolves.toEqual({
143
- error: 'translated:storage_s3.errors.keyAccessDenied',
144
- })
145
119
  }
146
120
  expect(mockRead).not.toHaveBeenCalled()
147
121
  })
@@ -27,21 +27,6 @@ beforeEach(() => {
27
27
  })
28
28
 
29
29
  describe('createStorageService', () => {
30
- it('preserves the configured path prefix for programmatic uploads', async () => {
31
- mockSend.mockResolvedValueOnce({})
32
- const service = createStorageService({ ...BASE_CONFIG, pathPrefix: 'backups/' })
33
-
34
- const result = await service.upload({
35
- namespace: 'docs',
36
- fileName: 'report.pdf',
37
- buffer: Buffer.from('pdf'),
38
- scope: TENANT_SCOPE,
39
- })
40
-
41
- expect(result.key).toMatch(/^backups\/docs\/org_org-owned\/tenant_tenant-owned\//)
42
- expect(mockSend.mock.calls[0]?.[0]?.Key).toBe(result.key)
43
- })
44
-
45
30
  it('rejects cross-tenant downloads before calling S3', async () => {
46
31
  const service = createStorageService(BASE_CONFIG)
47
32
 
@@ -50,7 +35,7 @@ describe('createStorageService', () => {
50
35
  key: 'docs/org_org-victim/tenant_tenant-owned/file.txt',
51
36
  scope: TENANT_SCOPE,
52
37
  }),
53
- ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
38
+ ).rejects.toThrow('S3 key is not scoped to the active tenant')
54
39
 
55
40
  expect(mockSend).not.toHaveBeenCalled()
56
41
  })
@@ -85,7 +70,7 @@ describe('createStorageService', () => {
85
70
  key: 'docs/org_org-victim/tenant_tenant-owned/file.txt',
86
71
  scope: TENANT_SCOPE,
87
72
  }),
88
- ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
73
+ ).rejects.toThrow('S3 key is not scoped to the active tenant')
89
74
 
90
75
  expect(mockSend).not.toHaveBeenCalled()
91
76
  })
@@ -98,7 +83,7 @@ describe('createStorageService', () => {
98
83
  key: 'docs/org_org-owned/tenant_tenant-victim/file.txt',
99
84
  scope: TENANT_SCOPE,
100
85
  }),
101
- ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
86
+ ).rejects.toThrow('S3 key is not scoped to the active tenant')
102
87
 
103
88
  expect(mockSend).not.toHaveBeenCalled()
104
89
  })
@@ -112,7 +97,7 @@ describe('createStorageService', () => {
112
97
  operation: 'download',
113
98
  scope: TENANT_SCOPE,
114
99
  }),
115
- ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
100
+ ).rejects.toThrow('S3 key is not scoped to the active tenant')
116
101
 
117
102
  expect(mockGetSignedUrl).not.toHaveBeenCalled()
118
103
  })
@@ -126,7 +111,7 @@ describe('createStorageService', () => {
126
111
  operation: 'upload',
127
112
  scope: TENANT_SCOPE,
128
113
  }),
129
- ).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
114
+ ).rejects.toThrow('S3 key is not scoped to the active tenant')
130
115
 
131
116
  expect(mockGetSignedUrl).not.toHaveBeenCalled()
132
117
  })
@@ -3,10 +3,8 @@ import { z } from 'zod'
3
3
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
4
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
- import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
7
6
  import { isS3KeyAddressableByScope } from '../../../../lib/key-scope'
8
7
  import { S3StorageDriver } from '../../../../lib/s3-driver'
9
- import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
10
8
 
11
9
  export const metadata = {
12
10
  path: '/storage-providers/s3/delete',
@@ -28,42 +26,27 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
28
26
  }
29
27
 
30
28
  export async function DELETE(req: Request) {
31
- const { t } = await resolveTranslations()
32
29
  const auth = await getAuthFromRequest(req)
33
30
  if (!auth?.tenantId || !auth.orgId) {
34
- return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })
31
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
35
32
  }
36
33
 
37
34
  const json = await req.json().catch(() => null)
38
35
  const parsed = requestSchema.safeParse(json)
39
36
  if (!parsed.success) {
40
- return NextResponse.json({ error: t('storage_s3.errors.invalidPayload', 'Invalid payload') }, { status: 400 })
37
+ return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
41
38
  }
42
39
 
43
40
  if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {
44
- 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 })
45
42
  }
46
43
 
47
44
  const driver = await resolveDriver(auth.tenantId, auth.orgId)
48
45
  if (!driver) {
49
- return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
46
+ return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
50
47
  }
51
48
 
52
- await (driver.deleteStrict?.('', parsed.data.key) ?? driver.delete('', parsed.data.key))
53
- const { resolve } = await createRequestContainer()
54
- let attachmentQuotaService: AttachmentQuotaService | null = null
55
- try {
56
- attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService | null
57
- } catch {
58
- // Backward-compatible when the attachments quota service is not registered.
59
- }
60
- if (attachmentQuotaService) {
61
- await attachmentQuotaService.releaseCommittedByPath({
62
- tenantId: auth.tenantId,
63
- storageDriver: 's3',
64
- storagePath: parsed.data.key,
65
- })
66
- }
49
+ await driver.delete('', parsed.data.key)
67
50
  return new NextResponse(null, { status: 204 })
68
51
  }
69
52