@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
@@ -0,0 +1,179 @@
1
+ import { lookup as dnsLookup } from 'node:dns/promises'
2
+ import { Agent as HttpAgent } from 'node:http'
3
+ import { Agent as HttpsAgent } from 'node:https'
4
+ import { isIP, type LookupFunction } from 'node:net'
5
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
6
+ import {
7
+ assertSafeOutboundUrl,
8
+ assertStaticallySafeOutboundUrl,
9
+ parseOutboundUrl,
10
+ resolveSafeOutboundUrl,
11
+ type HostLookup,
12
+ type ResolvedHostAddress,
13
+ type UrlSafetyReason,
14
+ } from '@open-mercato/shared/lib/url-safety'
15
+
16
+ const SUBJECT = 'S3 endpoint'
17
+
18
+ export class UnsafeS3EndpointError extends Error {
19
+ public readonly reason: string
20
+
21
+ constructor(reason: string, message?: string) {
22
+ super(message ?? `S3 endpoint rejected: ${reason}`)
23
+ this.name = 'UnsafeS3EndpointError'
24
+ this.reason = reason
25
+ }
26
+ }
27
+
28
+ const s3EndpointErrorFactory = (reason: UrlSafetyReason, message: string) =>
29
+ new UnsafeS3EndpointError(reason, message)
30
+
31
+ export type AssertStaticS3EndpointDeps = {
32
+ allowInternal?: boolean
33
+ }
34
+
35
+ export function assertStaticallySafeS3Endpoint(
36
+ rawEndpoint: string | null | undefined,
37
+ deps: AssertStaticS3EndpointDeps = {},
38
+ ): void {
39
+ if (!rawEndpoint) return
40
+ const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled()
41
+ assertStaticallySafeOutboundUrl(rawEndpoint, {
42
+ errorFactory: s3EndpointErrorFactory,
43
+ subject: SUBJECT,
44
+ allowPrivate,
45
+ })
46
+ }
47
+
48
+ export type AssertSafeS3EndpointDeps = {
49
+ lookupHost?: HostLookup
50
+ allowInternal?: boolean
51
+ }
52
+
53
+ export async function assertSafeS3Endpoint(
54
+ rawEndpoint: string | null | undefined,
55
+ deps: AssertSafeS3EndpointDeps = {},
56
+ ): Promise<void> {
57
+ if (!rawEndpoint) return
58
+ const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled()
59
+ await assertSafeOutboundUrl(rawEndpoint, {
60
+ errorFactory: s3EndpointErrorFactory,
61
+ subject: SUBJECT,
62
+ allowPrivate,
63
+ lookupHost: deps.lookupHost,
64
+ })
65
+ }
66
+
67
+ export type SafeS3EndpointLookupDeps = {
68
+ lookupHost?: HostLookup
69
+ allowInternal?: boolean
70
+ }
71
+
72
+ export function createSafeS3EndpointLookup(
73
+ rawEndpoint: string | null | undefined,
74
+ deps: SafeS3EndpointLookupDeps = {},
75
+ ): LookupFunction | undefined {
76
+ if (!rawEndpoint) return undefined
77
+ const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled()
78
+ if (allowPrivate) return undefined
79
+
80
+ const { hostname } = parseOutboundUrl(rawEndpoint, {
81
+ errorFactory: s3EndpointErrorFactory,
82
+ subject: SUBJECT,
83
+ })
84
+ const endpointHostname = normalizeLookupHostname(hostname)
85
+
86
+ return (host, options, callback) => {
87
+ const lookupHostname = normalizeLookupHostname(host)
88
+ if (!isAllowedS3LookupHost(lookupHostname, endpointHostname)) {
89
+ callback(new UnsafeS3EndpointError(
90
+ 'blocked_hostname',
91
+ `S3 endpoint lookup host "${host}" does not match configured host "${endpointHostname}"`,
92
+ ), '', 0)
93
+ return
94
+ }
95
+
96
+ resolveSafeOutboundUrl(buildLookupValidationUrl(rawEndpoint, lookupHostname), {
97
+ errorFactory: s3EndpointErrorFactory,
98
+ subject: SUBJECT,
99
+ lookupHost: deps.lookupHost ?? defaultLookupHost,
100
+ }).then(({ addresses }) => {
101
+ if (!addresses || addresses.length === 0) {
102
+ const family = isIP(lookupHostname)
103
+ if (options.all) callback(null, [{ address: lookupHostname, family }] as ResolvedHostAddress[])
104
+ else callback(null, lookupHostname, family)
105
+ return
106
+ }
107
+ if (options.all) {
108
+ callback(null, addresses.map((record) => ({ address: record.address, family: record.family })))
109
+ return
110
+ }
111
+ callback(null, addresses[0].address, addresses[0].family)
112
+ }).catch((error: unknown) => {
113
+ callback(error instanceof Error ? error : new UnsafeS3EndpointError('dns_resolution_failed'), '', 0)
114
+ })
115
+ }
116
+ }
117
+
118
+ function normalizeLookupHostname(hostname: string): string {
119
+ let normalized = hostname.trim().toLowerCase()
120
+ if (normalized.startsWith('[') && normalized.endsWith(']')) {
121
+ normalized = normalized.slice(1, -1)
122
+ }
123
+ while (normalized.endsWith('.')) {
124
+ normalized = normalized.slice(0, -1)
125
+ }
126
+ return normalized
127
+ }
128
+
129
+ function isAllowedS3LookupHost(lookupHostname: string, endpointHostname: string): boolean {
130
+ if (lookupHostname === endpointHostname) return true
131
+ if (isIP(endpointHostname)) return false
132
+ return lookupHostname.endsWith(`.${endpointHostname}`)
133
+ }
134
+
135
+ function buildLookupValidationUrl(rawEndpoint: string, lookupHostname: string): string {
136
+ const { url } = parseOutboundUrl(rawEndpoint, {
137
+ errorFactory: s3EndpointErrorFactory,
138
+ subject: SUBJECT,
139
+ })
140
+ const host = isIP(lookupHostname) === 6 ? `[${lookupHostname}]` : lookupHostname
141
+ const port = url.port ? `:${url.port}` : ''
142
+ return `${url.protocol}//${host}${port}`
143
+ }
144
+
145
+ export type SafeS3RequestHandlerOptions = {
146
+ connectionTimeout: number
147
+ requestTimeout: number
148
+ httpAgent?: HttpAgent
149
+ httpsAgent?: HttpsAgent
150
+ }
151
+
152
+ const S3_CONNECTION_TIMEOUT_MS = 10_000
153
+ const S3_REQUEST_TIMEOUT_MS = 120_000
154
+
155
+ export function createSafeS3RequestHandler(
156
+ rawEndpoint: string | null | undefined,
157
+ deps: SafeS3EndpointLookupDeps = {},
158
+ ): SafeS3RequestHandlerOptions | undefined {
159
+ const lookup = createSafeS3EndpointLookup(rawEndpoint, deps)
160
+ const timeouts = {
161
+ connectionTimeout: S3_CONNECTION_TIMEOUT_MS,
162
+ requestTimeout: S3_REQUEST_TIMEOUT_MS,
163
+ }
164
+ if (!lookup) return timeouts
165
+ return {
166
+ ...timeouts,
167
+ httpAgent: new HttpAgent({ lookup }),
168
+ httpsAgent: new HttpsAgent({ lookup }),
169
+ }
170
+ }
171
+
172
+ export function isAllowInternalS3EndpointsEnabled(): boolean {
173
+ return parseBooleanWithDefault(process.env.OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS, false)
174
+ }
175
+
176
+ const defaultLookupHost: HostLookup = async (hostname) => {
177
+ const records = await dnsLookup(hostname, { all: true, verbatim: true })
178
+ return records
179
+ }
@@ -1,4 +1,5 @@
1
1
  import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3'
2
+ import { assertSafeS3Endpoint, createSafeS3RequestHandler } from './endpoint-safety'
2
3
  import type { S3DriverConfig } from './s3-driver'
3
4
 
4
5
  export const s3HealthCheck = {
@@ -67,11 +68,28 @@ export const s3HealthCheck = {
67
68
  }
68
69
  }
69
70
 
71
+ try {
72
+ await assertSafeS3Endpoint(cfg.endpoint)
73
+ } catch (err: unknown) {
74
+ const message = err instanceof Error ? err.message : 'Unsafe S3 endpoint'
75
+ const reason = err && typeof err === 'object' && 'reason' in err ? (err as { reason?: unknown }).reason : undefined
76
+ return {
77
+ status: 'unhealthy',
78
+ message,
79
+ details: {
80
+ ...(typeof reason === 'string' ? { reason } : {}),
81
+ },
82
+ checkedAt,
83
+ }
84
+ }
85
+
86
+ const requestHandler = createSafeS3RequestHandler(cfg.endpoint)
70
87
  const client = new S3Client({
71
88
  region: cfg.region ?? 'us-east-1',
72
89
  endpoint: cfg.endpoint,
73
90
  forcePathStyle: cfg.forcePathStyle ?? false,
74
91
  credentials: credentialConfig,
92
+ ...(requestHandler ? { requestHandler } : {}),
75
93
  })
76
94
 
77
95
  try {
@@ -113,7 +113,7 @@ export function assertS3KeyScopedToTenant(
113
113
  pathPrefix?: string,
114
114
  ): void {
115
115
  if (!isS3KeyScopedToTenant(storagePath, scope, pathPrefix)) {
116
- throw new Error('S3 key is not scoped to the active tenant')
116
+ throw new Error('[internal] S3 key is not scoped to the active tenant')
117
117
  }
118
118
  }
119
119
 
@@ -123,7 +123,7 @@ export function assertS3KeyAddressableByTenantScope(
123
123
  pathPrefix?: string,
124
124
  ): void {
125
125
  if (!isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix)) {
126
- throw new Error('S3 key is not scoped to the active tenant')
126
+ throw new Error('[internal] S3 key is not scoped to the active tenant')
127
127
  }
128
128
  }
129
129
 
@@ -140,7 +140,7 @@ export function assertS3ListPrefixScopedToTenant(
140
140
 
141
141
  if (parts[0]?.startsWith('org_') || parts[1]?.startsWith('tenant_')) {
142
142
  if (parts[0] !== segments.orgSegment || parts[1] !== segments.tenantSegment) {
143
- throw new Error('S3 prefix is not scoped to the active tenant')
143
+ throw new Error('[internal] S3 prefix is not scoped to the active tenant')
144
144
  }
145
145
  return
146
146
  }
@@ -149,7 +149,7 @@ export function assertS3ListPrefixScopedToTenant(
149
149
  (parts[1]?.startsWith('org_') && parts[1] !== segments.orgSegment)
150
150
  || (parts[2]?.startsWith('tenant_') && parts[2] !== segments.tenantSegment)
151
151
  ) {
152
- throw new Error('S3 prefix is not scoped to the active tenant')
152
+ throw new Error('[internal] S3 prefix is not scoped to the active tenant')
153
153
  }
154
154
  }
155
155
 
@@ -0,0 +1,29 @@
1
+ import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
2
+ import { isS3KeyScopedToTenant } from './key-scope'
3
+ import type { S3StorageDriver } from './s3-driver'
4
+
5
+ export async function reconcileTenantS3Objects(input: {
6
+ driver: S3StorageDriver
7
+ quotaService: AttachmentQuotaService
8
+ tenantId: string
9
+ organizationId: string
10
+ }): Promise<void> {
11
+ const objects: Array<{ path: string; bytes: number }> = []
12
+ let continuationToken: string | undefined
13
+ do {
14
+ const page = await input.driver.listObjects('', 1000, continuationToken)
15
+ for (const file of page.files) {
16
+ if (isS3KeyScopedToTenant(file.key, input.organizationId, input.tenantId)) {
17
+ objects.push({ path: file.key, bytes: file.size })
18
+ }
19
+ }
20
+ continuationToken = page.truncated ? page.nextContinuationToken : undefined
21
+ } while (continuationToken)
22
+
23
+ await input.quotaService.reconcileStandaloneObjects({
24
+ tenantId: input.tenantId,
25
+ organizationId: input.organizationId,
26
+ storageDriver: 's3',
27
+ objects,
28
+ })
29
+ }
@@ -0,0 +1,17 @@
1
+ import { createModuleQueue } from '@open-mercato/queue'
2
+
3
+ export type StorageS3QuotaRecoveryJob = {
4
+ reservationId: string
5
+ tenantId: string
6
+ organizationId: string
7
+ absenceCheck?: number
8
+ }
9
+
10
+ const queue = createModuleQueue<StorageS3QuotaRecoveryJob>('storage-s3-quota-recovery', { concurrency: 2 })
11
+
12
+ export async function scheduleStorageS3QuotaRecovery(
13
+ payload: StorageS3QuotaRecoveryJob,
14
+ delayMs: number,
15
+ ): Promise<void> {
16
+ await queue.enqueue(payload, { delayMs: Math.max(1_000, delayMs) })
17
+ }
@@ -10,7 +10,12 @@ import {
10
10
  ListObjectsV2Command,
11
11
  } from '@aws-sdk/client-s3'
12
12
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
13
- import type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'
13
+ import type { PrepareFilePayload, StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'
14
+ import {
15
+ assertSafeS3Endpoint,
16
+ assertStaticallySafeS3Endpoint,
17
+ createSafeS3RequestHandler,
18
+ } from './endpoint-safety'
14
19
  import {
15
20
  assertS3KeyAddressableByTenantScope,
16
21
  assertS3KeyScopedToTenant,
@@ -71,12 +76,14 @@ export class S3StorageDriver implements StorageDriver {
71
76
  readonly key = 's3'
72
77
  private readonly client: S3Client
73
78
  private readonly bucket: string
79
+ private readonly endpoint?: string
74
80
  private readonly pathPrefix: string
75
81
  private readonly scope: S3TenantScope | null
76
82
 
77
83
  constructor(config: Record<string, unknown>) {
78
84
  const cfg = config as S3DriverConfig
79
85
  this.bucket = cfg.bucket
86
+ this.endpoint = cfg.endpoint
80
87
  this.pathPrefix = cfg.pathPrefix ?? ''
81
88
  this.scope = cfg.organizationId && cfg.tenantId
82
89
  ? { organizationId: cfg.organizationId, tenantId: cfg.tenantId }
@@ -109,11 +116,15 @@ export class S3StorageDriver implements StorageDriver {
109
116
  }
110
117
  }
111
118
 
119
+ assertStaticallySafeS3Endpoint(cfg.endpoint)
120
+ const requestHandler = createSafeS3RequestHandler(cfg.endpoint)
121
+
112
122
  this.client = new S3Client({
113
123
  region: cfg.region ?? 'us-east-1',
114
124
  endpoint: cfg.endpoint,
115
125
  forcePathStyle: cfg.forcePathStyle ?? false,
116
126
  credentials,
127
+ ...(requestHandler ? { requestHandler } : {}),
117
128
  })
118
129
  }
119
130
 
@@ -132,17 +143,24 @@ export class S3StorageDriver implements StorageDriver {
132
143
  : storagePath.replace(/^\/+/, '')
133
144
  const firstSegment = prefix.split('/').filter(Boolean)[0]
134
145
  if (firstSegment !== partitionCode) {
135
- throw new Error('S3 key is not scoped to the requested partition')
146
+ throw new Error('[internal] S3 key is not scoped to the requested partition')
136
147
  }
137
148
  }
138
149
 
139
- async store(payload: StoreFilePayload): Promise<StoredFile> {
150
+ prepareStoragePath(payload: PrepareFilePayload): string {
140
151
  const orgSegment = resolveOrgSegment(payload.orgId ?? null)
141
152
  const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)
142
153
  const safeName = sanitizeFileName(payload.fileName || 'file')
143
154
  const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)
144
155
  const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`
145
- const key = `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`
156
+ return `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`
157
+ }
158
+
159
+ async store(payload: StoreFilePayload): Promise<StoredFile> {
160
+ const key = payload.storagePath ?? this.prepareStoragePath(payload)
161
+ this.assertPartitionScoped(payload.partitionCode, key)
162
+ this.assertKeyScoped(key)
163
+ await this.assertEndpointSafe()
146
164
 
147
165
  await this.client.send(
148
166
  new PutObjectCommand({
@@ -150,6 +168,7 @@ export class S3StorageDriver implements StorageDriver {
150
168
  Key: key,
151
169
  Body: payload.buffer,
152
170
  ContentLength: payload.buffer.length,
171
+ IfNoneMatch: '*',
153
172
  }),
154
173
  )
155
174
 
@@ -159,11 +178,12 @@ export class S3StorageDriver implements StorageDriver {
159
178
  async read(partitionCode: string, storagePath: string): Promise<ReadFileResult> {
160
179
  this.assertPartitionScoped(partitionCode, storagePath)
161
180
  this.assertKeyAddressable(storagePath)
181
+ await this.assertEndpointSafe()
162
182
  const response = await this.client.send(
163
183
  new GetObjectCommand({ Bucket: this.bucket, Key: storagePath }),
164
184
  )
165
185
  if (!response.Body) {
166
- throw new Error(`S3 object body is empty for key: ${storagePath}`)
186
+ throw new Error(`[internal] S3 object body is empty for key: ${storagePath}`)
167
187
  }
168
188
  const buffer = await streamToBuffer(response.Body as NodeJS.ReadableStream)
169
189
  return { buffer, contentType: response.ContentType }
@@ -173,14 +193,19 @@ export class S3StorageDriver implements StorageDriver {
173
193
  this.assertPartitionScoped(partitionCode, storagePath)
174
194
  this.assertKeyAddressable(storagePath)
175
195
  try {
176
- await this.client.send(
177
- new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }),
178
- )
196
+ await this.deleteStrict(partitionCode, storagePath)
179
197
  } catch {
180
- // best-effort removal
198
+ // Backward-compatible best-effort deletion.
181
199
  }
182
200
  }
183
201
 
202
+ async deleteStrict(partitionCode: string, storagePath: string): Promise<void> {
203
+ this.assertPartitionScoped(partitionCode, storagePath)
204
+ this.assertKeyAddressable(storagePath)
205
+ await this.assertEndpointSafe()
206
+ await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }))
207
+ }
208
+
184
209
  async toLocalPath(
185
210
  partitionCode: string,
186
211
  storagePath: string,
@@ -207,6 +232,7 @@ export class S3StorageDriver implements StorageDriver {
207
232
  */
208
233
  async putObject(key: string, buffer: Buffer, contentType?: string): Promise<void> {
209
234
  this.assertKeyScoped(key)
235
+ await this.assertEndpointSafe()
210
236
  await this.client.send(
211
237
  new PutObjectCommand({
212
238
  Bucket: this.bucket,
@@ -214,6 +240,7 @@ export class S3StorageDriver implements StorageDriver {
214
240
  Body: buffer,
215
241
  ContentType: contentType,
216
242
  ContentLength: buffer.length,
243
+ IfNoneMatch: '*',
217
244
  }),
218
245
  )
219
246
  }
@@ -234,6 +261,7 @@ export class S3StorageDriver implements StorageDriver {
234
261
  }> {
235
262
  const activeScope = scope ?? this.scope
236
263
  assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix)
264
+ await this.assertEndpointSafe()
237
265
  const response = await this.client.send(
238
266
  new ListObjectsV2Command({
239
267
  Bucket: this.bucket,
@@ -260,24 +288,51 @@ export class S3StorageDriver implements StorageDriver {
260
288
  async getSignedUrl(
261
289
  storagePath: string,
262
290
  operation: 'upload' | 'download',
263
- expiresIn = 3600,
291
+ expiresIn?: number,
264
292
  contentType?: string,
293
+ contentLength?: number,
294
+ createOnly?: boolean,
295
+ scope?: S3TenantScope | null,
296
+ ): Promise<string>
297
+ async getSignedUrl(
298
+ storagePath: string,
299
+ operation: 'upload' | 'download',
300
+ expiresIn: number,
301
+ contentType: string | undefined,
265
302
  scope?: S3TenantScope | null,
303
+ ): Promise<string>
304
+ async getSignedUrl(
305
+ storagePath: string,
306
+ operation: 'upload' | 'download',
307
+ expiresIn = 3600,
308
+ contentType?: string,
309
+ contentLengthOrScope?: number | S3TenantScope | null,
310
+ createOnly = false,
311
+ explicitScope?: S3TenantScope | null,
266
312
  ): Promise<string> {
313
+ const contentLength = typeof contentLengthOrScope === 'number' ? contentLengthOrScope : undefined
314
+ const scope = typeof contentLengthOrScope === 'number' ? explicitScope : contentLengthOrScope
267
315
  if (operation === 'upload') {
268
316
  this.assertKeyScoped(storagePath, scope)
269
317
  } else {
270
318
  this.assertKeyAddressable(storagePath, scope)
271
319
  }
320
+ await this.assertEndpointSafe()
272
321
  if (operation === 'upload') {
273
322
  const command = new PutObjectCommand({
274
323
  Bucket: this.bucket,
275
324
  Key: storagePath,
276
325
  ContentType: contentType,
326
+ ContentLength: contentLength,
327
+ ...(createOnly ? { IfNoneMatch: '*' } : {}),
277
328
  })
278
329
  return getSignedUrl(this.client, command, { expiresIn })
279
330
  }
280
331
  const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })
281
332
  return getSignedUrl(this.client, command, { expiresIn })
282
333
  }
334
+
335
+ private async assertEndpointSafe(): Promise<void> {
336
+ await assertSafeS3Endpoint(this.endpoint)
337
+ }
283
338
  }