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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js +20 -5
  3. package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js.map +2 -2
  4. package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js +7 -5
  5. package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js.map +2 -2
  6. package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js +6 -4
  7. package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js.map +2 -2
  8. package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js +70 -8
  9. package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js.map +3 -3
  10. package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js +92 -15
  11. package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
  12. package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js +145 -0
  13. package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js.map +7 -0
  14. package/dist/modules/storage_s3/di.js +6 -2
  15. package/dist/modules/storage_s3/di.js.map +2 -2
  16. package/dist/modules/storage_s3/integration.js +1 -1
  17. package/dist/modules/storage_s3/integration.js.map +1 -1
  18. package/dist/modules/storage_s3/lib/endpoint-safety.js +133 -0
  19. package/dist/modules/storage_s3/lib/endpoint-safety.js.map +7 -0
  20. package/dist/modules/storage_s3/lib/health.js +18 -1
  21. package/dist/modules/storage_s3/lib/health.js.map +2 -2
  22. package/dist/modules/storage_s3/lib/key-scope.js +4 -4
  23. package/dist/modules/storage_s3/lib/key-scope.js.map +2 -2
  24. package/dist/modules/storage_s3/lib/quota-accounting.js +24 -0
  25. package/dist/modules/storage_s3/lib/quota-accounting.js.map +7 -0
  26. package/dist/modules/storage_s3/lib/quota-recovery-queue.js +9 -0
  27. package/dist/modules/storage_s3/lib/quota-recovery-queue.js.map +7 -0
  28. package/dist/modules/storage_s3/lib/s3-driver.js +44 -15
  29. package/dist/modules/storage_s3/lib/s3-driver.js.map +2 -2
  30. package/dist/modules/storage_s3/lib/storage-service.js +126 -22
  31. package/dist/modules/storage_s3/lib/storage-service.js.map +3 -3
  32. package/dist/modules/storage_s3/workers/quota-recovery.js +70 -0
  33. package/dist/modules/storage_s3/workers/quota-recovery.js.map +7 -0
  34. package/jest.config.cjs +1 -0
  35. package/package.json +13 -6
  36. package/src/modules/storage_s3/__tests__/endpoint-safety.test.ts +71 -0
  37. package/src/modules/storage_s3/__tests__/health.test.ts +38 -0
  38. package/src/modules/storage_s3/__tests__/i18nPackageExports.test.ts +13 -0
  39. package/src/modules/storage_s3/__tests__/quota-lifecycle.test.ts +216 -0
  40. package/src/modules/storage_s3/__tests__/quota-recovery.test.ts +83 -0
  41. package/src/modules/storage_s3/__tests__/s3Driver.test.ts +23 -8
  42. package/src/modules/storage_s3/__tests__/s3ListRoute.test.ts +7 -1
  43. package/src/modules/storage_s3/__tests__/s3Routes.test.ts +26 -0
  44. package/src/modules/storage_s3/__tests__/storageService.test.ts +20 -5
  45. package/src/modules/storage_s3/__tests__/upload.test.ts +93 -0
  46. package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +22 -5
  47. package/src/modules/storage_s3/api/get/storage-providers/s3/download.ts +7 -5
  48. package/src/modules/storage_s3/api/get/storage-providers/s3/list.ts +6 -4
  49. package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +77 -6
  50. package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +101 -16
  51. package/src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].ts +150 -0
  52. package/src/modules/storage_s3/di.ts +14 -2
  53. package/src/modules/storage_s3/i18n/de.json +19 -0
  54. package/src/modules/storage_s3/i18n/en.json +19 -0
  55. package/src/modules/storage_s3/i18n/es.json +19 -0
  56. package/src/modules/storage_s3/i18n/ko.json +19 -0
  57. package/src/modules/storage_s3/i18n/pl.json +19 -0
  58. package/src/modules/storage_s3/integration.ts +1 -1
  59. package/src/modules/storage_s3/lib/endpoint-safety.ts +179 -0
  60. package/src/modules/storage_s3/lib/health.ts +18 -0
  61. package/src/modules/storage_s3/lib/key-scope.ts +4 -4
  62. package/src/modules/storage_s3/lib/quota-accounting.ts +29 -0
  63. package/src/modules/storage_s3/lib/quota-recovery-queue.ts +17 -0
  64. package/src/modules/storage_s3/lib/s3-driver.ts +65 -10
  65. package/src/modules/storage_s3/lib/storage-service.ts +131 -22
  66. package/src/modules/storage_s3/workers/quota-recovery.ts +82 -0
@@ -1,5 +1,8 @@
1
- import { randomUUID } from 'crypto'
1
+ import { createHash, randomBytes } from 'crypto'
2
2
  import { S3StorageDriver } from './s3-driver'
3
+ import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
4
+ import { resolveAttachmentMaxBytes } from '@open-mercato/core/modules/attachments/lib/upload-limits'
5
+ import { reconcileTenantS3Objects } from './quota-accounting'
3
6
  import {
4
7
  assertS3KeyAddressableByTenantScope,
5
8
  assertS3KeyScopedToTenant,
@@ -34,6 +37,7 @@ type SignedUrlInput = {
34
37
  operation: 'upload' | 'download'
35
38
  expiresIn?: number
36
39
  contentType?: string
40
+ size?: number
37
41
  scope: TenantScope
38
42
  }
39
43
  type ListInput = {
@@ -53,6 +57,11 @@ type S3Config = {
53
57
  accessKeyId?: string
54
58
  secretAccessKey?: string
55
59
  sessionToken?: string
60
+ quotaService?: AttachmentQuotaService
61
+ quotaRecoveryScheduler?: (
62
+ payload: { reservationId: string; tenantId: string; organizationId: string },
63
+ delayMs: number,
64
+ ) => Promise<void>
56
65
  pathPrefix?: string
57
66
  organizationId?: string | null
58
67
  tenantId?: string | null
@@ -62,19 +71,11 @@ function sanitizeSegment(value: string): string {
62
71
  return value.replace(/[^a-zA-Z0-9._-]/g, '_')
63
72
  }
64
73
 
65
- function buildKey(namespace: string, scope: TenantScope, fileName: string): string {
66
- const orgSeg = scope.organizationId ? `org_${scope.organizationId}` : 'org_shared'
67
- const tenantSeg = scope.tenantId ? `tenant_${scope.tenantId}` : 'tenant_shared'
68
- const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)
69
- const safeName = sanitizeSegment(fileName || 'file')
70
- return `${sanitizeSegment(namespace)}/${orgSeg}/${tenantSeg}/${Date.now()}_${uniqueSuffix}_${safeName}`
71
- }
72
-
73
74
  export interface StorageService {
74
75
  upload(input: UploadInput): Promise<StorageResult>
75
76
  download(input: DownloadInput): Promise<{ buffer: Buffer; contentType?: string }>
76
77
  delete(input: DeleteInput): Promise<void>
77
- getSignedUrl(input: SignedUrlInput): Promise<{ url: string; expiresAt: Date }>
78
+ getSignedUrl(input: SignedUrlInput): Promise<{ url: string; expiresAt: Date; reservationId?: string }>
78
79
  list(input: ListInput): Promise<{
79
80
  files: Array<{ key: string; size: number; lastModified: Date }>
80
81
  truncated: boolean
@@ -85,23 +86,86 @@ export interface StorageService {
85
86
 
86
87
  export function createStorageService(config: S3Config): StorageService {
87
88
  const driver = new S3StorageDriver(config as Record<string, unknown>)
89
+ const quotaService = config.quotaService
90
+ const quotaRecoveryScheduler = config.quotaRecoveryScheduler
88
91
  const pathPrefix = config.pathPrefix
89
92
 
90
93
  return {
91
94
  async upload({ namespace, fileName, buffer, contentType, scope }): Promise<StorageResult> {
92
- const stored = await driver.store({
93
- partitionCode: namespace,
95
+ const storagePath = driver.prepareStoragePath({
96
+ partitionCode: sanitizeSegment(namespace),
94
97
  orgId: scope.organizationId,
95
98
  tenantId: scope.tenantId,
96
99
  fileName,
97
- buffer,
98
100
  })
99
- return {
100
- key: stored.storagePath,
101
- namespace,
102
- fileName,
103
- size: buffer.length,
104
- contentType,
101
+ assertS3KeyScopedToTenant(storagePath, scope, pathPrefix)
102
+ let reservation = null as Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null
103
+ let storageStarted = false
104
+ let objectStored = false
105
+ try {
106
+ if (quotaService && scope.tenantId && scope.organizationId) {
107
+ await reconcileTenantS3Objects({
108
+ driver,
109
+ quotaService,
110
+ tenantId: scope.tenantId,
111
+ organizationId: scope.organizationId,
112
+ })
113
+ reservation = await quotaService.reserve({
114
+ tenantId: scope.tenantId,
115
+ organizationId: scope.organizationId,
116
+ bytes: buffer.length,
117
+ source: 'storage_service',
118
+ storageDriver: 's3',
119
+ storagePath,
120
+ partitionCode: namespace,
121
+ })
122
+ }
123
+ if (reservation) {
124
+ if (!quotaRecoveryScheduler) throw new Error('Storage quota recovery is unavailable.')
125
+ await quotaRecoveryScheduler({
126
+ reservationId: reservation.id,
127
+ tenantId: scope.tenantId!,
128
+ organizationId: scope.organizationId!,
129
+ },
130
+ Math.max(1_000, reservation.expiresAt.getTime() - Date.now()),
131
+ )
132
+ await quotaService!.beginStorage(reservation.id, reservation.leaseToken)
133
+ storageStarted = true
134
+ }
135
+ const stored = await driver.store({
136
+ partitionCode: namespace,
137
+ orgId: scope.organizationId,
138
+ tenantId: scope.tenantId,
139
+ fileName,
140
+ buffer,
141
+ storagePath,
142
+ })
143
+ objectStored = true
144
+ if (reservation) {
145
+ await quotaService!.markStored(reservation.id, reservation.leaseToken)
146
+ await quotaService!.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)
147
+ }
148
+ return {
149
+ key: stored.storagePath,
150
+ namespace,
151
+ fileName,
152
+ size: buffer.length,
153
+ contentType,
154
+ }
155
+ } catch (error) {
156
+ if (reservation) {
157
+ if (objectStored) {
158
+ try {
159
+ await driver.deleteStrict('', storagePath)
160
+ await quotaService!.release(reservation.id, reservation.leaseToken)
161
+ } catch {
162
+ // Keep the reservation counted for recovery when absence is ambiguous.
163
+ }
164
+ } else if (!storageStarted) {
165
+ await quotaService!.release(reservation.id, reservation.leaseToken).catch(() => {})
166
+ }
167
+ }
168
+ throw error
105
169
  }
106
170
  },
107
171
 
@@ -112,17 +176,62 @@ export function createStorageService(config: S3Config): StorageService {
112
176
 
113
177
  async delete({ key, scope }): Promise<void> {
114
178
  assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)
115
- return driver.delete('', key)
179
+ return driver.deleteStrict('', key)
180
+ .then(async () => {
181
+ if (quotaService && scope.tenantId) {
182
+ await quotaService.releaseCommittedByPath({
183
+ tenantId: scope.tenantId,
184
+ storageDriver: 's3',
185
+ storagePath: key,
186
+ })
187
+ }
188
+ })
116
189
  },
117
190
 
118
- async getSignedUrl({ key, operation, expiresIn = 3600, contentType, scope }): Promise<{ url: string; expiresAt: Date }> {
191
+ async getSignedUrl({ key, operation, expiresIn = 3600, contentType, size, scope }) {
119
192
  if (operation === 'upload') {
120
193
  assertS3KeyScopedToTenant(key, scope, pathPrefix)
121
194
  } else {
122
195
  assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)
123
196
  }
197
+ const expiresAt = new Date(Date.now() + expiresIn * 1000)
198
+ if (operation === 'upload' && quotaService && scope.tenantId && scope.organizationId) {
199
+ const compatibilityToken = randomBytes(32).toString('base64url')
200
+ let reservation: Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null = null
201
+ try {
202
+ await reconcileTenantS3Objects({
203
+ driver,
204
+ quotaService,
205
+ tenantId: scope.tenantId,
206
+ organizationId: scope.organizationId,
207
+ })
208
+ reservation = await quotaService.reserve({
209
+ tenantId: scope.tenantId,
210
+ organizationId: scope.organizationId,
211
+ bytes: size ?? resolveAttachmentMaxBytes(),
212
+ source: 'storage_s3_signed',
213
+ storageDriver: 's3',
214
+ storagePath: key,
215
+ uploadTokenHash: createHash('sha256').update(compatibilityToken).digest('hex'),
216
+ ttlMs: expiresIn * 1000,
217
+ })
218
+ if (!quotaRecoveryScheduler) throw new Error('Storage quota recovery is unavailable.')
219
+ await quotaRecoveryScheduler({
220
+ reservationId: reservation.id,
221
+ tenantId: scope.tenantId!,
222
+ organizationId: scope.organizationId!,
223
+ }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))
224
+ const url = `/api/storage-providers/s3/signed-upload/${compatibilityToken}`
225
+ return { url, expiresAt, reservationId: reservation.id }
226
+ } catch (error) {
227
+ if (reservation) {
228
+ await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
229
+ }
230
+ throw error
231
+ }
232
+ }
124
233
  const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope)
125
- return { url, expiresAt: new Date(Date.now() + expiresIn * 1000) }
234
+ return { url, expiresAt }
126
235
  },
127
236
 
128
237
  async list({ prefix, maxKeys = 100, continuationToken, scope }): Promise<{
@@ -0,0 +1,82 @@
1
+ import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
2
+ import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
3
+ import { S3StorageDriver } from '../lib/s3-driver'
4
+ import { scheduleStorageS3QuotaRecovery, type StorageS3QuotaRecoveryJob } from '../lib/quota-recovery-queue'
5
+
6
+ export const metadata: WorkerMeta = {
7
+ queue: 'storage-s3-quota-recovery',
8
+ id: 'storage_s3:quota-recovery',
9
+ concurrency: 2,
10
+ }
11
+
12
+ type HandlerContext = JobContext & { resolve: <T = unknown>(name: string) => T }
13
+
14
+ type IntegrationCredentialsService = {
15
+ resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>
16
+ }
17
+
18
+ export default async function handle(
19
+ job: QueuedJob<StorageS3QuotaRecoveryJob>,
20
+ ctx: HandlerContext,
21
+ ): Promise<void> {
22
+ const quotaService = ctx.resolve<AttachmentQuotaService>('attachmentQuotaService')
23
+ const scope = {
24
+ tenantId: job.payload.tenantId,
25
+ organizationId: job.payload.organizationId,
26
+ }
27
+ const record = await quotaService.getReservation(job.payload.reservationId, scope)
28
+ if (!record || record.status === 'committed') return
29
+ if (record.expiresAt && record.expiresAt.getTime() > Date.now()) {
30
+ await scheduleStorageS3QuotaRecovery(job.payload, record.expiresAt.getTime() - Date.now())
31
+ return
32
+ }
33
+
34
+ const claimed = await quotaService.claimExpired(record.id, scope)
35
+ if (!claimed) return
36
+ try {
37
+ const credentialsService = ctx.resolve<IntegrationCredentialsService>('integrationCredentialsService')
38
+ const credentials = await credentialsService.resolve('storage_s3', {
39
+ tenantId: claimed.tenantId,
40
+ organizationId: claimed.organizationId,
41
+ })
42
+ if (!credentials) throw new Error('S3 integration is not configured for quota recovery.')
43
+ const driver = new S3StorageDriver({
44
+ ...credentials,
45
+ organizationId: claimed.organizationId,
46
+ tenantId: claimed.tenantId,
47
+ })
48
+
49
+ if (claimed.source === 'storage_s3_signed') {
50
+ const listed = await driver.listObjects(claimed.storagePath, 1)
51
+ const object = listed.files.find((file) => file.key === claimed.storagePath)
52
+ if (!object) {
53
+ if (record.status === 'storing' && (job.payload.absenceCheck ?? 0) === 0 && claimed.expiresAt) {
54
+ await scheduleStorageS3QuotaRecovery({
55
+ ...job.payload,
56
+ absenceCheck: 1,
57
+ },
58
+ claimed.expiresAt.getTime() - Date.now(),
59
+ )
60
+ return
61
+ }
62
+ await quotaService.release(claimed.id, claimed.leaseToken)
63
+ return
64
+ }
65
+ const validSize = claimed.uploadTokenHash
66
+ ? object.size <= claimed.reservedBytes
67
+ : object.size === claimed.reservedBytes
68
+ if (validSize) {
69
+ await quotaService.commitRecoveredStandalone(claimed.id, claimed.leaseToken, object.size)
70
+ return
71
+ }
72
+ }
73
+
74
+ await driver.deleteStrict('', claimed.storagePath)
75
+ await quotaService.release(claimed.id, claimed.leaseToken)
76
+ } catch (error) {
77
+ if (claimed.expiresAt) {
78
+ await scheduleStorageS3QuotaRecovery(job.payload, claimed.expiresAt.getTime() - Date.now())
79
+ }
80
+ throw error
81
+ }
82
+ }