@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,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../src/modules/storage_s3/api/post/storage-providers/s3/upload.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport {\n isMultipartUploadLimitError,\n isMultipartRequestWithinUploadLimit,\n parseMultipartFormDataWithinUploadLimit,\n resolveAttachmentMaxBytes,\n willExceedAttachmentTenantQuota,\n} from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport { isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { reconcileTenantS3Objects } from '../../../../lib/quota-accounting'\nimport { randomUUID } from 'crypto'\n\nexport const metadata = {\n path: '/storage-providers/s3/upload',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst responseSchema = z.object({\n key: z.string(),\n bucket: z.string(),\n size: z.number().int(),\n contentType: z.string().optional(),\n})\n\nfunction sanitizeFileName(name: string): string {\n return name.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload'\n}\n\nasync function resolveDriver(\n tenantId: string,\n orgId: string,\n): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nasync function readTenantStorageUsageBytes(\n driver: S3StorageDriver,\n tenantId: string,\n orgId: string,\n): Promise<number> {\n let totalBytes = 0\n let continuationToken: string | undefined\n\n do {\n const page = await driver.listObjects('', 1000, continuationToken)\n for (const file of page.files) {\n if (isS3KeyScopedToTenant(file.key, orgId, tenantId)) {\n totalBytes += file.size\n }\n }\n continuationToken = page.truncated ? page.nextContinuationToken : undefined\n } while (continuationToken)\n\n return totalBytes\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n\n const contentType = req.headers.get('content-type') ?? ''\n if (!contentType.includes('multipart/form-data')) {\n return NextResponse.json({ error: t('storage_s3.errors.multipartRequired', 'Expected multipart/form-data') }, { status: 400 })\n }\n\n let form: FormData\n try {\n form = await parseMultipartFormDataWithinUploadLimit(req)\n } catch (error) {\n if (isMultipartUploadLimitError(error)) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n throw error\n }\n const file = form.get('file') as File | null\n if (!file) {\n return NextResponse.json({ error: t('storage_s3.errors.fileRequired', 'file field is required') }, { status: 400 })\n }\n\n const keyOverride = form.get('key') ? String(form.get('key')) : null\n\n if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {\n return NextResponse.json(\n { error: t('storage_s3.errors.keyOverrideAccessDenied', 'Access denied: key override is not scoped to this tenant.') },\n { status: 403 },\n )\n }\n\n if (hasDangerousExecutableExtension(file.name)) {\n return NextResponse.json({ error: t('storage_s3.errors.dangerousExecutable', 'Executable file types are not allowed as attachments.') }, { status: 400 })\n }\n\n const effectiveMaxBytes = resolveAttachmentMaxBytes()\n if (file.size > effectiveMaxBytes) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })\n }\n\n const buffer = Buffer.from(await file.arrayBuffer())\n const safeName = sanitizeFileName(file.name)\n const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null)\n if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {\n return NextResponse.json({ error: t('storage_s3.errors.activeContentBlocked', 'Active content uploads are not allowed.') }, { status: 400 })\n }\n\n const key =\n keyOverride ??\n `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`\n\n const { resolve } = await createRequestContainer()\n let attachmentQuotaService: AttachmentQuotaService | null = null\n let recoveryScheduler: ((\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>) | null = null\n try {\n attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService\n recoveryScheduler = resolve('storageS3QuotaRecoveryScheduler') as (\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>\n } catch {\n // Backward-compatible fallback below when the attachments quota service is not registered.\n }\n let reservation: { id: string; leaseToken: string; expiresAt: Date } | null = null\n if (attachmentQuotaService) {\n try {\n await reconcileTenantS3Objects({\n driver,\n quotaService: attachmentQuotaService,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n reservation = await attachmentQuotaService.reserve({\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n bytes: buffer.length,\n source: 'storage_s3_upload',\n storageDriver: 's3',\n storagePath: key,\n })\n if (!recoveryScheduler) throw new Error('Storage quota recovery is unavailable.')\n await recoveryScheduler({\n reservationId: reservation.id,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))\n await attachmentQuotaService.beginStorage(reservation.id, reservation.leaseToken)\n } catch (error) {\n if (reservation) {\n await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n reservation = null\n }\n const code = (error as { code?: unknown })?.code\n if (code === 'quota_exceeded') {\n return NextResponse.json({ error: '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 } else {\n const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId)\n if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {\n return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })\n }\n }\n\n try {\n await driver.putObject(key, buffer, trustedMimeType)\n if (reservation) {\n await attachmentQuotaService!.markStored(reservation.id, reservation.leaseToken)\n await attachmentQuotaService!.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)\n }\n } catch (error) {\n if (reservation) {\n try {\n await driver.deleteStrict('', key)\n await attachmentQuotaService!.release(reservation.id, reservation.leaseToken)\n } catch {\n // Retain the reservation when object absence cannot be proven.\n }\n }\n return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })\n }\n\n return NextResponse.json({\n key,\n bucket: driver.getBucket(),\n size: buffer.length,\n contentType: trustedMimeType,\n })\n}\n\nexport default POST\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Upload file to S3',\n methods: {\n POST: {\n summary: 'Upload a file directly to S3',\n description: 'Uploads a file to the configured S3 bucket. Requires storage_providers.manage feature.',\n requestBody: {\n contentType: 'multipart/form-data',\n schema: z.object({\n file: z.any().describe('File to upload'),\n key: z.string().optional().describe('Optional S3 key override (must be scoped to org/tenant)'),\n contentType: z.string().optional().describe('Optional client-provided content-type hint; the server derives the trusted MIME type.'),\n }),\n },\n responses: [{ status: 200, description: 'Upload result', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Missing file, blocked file type, or S3 not configured', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Key override not scoped to this tenant', schema: z.object({ error: z.string() }) },\n { status: 413, description: 'Upload too large or tenant storage quota exceeded', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport {\n isMultipartRequestWithinUploadLimit,\n resolveAttachmentMaxBytes,\n willExceedAttachmentTenantQuota,\n} from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport { isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\nimport { randomUUID } from 'crypto'\n\nexport const metadata = {\n path: '/storage-providers/s3/upload',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst responseSchema = z.object({\n key: z.string(),\n bucket: z.string(),\n size: z.number().int(),\n contentType: z.string().optional(),\n})\n\nfunction sanitizeFileName(name: string): string {\n return name.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload'\n}\n\nasync function resolveDriver(\n tenantId: string,\n orgId: string,\n): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nasync function readTenantStorageUsageBytes(\n driver: S3StorageDriver,\n tenantId: string,\n orgId: string,\n): Promise<number> {\n let totalBytes = 0\n let continuationToken: string | undefined\n\n do {\n const page = await driver.listObjects('', 1000, continuationToken)\n for (const file of page.files) {\n if (isS3KeyScopedToTenant(file.key, orgId, tenantId)) {\n totalBytes += file.size\n }\n }\n continuationToken = page.truncated ? page.nextContinuationToken : undefined\n } while (continuationToken)\n\n return totalBytes\n}\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {\n return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })\n }\n\n const contentType = req.headers.get('content-type') ?? ''\n if (!contentType.includes('multipart/form-data')) {\n return NextResponse.json({ error: 'Expected multipart/form-data' }, { status: 400 })\n }\n\n const form = await req.formData()\n const file = form.get('file') as File | null\n if (!file) {\n return NextResponse.json({ error: 'file field is required' }, { status: 400 })\n }\n\n const keyOverride = form.get('key') ? String(form.get('key')) : null\n\n if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {\n return NextResponse.json(\n { error: 'Access denied: key override is not scoped to this tenant.' },\n { status: 403 },\n )\n }\n\n if (hasDangerousExecutableExtension(file.name)) {\n return NextResponse.json({ error: 'Executable file types are not allowed as attachments.' }, { status: 400 })\n }\n\n const effectiveMaxBytes = resolveAttachmentMaxBytes()\n if (file.size > effectiveMaxBytes) {\n return NextResponse.json({ error: 'Attachment exceeds the maximum upload size.' }, { status: 413 })\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })\n }\n\n const buffer = Buffer.from(await file.arrayBuffer())\n const safeName = sanitizeFileName(file.name)\n const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null)\n if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {\n return NextResponse.json({ error: 'Active content uploads are not allowed.' }, { status: 400 })\n }\n\n const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId)\n if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {\n return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })\n }\n\n const key =\n keyOverride ??\n `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`\n\n await driver.putObject(key, buffer, trustedMimeType)\n\n return NextResponse.json({\n key,\n bucket: driver.getBucket(),\n size: buffer.length,\n contentType: trustedMimeType,\n })\n}\n\nexport default POST\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Upload file to S3',\n methods: {\n POST: {\n summary: 'Upload a file directly to S3',\n description: 'Uploads a file to the configured S3 bucket. Requires storage_providers.manage feature.',\n requestBody: {\n contentType: 'multipart/form-data',\n schema: z.object({\n file: z.any().describe('File to upload'),\n key: z.string().optional().describe('Optional S3 key override (must be scoped to org/tenant)'),\n contentType: z.string().optional().describe('Optional client-provided content-type hint; the server derives the trusted MIME type.'),\n }),\n },\n responses: [{ status: 200, description: 'Upload result', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Missing file, blocked file type, or S3 not configured', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Key override not scoped to this tenant', schema: z.object({ error: z.string() }) },\n { status: 413, description: 'Upload too large or tenant storage quota exceeded', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -7,7 +7,6 @@ import { S3StorageDriver } from "./lib/s3-driver.js";
7
7
  import { createStorageService } from "./lib/storage-service.js";
8
8
  import { s3HealthCheck } from "./lib/health.js";
9
9
  import { createLogger } from "@open-mercato/shared/lib/logger";
10
- import { scheduleStorageS3QuotaRecovery } from "./lib/quota-recovery-queue.js";
11
10
  const logger = createLogger("storage_s3");
12
11
  registerExternalStorageDriver("s3", (config) => {
13
12
  logger.debug("Creating S3StorageDriver", {
@@ -55,13 +54,12 @@ function register(container) {
55
54
  });
56
55
  container.register({
57
56
  s3HealthCheck: asValue(s3HealthCheck),
58
- storageS3QuotaRecoveryScheduler: asValue(scheduleStorageS3QuotaRecovery),
59
57
  storageService: asFunction(
60
- ({ integrationCredentialsService, attachmentQuotaService, storageS3QuotaRecoveryScheduler }) => {
58
+ ({ integrationCredentialsService }) => {
61
59
  return {
62
60
  async _resolveService(scope) {
63
61
  const creds = await integrationCredentialsService.resolve("storage_s3", scope);
64
- if (!creds) throw new Error("[internal] S3 storage integration is not configured for this tenant.");
62
+ if (!creds) throw new Error("S3 storage integration is not configured for this tenant.");
65
63
  return createStorageService({
66
64
  authMode: creds.authMode === "ambient" || creds.authMode === "access_keys" ? creds.authMode : void 0,
67
65
  bucket: String(creds.bucket ?? ""),
@@ -71,8 +69,6 @@ function register(container) {
71
69
  accessKeyId: creds.accessKeyId ? String(creds.accessKeyId) : void 0,
72
70
  secretAccessKey: creds.secretAccessKey ? String(creds.secretAccessKey) : void 0,
73
71
  sessionToken: creds.sessionToken ? String(creds.sessionToken) : void 0,
74
- quotaService: attachmentQuotaService,
75
- quotaRecoveryScheduler: storageS3QuotaRecoveryScheduler,
76
72
  organizationId: scope.organizationId,
77
73
  tenantId: scope.tenantId
78
74
  // Credentials are resolved from the Integration Marketplace (encrypted at rest)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/storage_s3/di.ts"],
4
- "sourcesContent": ["import { asFunction, asValue } from 'awilix'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n registerExternalStorageDriver,\n registerExternalCredentialEnhancer,\n} from '@open-mercato/core/modules/attachments/lib/drivers'\nimport { S3StorageDriver } from './lib/s3-driver'\nimport { createStorageService } from './lib/storage-service'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { s3HealthCheck } from './lib/health'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { scheduleStorageS3QuotaRecovery } from './lib/quota-recovery-queue'\n\nconst logger = createLogger('storage_s3')\n\ntype IntegrationCredentialsService = {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n}\n\n// Module-level registration \u2014 runs at import time, before any DI container is built.\n// This avoids the singleton-proxy resolution issue when registering via DI.\nregisterExternalStorageDriver('s3', (config: Record<string, unknown>) => {\n logger.debug('Creating S3StorageDriver', {\n bucket: config.bucket,\n region: config.region,\n endpoint: config.endpoint,\n forcePathStyle: config.forcePathStyle,\n hasAccessKey: Boolean(config.accessKeyId),\n hasCredentialsEnvPrefix: Boolean(config.credentialsEnvPrefix),\n })\n return new S3StorageDriver(config)\n})\n\nexport function register(container: AppContainer) {\n // Register the credential enhancer via DI so it can access the request-scoped\n // integrationCredentialsService to inject marketplace credentials at upload time.\n registerExternalCredentialEnhancer('s3', async (config, scope) => {\n const scopedConfig = {\n ...config,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n }\n if (config.credentialsEnvPrefix || config.accessKeyId || config.authMode) return scopedConfig\n try {\n const credsSvc = container.resolve('integrationCredentialsService') as IntegrationCredentialsService\n const creds = await credsSvc.resolve('storage_s3', scope)\n if (!creds) {\n logger.debug('No marketplace credentials found for scope', { tenantId: scope.tenantId, organizationId: scope.organizationId })\n return scopedConfig\n }\n logger.debug('Injecting marketplace credentials into S3 driver config')\n return {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n authMode: creds.authMode,\n bucket: config.bucket ?? (creds.bucket ? String(creds.bucket) : undefined),\n region: config.region ?? (creds.region ? String(creds.region) : undefined),\n endpoint: config.endpoint ?? (creds.endpoint ? String(creds.endpoint) : undefined),\n forcePathStyle: config.forcePathStyle ?? Boolean(creds.forcePathStyle),\n accessKeyId: creds.accessKeyId ? String(creds.accessKeyId) : undefined,\n secretAccessKey: creds.secretAccessKey ? String(creds.secretAccessKey) : undefined,\n sessionToken: creds.sessionToken ? String(creds.sessionToken) : undefined,\n }\n } catch (err) {\n logger.warn('Credential enhancer failed, using scoped partition config', { err })\n return scopedConfig\n }\n })\n\n container.register({\n s3HealthCheck: asValue(s3HealthCheck),\n storageS3QuotaRecoveryScheduler: asValue(scheduleStorageS3QuotaRecovery),\n storageService: asFunction(\n ({ integrationCredentialsService, attachmentQuotaService, storageS3QuotaRecoveryScheduler }: {\n integrationCredentialsService: IntegrationCredentialsService\n attachmentQuotaService: AttachmentQuotaService\n storageS3QuotaRecoveryScheduler: (\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>\n }) => {\n // StorageService factory \u2014 builds the service lazily using credentials\n // resolved from the Integration Marketplace per request.\n return {\n async _resolveService(scope: { tenantId: string; organizationId: string }) {\n const creds = await integrationCredentialsService.resolve('storage_s3', scope)\n if (!creds) throw new Error('[internal] S3 storage integration is not configured for this tenant.')\n return createStorageService({\n authMode: creds.authMode === 'ambient' || creds.authMode === 'access_keys'\n ? (creds.authMode as 'ambient' | 'access_keys')\n : undefined,\n bucket: String(creds.bucket ?? ''),\n region: creds.region ? String(creds.region) : undefined,\n endpoint: creds.endpoint ? String(creds.endpoint) : undefined,\n forcePathStyle: Boolean(creds.forcePathStyle),\n accessKeyId: creds.accessKeyId ? String(creds.accessKeyId) : undefined,\n secretAccessKey: creds.secretAccessKey ? String(creds.secretAccessKey) : undefined,\n sessionToken: creds.sessionToken ? String(creds.sessionToken) : undefined,\n quotaService: attachmentQuotaService,\n quotaRecoveryScheduler: storageS3QuotaRecoveryScheduler,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n // Credentials are resolved from the Integration Marketplace (encrypted at rest)\n // and injected directly rather than via env prefix for the standalone service.\n } as Parameters<typeof createStorageService>[0])\n },\n }\n },\n )\n .scoped()\n .proxy(),\n })\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { asFunction, asValue } from 'awilix'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n registerExternalStorageDriver,\n registerExternalCredentialEnhancer,\n} from '@open-mercato/core/modules/attachments/lib/drivers'\nimport { S3StorageDriver } from './lib/s3-driver'\nimport { createStorageService } from './lib/storage-service'\nimport { s3HealthCheck } from './lib/health'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('storage_s3')\n\ntype IntegrationCredentialsService = {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n}\n\n// Module-level registration \u2014 runs at import time, before any DI container is built.\n// This avoids the singleton-proxy resolution issue when registering via DI.\nregisterExternalStorageDriver('s3', (config: Record<string, unknown>) => {\n logger.debug('Creating S3StorageDriver', {\n bucket: config.bucket,\n region: config.region,\n endpoint: config.endpoint,\n forcePathStyle: config.forcePathStyle,\n hasAccessKey: Boolean(config.accessKeyId),\n hasCredentialsEnvPrefix: Boolean(config.credentialsEnvPrefix),\n })\n return new S3StorageDriver(config)\n})\n\nexport function register(container: AppContainer) {\n // Register the credential enhancer via DI so it can access the request-scoped\n // integrationCredentialsService to inject marketplace credentials at upload time.\n registerExternalCredentialEnhancer('s3', async (config, scope) => {\n const scopedConfig = {\n ...config,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n }\n if (config.credentialsEnvPrefix || config.accessKeyId || config.authMode) return scopedConfig\n try {\n const credsSvc = container.resolve('integrationCredentialsService') as IntegrationCredentialsService\n const creds = await credsSvc.resolve('storage_s3', scope)\n if (!creds) {\n logger.debug('No marketplace credentials found for scope', { tenantId: scope.tenantId, organizationId: scope.organizationId })\n return scopedConfig\n }\n logger.debug('Injecting marketplace credentials into S3 driver config')\n return {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n authMode: creds.authMode,\n bucket: config.bucket ?? (creds.bucket ? String(creds.bucket) : undefined),\n region: config.region ?? (creds.region ? String(creds.region) : undefined),\n endpoint: config.endpoint ?? (creds.endpoint ? String(creds.endpoint) : undefined),\n forcePathStyle: config.forcePathStyle ?? Boolean(creds.forcePathStyle),\n accessKeyId: creds.accessKeyId ? String(creds.accessKeyId) : undefined,\n secretAccessKey: creds.secretAccessKey ? String(creds.secretAccessKey) : undefined,\n sessionToken: creds.sessionToken ? String(creds.sessionToken) : undefined,\n }\n } catch (err) {\n logger.warn('Credential enhancer failed, using scoped partition config', { err })\n return scopedConfig\n }\n })\n\n container.register({\n s3HealthCheck: asValue(s3HealthCheck),\n storageService: asFunction(\n ({ integrationCredentialsService }: { integrationCredentialsService: IntegrationCredentialsService }) => {\n // StorageService factory \u2014 builds the service lazily using credentials\n // resolved from the Integration Marketplace per request.\n return {\n async _resolveService(scope: { tenantId: string; organizationId: string }) {\n const creds = await integrationCredentialsService.resolve('storage_s3', scope)\n if (!creds) throw new Error('S3 storage integration is not configured for this tenant.')\n return createStorageService({\n authMode: creds.authMode === 'ambient' || creds.authMode === 'access_keys'\n ? (creds.authMode as 'ambient' | 'access_keys')\n : undefined,\n bucket: String(creds.bucket ?? ''),\n region: creds.region ? String(creds.region) : undefined,\n endpoint: creds.endpoint ? String(creds.endpoint) : undefined,\n forcePathStyle: Boolean(creds.forcePathStyle),\n accessKeyId: creds.accessKeyId ? String(creds.accessKeyId) : undefined,\n secretAccessKey: creds.secretAccessKey ? String(creds.secretAccessKey) : undefined,\n sessionToken: creds.sessionToken ? String(creds.sessionToken) : undefined,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n // Credentials are resolved from the Integration Marketplace (encrypted at rest)\n // and injected directly rather than via env prefix for the standalone service.\n } as Parameters<typeof createStorageService>[0])\n },\n }\n },\n )\n .scoped()\n .proxy(),\n })\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -72,7 +72,7 @@ const integration = {
72
72
  type: "url",
73
73
  required: false,
74
74
  placeholder: "https://fra1.digitaloceanspaces.com",
75
- helpText: "Custom S3 endpoint URL. Leave empty for AWS S3. Internal/private endpoints require OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS=true."
75
+ helpText: "Custom S3 endpoint URL. Leave empty for AWS S3. Required for MinIO, DigitalOcean Spaces, and other providers."
76
76
  },
77
77
  {
78
78
  key: "forcePathStyle",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/storage_s3/integration.ts"],
4
- "sourcesContent": ["import type { IntegrationBundle, IntegrationDefinition } from '@open-mercato/shared/modules/integrations/types'\n\nexport const integration: IntegrationDefinition = {\n id: 'storage_s3',\n title: 'S3-Compatible Storage',\n description:\n 'Store attachments and files in AWS S3, DigitalOcean Spaces, MinIO, or any S3-compatible object storage.',\n category: 'storage',\n hub: 'storage_hubs',\n icon: 's3',\n package: '@open-mercato/storage-s3',\n version: '1.0.0',\n author: 'Open Mercato Team',\n company: 'Open Mercato',\n license: 'MIT',\n tags: ['s3', 'aws', 'storage', 'minio', 'digitalocean-spaces'],\n healthCheck: { service: 's3HealthCheck' },\n credentials: {\n fields: [\n {\n key: 'authMode',\n label: 'Authentication',\n type: 'select',\n required: false,\n helpText:\n 'Choose how the AWS SDK should authenticate. For production deployments, prefer IAM roles (IRSA/instance profile) when available.',\n options: [\n { value: 'access_keys', label: 'Access keys' },\n { value: 'ambient', label: 'Credentials provided by AWS (STS / IRSA / instance profile)' },\n ],\n },\n {\n key: 'accessKeyId',\n label: 'Access Key ID',\n type: 'text',\n required: true,\n placeholder: 'AKIAIOSFODNN7EXAMPLE',\n helpText:\n 'AWS IAM access key ID, or the equivalent key for your S3-compatible provider.',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'secretAccessKey',\n label: 'Secret Access Key',\n type: 'secret',\n required: true,\n helpText:\n 'AWS IAM secret access key, or the equivalent secret for your S3-compatible provider.',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'sessionToken',\n label: 'Session Token (optional)',\n type: 'secret',\n required: false,\n helpText:\n 'Optional AWS session token when using temporary credentials (e.g. from STS).',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'region',\n label: 'Region',\n type: 'text',\n required: true,\n placeholder: 'eu-central-1',\n helpText: 'AWS region (e.g. eu-central-1). For DigitalOcean Spaces use the slug like fra1.',\n },\n {\n key: 'bucket',\n label: 'Bucket Name',\n type: 'text',\n required: true,\n placeholder: 'my-company-attachments',\n helpText: 'The S3 bucket where files will be stored.',\n },\n {\n key: 'endpoint',\n label: 'Custom Endpoint',\n type: 'url',\n required: false,\n placeholder: 'https://fra1.digitaloceanspaces.com',\n helpText: 'Custom S3 endpoint URL. Leave empty for AWS S3. Internal/private endpoints require OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS=true.',\n },\n {\n key: 'forcePathStyle',\n label: 'Force Path Style',\n type: 'boolean',\n required: false,\n helpText: 'Enable path-style addressing. Required for MinIO. Leave disabled for AWS S3 and DigitalOcean Spaces.',\n },\n ],\n },\n}\n\nexport const integrations: IntegrationDefinition[] = [integration]\nexport const bundles: IntegrationBundle[] = []\nexport const bundle: IntegrationBundle | undefined = undefined\n"],
4
+ "sourcesContent": ["import type { IntegrationBundle, IntegrationDefinition } from '@open-mercato/shared/modules/integrations/types'\n\nexport const integration: IntegrationDefinition = {\n id: 'storage_s3',\n title: 'S3-Compatible Storage',\n description:\n 'Store attachments and files in AWS S3, DigitalOcean Spaces, MinIO, or any S3-compatible object storage.',\n category: 'storage',\n hub: 'storage_hubs',\n icon: 's3',\n package: '@open-mercato/storage-s3',\n version: '1.0.0',\n author: 'Open Mercato Team',\n company: 'Open Mercato',\n license: 'MIT',\n tags: ['s3', 'aws', 'storage', 'minio', 'digitalocean-spaces'],\n healthCheck: { service: 's3HealthCheck' },\n credentials: {\n fields: [\n {\n key: 'authMode',\n label: 'Authentication',\n type: 'select',\n required: false,\n helpText:\n 'Choose how the AWS SDK should authenticate. For production deployments, prefer IAM roles (IRSA/instance profile) when available.',\n options: [\n { value: 'access_keys', label: 'Access keys' },\n { value: 'ambient', label: 'Credentials provided by AWS (STS / IRSA / instance profile)' },\n ],\n },\n {\n key: 'accessKeyId',\n label: 'Access Key ID',\n type: 'text',\n required: true,\n placeholder: 'AKIAIOSFODNN7EXAMPLE',\n helpText:\n 'AWS IAM access key ID, or the equivalent key for your S3-compatible provider.',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'secretAccessKey',\n label: 'Secret Access Key',\n type: 'secret',\n required: true,\n helpText:\n 'AWS IAM secret access key, or the equivalent secret for your S3-compatible provider.',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'sessionToken',\n label: 'Session Token (optional)',\n type: 'secret',\n required: false,\n helpText:\n 'Optional AWS session token when using temporary credentials (e.g. from STS).',\n visibleWhen: { field: 'authMode', equals: 'access_keys' },\n },\n {\n key: 'region',\n label: 'Region',\n type: 'text',\n required: true,\n placeholder: 'eu-central-1',\n helpText: 'AWS region (e.g. eu-central-1). For DigitalOcean Spaces use the slug like fra1.',\n },\n {\n key: 'bucket',\n label: 'Bucket Name',\n type: 'text',\n required: true,\n placeholder: 'my-company-attachments',\n helpText: 'The S3 bucket where files will be stored.',\n },\n {\n key: 'endpoint',\n label: 'Custom Endpoint',\n type: 'url',\n required: false,\n placeholder: 'https://fra1.digitaloceanspaces.com',\n helpText: 'Custom S3 endpoint URL. Leave empty for AWS S3. Required for MinIO, DigitalOcean Spaces, and other providers.',\n },\n {\n key: 'forcePathStyle',\n label: 'Force Path Style',\n type: 'boolean',\n required: false,\n helpText: 'Enable path-style addressing. Required for MinIO. Leave disabled for AWS S3 and DigitalOcean Spaces.',\n },\n ],\n },\n}\n\nexport const integrations: IntegrationDefinition[] = [integration]\nexport const bundles: IntegrationBundle[] = []\nexport const bundle: IntegrationBundle | undefined = undefined\n"],
5
5
  "mappings": "AAEO,MAAM,cAAqC;AAAA,EAChD,IAAI;AAAA,EACJ,OAAO;AAAA,EACP,aACE;AAAA,EACF,UAAU;AAAA,EACV,KAAK;AAAA,EACL,MAAM;AAAA,EACN,SAAS;AAAA,EACT,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,SAAS;AAAA,EACT,MAAM,CAAC,MAAM,OAAO,WAAW,SAAS,qBAAqB;AAAA,EAC7D,aAAa,EAAE,SAAS,gBAAgB;AAAA,EACxC,aAAa;AAAA,IACX,QAAQ;AAAA,MACN;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UACE;AAAA,QACF,SAAS;AAAA,UACP,EAAE,OAAO,eAAe,OAAO,cAAc;AAAA,UAC7C,EAAE,OAAO,WAAW,OAAO,8DAA8D;AAAA,QAC3F;AAAA,MACF;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UACE;AAAA,QACF,aAAa,EAAE,OAAO,YAAY,QAAQ,cAAc;AAAA,MAC1D;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UACE;AAAA,QACF,aAAa,EAAE,OAAO,YAAY,QAAQ,cAAc;AAAA,MAC1D;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UACE;AAAA,QACF,aAAa,EAAE,OAAO,YAAY,QAAQ,cAAc;AAAA,MAC1D;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UAAU;AAAA,MACZ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UAAU;AAAA,MACZ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,aAAa;AAAA,QACb,UAAU;AAAA,MACZ;AAAA,MACA;AAAA,QACE,KAAK;AAAA,QACL,OAAO;AAAA,QACP,MAAM;AAAA,QACN,UAAU;AAAA,QACV,UAAU;AAAA,MACZ;AAAA,IACF;AAAA,EACF;AACF;AAEO,MAAM,eAAwC,CAAC,WAAW;AAC1D,MAAM,UAA+B,CAAC;AACtC,MAAM,SAAwC;",
6
6
  "names": []
7
7
  }
@@ -1,5 +1,4 @@
1
1
  import { S3Client, HeadBucketCommand } from "@aws-sdk/client-s3";
2
- import { assertSafeS3Endpoint, createSafeS3RequestHandler } from "./endpoint-safety.js";
3
2
  const s3HealthCheck = {
4
3
  async check(credentials) {
5
4
  const cfg = credentials;
@@ -50,27 +49,11 @@ const s3HealthCheck = {
50
49
  };
51
50
  }
52
51
  }
53
- try {
54
- await assertSafeS3Endpoint(cfg.endpoint);
55
- } catch (err) {
56
- const message = err instanceof Error ? err.message : "Unsafe S3 endpoint";
57
- const reason = err && typeof err === "object" && "reason" in err ? err.reason : void 0;
58
- return {
59
- status: "unhealthy",
60
- message,
61
- details: {
62
- ...typeof reason === "string" ? { reason } : {}
63
- },
64
- checkedAt
65
- };
66
- }
67
- const requestHandler = createSafeS3RequestHandler(cfg.endpoint);
68
52
  const client = new S3Client({
69
53
  region: cfg.region ?? "us-east-1",
70
54
  endpoint: cfg.endpoint,
71
55
  forcePathStyle: cfg.forcePathStyle ?? false,
72
- credentials: credentialConfig,
73
- ...requestHandler ? { requestHandler } : {}
56
+ credentials: credentialConfig
74
57
  });
75
58
  try {
76
59
  await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }));
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/storage_s3/lib/health.ts"],
4
- "sourcesContent": ["import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3'\nimport { assertSafeS3Endpoint, createSafeS3RequestHandler } from './endpoint-safety'\nimport type { S3DriverConfig } from './s3-driver'\n\nexport const s3HealthCheck = {\n async check(credentials: Record<string, unknown>): Promise<{\n status: 'healthy' | 'unhealthy'\n message: string\n details: Record<string, unknown>\n checkedAt: Date\n }> {\n const cfg = credentials as S3DriverConfig\n const checkedAt = new Date()\n\n if (!cfg.bucket || !cfg.region) {\n return {\n status: 'unhealthy',\n message: 'Missing required credentials: bucket and region are required.',\n details: { bucket: cfg.bucket, region: cfg.region },\n checkedAt,\n }\n }\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n const hasDirectKeys = Boolean(cfg.accessKeyId && cfg.secretAccessKey)\n const hasAnyKeyPrefix = Boolean(cfg.credentialsEnvPrefix)\n const hasAnyKeys = hasDirectKeys || hasAnyKeyPrefix\n\n if (authMode === 'ambient' && hasAnyKeys) {\n return {\n status: 'unhealthy',\n message: 'Invalid configuration: authMode=ambient cannot be combined with access keys.',\n details: { authMode, hasAccessKeys: true },\n checkedAt,\n }\n }\n\n let credentialConfig: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentialConfig = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentialConfig = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n } else {\n return {\n status: 'unhealthy',\n message: 'Missing required credentials: accessKeyId and secretAccessKey are required for access_keys auth mode.',\n details: { authMode: 'access_keys' },\n checkedAt,\n }\n }\n }\n\n try {\n await assertSafeS3Endpoint(cfg.endpoint)\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : 'Unsafe S3 endpoint'\n const reason = err && typeof err === 'object' && 'reason' in err ? (err as { reason?: unknown }).reason : undefined\n return {\n status: 'unhealthy',\n message,\n details: {\n ...(typeof reason === 'string' ? { reason } : {}),\n },\n checkedAt,\n }\n }\n\n const requestHandler = createSafeS3RequestHandler(cfg.endpoint)\n const client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials: credentialConfig,\n ...(requestHandler ? { requestHandler } : {}),\n })\n\n try {\n await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }))\n return {\n status: 'healthy',\n message: `Connected to bucket \"${cfg.bucket}\" in region \"${cfg.region}\".`,\n details: {\n bucket: cfg.bucket,\n region: cfg.region,\n endpoint: cfg.endpoint ?? '(AWS default)',\n },\n checkedAt,\n }\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : 'Unknown error'\n return {\n status: 'unhealthy',\n message: `S3 connection failed: ${message}`,\n details: { bucket: cfg.bucket, region: cfg.region, error: message },\n checkedAt,\n }\n }\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3'\nimport type { S3DriverConfig } from './s3-driver'\n\nexport const s3HealthCheck = {\n async check(credentials: Record<string, unknown>): Promise<{\n status: 'healthy' | 'unhealthy'\n message: string\n details: Record<string, unknown>\n checkedAt: Date\n }> {\n const cfg = credentials as S3DriverConfig\n const checkedAt = new Date()\n\n if (!cfg.bucket || !cfg.region) {\n return {\n status: 'unhealthy',\n message: 'Missing required credentials: bucket and region are required.',\n details: { bucket: cfg.bucket, region: cfg.region },\n checkedAt,\n }\n }\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n const hasDirectKeys = Boolean(cfg.accessKeyId && cfg.secretAccessKey)\n const hasAnyKeyPrefix = Boolean(cfg.credentialsEnvPrefix)\n const hasAnyKeys = hasDirectKeys || hasAnyKeyPrefix\n\n if (authMode === 'ambient' && hasAnyKeys) {\n return {\n status: 'unhealthy',\n message: 'Invalid configuration: authMode=ambient cannot be combined with access keys.',\n details: { authMode, hasAccessKeys: true },\n checkedAt,\n }\n }\n\n let credentialConfig: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentialConfig = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentialConfig = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n } else {\n return {\n status: 'unhealthy',\n message: 'Missing required credentials: accessKeyId and secretAccessKey are required for access_keys auth mode.',\n details: { authMode: 'access_keys' },\n checkedAt,\n }\n }\n }\n\n const client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials: credentialConfig,\n })\n\n try {\n await client.send(new HeadBucketCommand({ Bucket: cfg.bucket }))\n return {\n status: 'healthy',\n message: `Connected to bucket \"${cfg.bucket}\" in region \"${cfg.region}\".`,\n details: {\n bucket: cfg.bucket,\n region: cfg.region,\n endpoint: cfg.endpoint ?? '(AWS default)',\n },\n checkedAt,\n }\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : 'Unknown error'\n return {\n status: 'unhealthy',\n message: `S3 connection failed: ${message}`,\n details: { bucket: cfg.bucket, region: cfg.region, error: message },\n checkedAt,\n }\n }\n },\n}\n"],
5
+ "mappings": "AAAA,SAAS,UAAU,yBAAyB;AAGrC,MAAM,gBAAgB;AAAA,EAC3B,MAAM,MAAM,aAKT;AACD,UAAM,MAAM;AACZ,UAAM,YAAY,oBAAI,KAAK;AAE3B,QAAI,CAAC,IAAI,UAAU,CAAC,IAAI,QAAQ;AAC9B,aAAO;AAAA,QACL,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,SAAS,EAAE,QAAQ,IAAI,QAAQ,QAAQ,IAAI,OAAO;AAAA,QAClD;AAAA,MACF;AAAA,IACF;AAEA,UAAM,WAAW,IAAI;AACrB,UAAM,sBACJ,aAAa,iBAEX,aAAa,cACR,IAAI,eAAe,IAAI,mBAAoB,QAAQ,IAAI,oBAAoB;AAGpF,UAAM,gBAAgB,QAAQ,IAAI,eAAe,IAAI,eAAe;AACpE,UAAM,kBAAkB,QAAQ,IAAI,oBAAoB;AACxD,UAAM,aAAa,iBAAiB;AAEpC,QAAI,aAAa,aAAa,YAAY;AACxC,aAAO;AAAA,QACL,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,SAAS,EAAE,UAAU,eAAe,KAAK;AAAA,QACzC;AAAA,MACF;AAAA,IACF;AAEA,QAAI;AACJ,QAAI,qBAAqB;AACvB,UAAI,IAAI,sBAAsB;AAC5B,cAAM,SAAS,IAAI;AACnB,cAAM,cAAc,QAAQ,IAAI,GAAG,MAAM,gBAAgB;AACzD,cAAM,kBAAkB,QAAQ,IAAI,GAAG,MAAM,oBAAoB;AACjE,cAAM,eAAe,QAAQ,IAAI,GAAG,MAAM,gBAAgB;AAC1D,YAAI,eAAe,iBAAiB;AAClC,6BAAmB,EAAE,aAAa,iBAAiB,GAAI,eAAe,EAAE,aAAa,IAAI,CAAC,EAAG;AAAA,QAC/F;AAAA,MACF,WAAW,IAAI,eAAe,IAAI,iBAAiB;AACjD,2BAAmB;AAAA,UACjB,aAAa,IAAI;AAAA,UACjB,iBAAiB,IAAI;AAAA,UACrB,GAAI,IAAI,eAAe,EAAE,cAAc,IAAI,aAAa,IAAI,CAAC;AAAA,QAC/D;AAAA,MACF,OAAO;AACL,eAAO;AAAA,UACL,QAAQ;AAAA,UACR,SAAS;AAAA,UACT,SAAS,EAAE,UAAU,cAAc;AAAA,UACnC;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAEA,UAAM,SAAS,IAAI,SAAS;AAAA,MAC1B,QAAQ,IAAI,UAAU;AAAA,MACtB,UAAU,IAAI;AAAA,MACd,gBAAgB,IAAI,kBAAkB;AAAA,MACtC,aAAa;AAAA,IACf,CAAC;AAED,QAAI;AACF,YAAM,OAAO,KAAK,IAAI,kBAAkB,EAAE,QAAQ,IAAI,OAAO,CAAC,CAAC;AAC/D,aAAO;AAAA,QACL,QAAQ;AAAA,QACR,SAAS,wBAAwB,IAAI,MAAM,gBAAgB,IAAI,MAAM;AAAA,QACrE,SAAS;AAAA,UACP,QAAQ,IAAI;AAAA,UACZ,QAAQ,IAAI;AAAA,UACZ,UAAU,IAAI,YAAY;AAAA,QAC5B;AAAA,QACA;AAAA,MACF;AAAA,IACF,SAAS,KAAc;AACrB,YAAM,UAAU,eAAe,QAAQ,IAAI,UAAU;AACrD,aAAO;AAAA,QACL,QAAQ;AAAA,QACR,SAAS,yBAAyB,OAAO;AAAA,QACzC,SAAS,EAAE,QAAQ,IAAI,QAAQ,QAAQ,IAAI,QAAQ,OAAO,QAAQ;AAAA,QAClE;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
@@ -57,12 +57,12 @@ function isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix) {
57
57
  }
58
58
  function assertS3KeyScopedToTenant(storagePath, scope, pathPrefix) {
59
59
  if (!isS3KeyScopedToTenant(storagePath, scope, pathPrefix)) {
60
- throw new Error("[internal] S3 key is not scoped to the active tenant");
60
+ throw new Error("S3 key is not scoped to the active tenant");
61
61
  }
62
62
  }
63
63
  function assertS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix) {
64
64
  if (!isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix)) {
65
- throw new Error("[internal] S3 key is not scoped to the active tenant");
65
+ throw new Error("S3 key is not scoped to the active tenant");
66
66
  }
67
67
  }
68
68
  function assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix) {
@@ -72,12 +72,12 @@ function assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix) {
72
72
  if (parts.length === 0) return;
73
73
  if (parts[0]?.startsWith("org_") || parts[1]?.startsWith("tenant_")) {
74
74
  if (parts[0] !== segments.orgSegment || parts[1] !== segments.tenantSegment) {
75
- throw new Error("[internal] S3 prefix is not scoped to the active tenant");
75
+ throw new Error("S3 prefix is not scoped to the active tenant");
76
76
  }
77
77
  return;
78
78
  }
79
79
  if (parts[1]?.startsWith("org_") && parts[1] !== segments.orgSegment || parts[2]?.startsWith("tenant_") && parts[2] !== segments.tenantSegment) {
80
- throw new Error("[internal] S3 prefix is not scoped to the active tenant");
80
+ throw new Error("S3 prefix is not scoped to the active tenant");
81
81
  }
82
82
  }
83
83
  function filterS3ObjectsToTenant(files, scope, pathPrefix) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/storage_s3/lib/key-scope.ts"],
4
- "sourcesContent": ["export type S3TenantScope = {\n organizationId?: string | null\n tenantId?: string | null\n}\n\nexport type S3ScopedObject = {\n key: string\n}\n\ntype ScopeSegments = {\n orgSegment: string\n tenantSegment: string\n}\n\nconst SHARED_ORG_SEGMENT = 'org_shared'\nconst SHARED_TENANT_SEGMENT = 'tenant_shared'\n\nfunction resolveSegment(value: string | null | undefined, prefix: 'org' | 'tenant'): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n if (!trimmed) return null\n return `${prefix}_${trimmed}`\n}\n\nfunction resolveScopeSegments(scope: S3TenantScope | null | undefined): ScopeSegments | null {\n const orgSegment = resolveSegment(scope?.organizationId, 'org')\n const tenantSegment = resolveSegment(scope?.tenantId, 'tenant')\n if (!orgSegment || !tenantSegment) return null\n return { orgSegment, tenantSegment }\n}\n\nfunction normalizePath(value: string): string {\n return value.replace(/^\\/+/, '')\n}\n\nfunction stripConfiguredPrefix(value: string, pathPrefix?: string): string {\n const normalized = normalizePath(value)\n const normalizedPrefix = normalizePath(pathPrefix ?? '')\n if (!normalizedPrefix) return normalized\n return normalized.startsWith(normalizedPrefix)\n ? normalized.slice(normalizedPrefix.length).replace(/^\\/+/, '')\n : normalized\n}\n\nfunction splitS3Path(value: string, pathPrefix?: string): string[] {\n return stripConfiguredPrefix(value, pathPrefix).split('/').filter(Boolean)\n}\n\nfunction findScopeSegmentIndex(parts: string[]): number {\n return parts.findIndex((part, index) => (\n part.startsWith('org_') && parts[index + 1]?.startsWith('tenant_')\n ))\n}\n\nfunction hasScopeSegments(parts: string[], orgSegment: string, tenantSegment: string): boolean {\n const scopeIndex = findScopeSegmentIndex(parts)\n return scopeIndex >= 0\n && parts[scopeIndex] === orgSegment\n && parts[scopeIndex + 1] === tenantSegment\n}\n\nfunction resolveScopeSegmentsFromArgs(\n scopeOrOrgId: S3TenantScope | string | null | undefined,\n tenantId?: string,\n): ScopeSegments | null {\n if (typeof scopeOrOrgId === 'string') {\n return resolveScopeSegments({ organizationId: scopeOrOrgId, tenantId })\n }\n return resolveScopeSegments(scopeOrOrgId)\n}\n\nexport function isS3KeyScopedToTenant(key: string, orgId: string, tenantId: string): boolean\nexport function isS3KeyScopedToTenant(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): boolean\nexport function isS3KeyScopedToTenant(\n storagePath: string,\n scopeOrOrgId: S3TenantScope | string | null | undefined,\n pathPrefixOrTenantId?: string,\n): boolean {\n const segments = resolveScopeSegmentsFromArgs(scopeOrOrgId, pathPrefixOrTenantId)\n if (!segments) return typeof scopeOrOrgId === 'string' ? false : true\n\n const pathPrefix = typeof scopeOrOrgId === 'string' ? undefined : pathPrefixOrTenantId\n const parts = splitS3Path(storagePath, pathPrefix)\n return hasScopeSegments(parts, segments.orgSegment, segments.tenantSegment)\n}\n\nexport function isS3KeyShared(key: string): boolean {\n return hasScopeSegments(splitS3Path(key), SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)\n}\n\nexport function isS3KeyAddressableByScope(key: string, orgId: string, tenantId: string): boolean {\n return isS3KeyScopedToTenant(key, orgId, tenantId) || isS3KeyShared(key)\n}\n\nexport function isS3KeyAddressableByTenantScope(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): boolean {\n if (!resolveScopeSegments(scope)) return true\n const parts = splitS3Path(storagePath, pathPrefix)\n return isS3KeyScopedToTenant(storagePath, scope, pathPrefix)\n || hasScopeSegments(parts, SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)\n}\n\nexport function assertS3KeyScopedToTenant(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n if (!isS3KeyScopedToTenant(storagePath, scope, pathPrefix)) {\n throw new Error('[internal] S3 key is not scoped to the active tenant')\n }\n}\n\nexport function assertS3KeyAddressableByTenantScope(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n if (!isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix)) {\n throw new Error('[internal] S3 key is not scoped to the active tenant')\n }\n}\n\nexport function assertS3ListPrefixScopedToTenant(\n prefix: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n const segments = resolveScopeSegments(scope)\n if (!segments) return\n\n const parts = splitS3Path(prefix, pathPrefix)\n if (parts.length === 0) return\n\n if (parts[0]?.startsWith('org_') || parts[1]?.startsWith('tenant_')) {\n if (parts[0] !== segments.orgSegment || parts[1] !== segments.tenantSegment) {\n throw new Error('[internal] S3 prefix is not scoped to the active tenant')\n }\n return\n }\n\n if (\n (parts[1]?.startsWith('org_') && parts[1] !== segments.orgSegment)\n || (parts[2]?.startsWith('tenant_') && parts[2] !== segments.tenantSegment)\n ) {\n throw new Error('[internal] S3 prefix is not scoped to the active tenant')\n }\n}\n\nexport function filterS3ObjectsToTenant<T extends S3ScopedObject>(\n files: T[],\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): T[] {\n if (!resolveScopeSegments(scope)) return files\n return files.filter((file) => isS3KeyScopedToTenant(file.key, scope, pathPrefix))\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["export type S3TenantScope = {\n organizationId?: string | null\n tenantId?: string | null\n}\n\nexport type S3ScopedObject = {\n key: string\n}\n\ntype ScopeSegments = {\n orgSegment: string\n tenantSegment: string\n}\n\nconst SHARED_ORG_SEGMENT = 'org_shared'\nconst SHARED_TENANT_SEGMENT = 'tenant_shared'\n\nfunction resolveSegment(value: string | null | undefined, prefix: 'org' | 'tenant'): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n if (!trimmed) return null\n return `${prefix}_${trimmed}`\n}\n\nfunction resolveScopeSegments(scope: S3TenantScope | null | undefined): ScopeSegments | null {\n const orgSegment = resolveSegment(scope?.organizationId, 'org')\n const tenantSegment = resolveSegment(scope?.tenantId, 'tenant')\n if (!orgSegment || !tenantSegment) return null\n return { orgSegment, tenantSegment }\n}\n\nfunction normalizePath(value: string): string {\n return value.replace(/^\\/+/, '')\n}\n\nfunction stripConfiguredPrefix(value: string, pathPrefix?: string): string {\n const normalized = normalizePath(value)\n const normalizedPrefix = normalizePath(pathPrefix ?? '')\n if (!normalizedPrefix) return normalized\n return normalized.startsWith(normalizedPrefix)\n ? normalized.slice(normalizedPrefix.length).replace(/^\\/+/, '')\n : normalized\n}\n\nfunction splitS3Path(value: string, pathPrefix?: string): string[] {\n return stripConfiguredPrefix(value, pathPrefix).split('/').filter(Boolean)\n}\n\nfunction findScopeSegmentIndex(parts: string[]): number {\n return parts.findIndex((part, index) => (\n part.startsWith('org_') && parts[index + 1]?.startsWith('tenant_')\n ))\n}\n\nfunction hasScopeSegments(parts: string[], orgSegment: string, tenantSegment: string): boolean {\n const scopeIndex = findScopeSegmentIndex(parts)\n return scopeIndex >= 0\n && parts[scopeIndex] === orgSegment\n && parts[scopeIndex + 1] === tenantSegment\n}\n\nfunction resolveScopeSegmentsFromArgs(\n scopeOrOrgId: S3TenantScope | string | null | undefined,\n tenantId?: string,\n): ScopeSegments | null {\n if (typeof scopeOrOrgId === 'string') {\n return resolveScopeSegments({ organizationId: scopeOrOrgId, tenantId })\n }\n return resolveScopeSegments(scopeOrOrgId)\n}\n\nexport function isS3KeyScopedToTenant(key: string, orgId: string, tenantId: string): boolean\nexport function isS3KeyScopedToTenant(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): boolean\nexport function isS3KeyScopedToTenant(\n storagePath: string,\n scopeOrOrgId: S3TenantScope | string | null | undefined,\n pathPrefixOrTenantId?: string,\n): boolean {\n const segments = resolveScopeSegmentsFromArgs(scopeOrOrgId, pathPrefixOrTenantId)\n if (!segments) return typeof scopeOrOrgId === 'string' ? false : true\n\n const pathPrefix = typeof scopeOrOrgId === 'string' ? undefined : pathPrefixOrTenantId\n const parts = splitS3Path(storagePath, pathPrefix)\n return hasScopeSegments(parts, segments.orgSegment, segments.tenantSegment)\n}\n\nexport function isS3KeyShared(key: string): boolean {\n return hasScopeSegments(splitS3Path(key), SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)\n}\n\nexport function isS3KeyAddressableByScope(key: string, orgId: string, tenantId: string): boolean {\n return isS3KeyScopedToTenant(key, orgId, tenantId) || isS3KeyShared(key)\n}\n\nexport function isS3KeyAddressableByTenantScope(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): boolean {\n if (!resolveScopeSegments(scope)) return true\n const parts = splitS3Path(storagePath, pathPrefix)\n return isS3KeyScopedToTenant(storagePath, scope, pathPrefix)\n || hasScopeSegments(parts, SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)\n}\n\nexport function assertS3KeyScopedToTenant(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n if (!isS3KeyScopedToTenant(storagePath, scope, pathPrefix)) {\n throw new Error('S3 key is not scoped to the active tenant')\n }\n}\n\nexport function assertS3KeyAddressableByTenantScope(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n if (!isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix)) {\n throw new Error('S3 key is not scoped to the active tenant')\n }\n}\n\nexport function assertS3ListPrefixScopedToTenant(\n prefix: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n const segments = resolveScopeSegments(scope)\n if (!segments) return\n\n const parts = splitS3Path(prefix, pathPrefix)\n if (parts.length === 0) return\n\n if (parts[0]?.startsWith('org_') || parts[1]?.startsWith('tenant_')) {\n if (parts[0] !== segments.orgSegment || parts[1] !== segments.tenantSegment) {\n throw new Error('S3 prefix is not scoped to the active tenant')\n }\n return\n }\n\n if (\n (parts[1]?.startsWith('org_') && parts[1] !== segments.orgSegment)\n || (parts[2]?.startsWith('tenant_') && parts[2] !== segments.tenantSegment)\n ) {\n throw new Error('S3 prefix is not scoped to the active tenant')\n }\n}\n\nexport function filterS3ObjectsToTenant<T extends S3ScopedObject>(\n files: T[],\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): T[] {\n if (!resolveScopeSegments(scope)) return files\n return files.filter((file) => isS3KeyScopedToTenant(file.key, scope, pathPrefix))\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -10,11 +10,6 @@ import {
10
10
  ListObjectsV2Command
11
11
  } from "@aws-sdk/client-s3";
12
12
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
13
- import {
14
- assertSafeS3Endpoint,
15
- assertStaticallySafeS3Endpoint,
16
- createSafeS3RequestHandler
17
- } from "./endpoint-safety.js";
18
13
  import {
19
14
  assertS3KeyAddressableByTenantScope,
20
15
  assertS3KeyScopedToTenant,
@@ -46,7 +41,6 @@ class S3StorageDriver {
46
41
  this.key = "s3";
47
42
  const cfg = config;
48
43
  this.bucket = cfg.bucket;
49
- this.endpoint = cfg.endpoint;
50
44
  this.pathPrefix = cfg.pathPrefix ?? "";
51
45
  this.scope = cfg.organizationId && cfg.tenantId ? { organizationId: cfg.organizationId, tenantId: cfg.tenantId } : null;
52
46
  const authMode = cfg.authMode;
@@ -69,14 +63,11 @@ class S3StorageDriver {
69
63
  };
70
64
  }
71
65
  }
72
- assertStaticallySafeS3Endpoint(cfg.endpoint);
73
- const requestHandler = createSafeS3RequestHandler(cfg.endpoint);
74
66
  this.client = new S3Client({
75
67
  region: cfg.region ?? "us-east-1",
76
68
  endpoint: cfg.endpoint,
77
69
  forcePathStyle: cfg.forcePathStyle ?? false,
78
- credentials,
79
- ...requestHandler ? { requestHandler } : {}
70
+ credentials
80
71
  });
81
72
  }
82
73
  assertKeyScoped(storagePath, scope) {
@@ -90,29 +81,22 @@ class S3StorageDriver {
90
81
  const prefix = this.pathPrefix && storagePath.startsWith(this.pathPrefix) ? storagePath.slice(this.pathPrefix.length).replace(/^\/+/, "") : storagePath.replace(/^\/+/, "");
91
82
  const firstSegment = prefix.split("/").filter(Boolean)[0];
92
83
  if (firstSegment !== partitionCode) {
93
- throw new Error("[internal] S3 key is not scoped to the requested partition");
84
+ throw new Error("S3 key is not scoped to the requested partition");
94
85
  }
95
86
  }
96
- prepareStoragePath(payload) {
87
+ async store(payload) {
97
88
  const orgSegment = resolveOrgSegment(payload.orgId ?? null);
98
89
  const tenantSegment = resolveTenantSegment(payload.tenantId ?? null);
99
90
  const safeName = sanitizeFileName(payload.fileName || "file");
100
91
  const uniqueSuffix = randomUUID().replace(/-/g, "").slice(0, 12);
101
92
  const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`;
102
- return `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`;
103
- }
104
- async store(payload) {
105
- const key = payload.storagePath ?? this.prepareStoragePath(payload);
106
- this.assertPartitionScoped(payload.partitionCode, key);
107
- this.assertKeyScoped(key);
108
- await this.assertEndpointSafe();
93
+ const key = `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`;
109
94
  await this.client.send(
110
95
  new PutObjectCommand({
111
96
  Bucket: this.bucket,
112
97
  Key: key,
113
98
  Body: payload.buffer,
114
- ContentLength: payload.buffer.length,
115
- IfNoneMatch: "*"
99
+ ContentLength: payload.buffer.length
116
100
  })
117
101
  );
118
102
  return { storagePath: key };
@@ -120,12 +104,11 @@ class S3StorageDriver {
120
104
  async read(partitionCode, storagePath) {
121
105
  this.assertPartitionScoped(partitionCode, storagePath);
122
106
  this.assertKeyAddressable(storagePath);
123
- await this.assertEndpointSafe();
124
107
  const response = await this.client.send(
125
108
  new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })
126
109
  );
127
110
  if (!response.Body) {
128
- throw new Error(`[internal] S3 object body is empty for key: ${storagePath}`);
111
+ throw new Error(`S3 object body is empty for key: ${storagePath}`);
129
112
  }
130
113
  const buffer = await streamToBuffer(response.Body);
131
114
  return { buffer, contentType: response.ContentType };
@@ -134,16 +117,12 @@ class S3StorageDriver {
134
117
  this.assertPartitionScoped(partitionCode, storagePath);
135
118
  this.assertKeyAddressable(storagePath);
136
119
  try {
137
- await this.deleteStrict(partitionCode, storagePath);
120
+ await this.client.send(
121
+ new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath })
122
+ );
138
123
  } catch {
139
124
  }
140
125
  }
141
- async deleteStrict(partitionCode, storagePath) {
142
- this.assertPartitionScoped(partitionCode, storagePath);
143
- this.assertKeyAddressable(storagePath);
144
- await this.assertEndpointSafe();
145
- await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }));
146
- }
147
126
  async toLocalPath(partitionCode, storagePath) {
148
127
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "s3-tmp-"));
149
128
  const cleanup = async () => {
@@ -165,15 +144,13 @@ class S3StorageDriver {
165
144
  */
166
145
  async putObject(key, buffer, contentType) {
167
146
  this.assertKeyScoped(key);
168
- await this.assertEndpointSafe();
169
147
  await this.client.send(
170
148
  new PutObjectCommand({
171
149
  Bucket: this.bucket,
172
150
  Key: key,
173
151
  Body: buffer,
174
152
  ContentType: contentType,
175
- ContentLength: buffer.length,
176
- IfNoneMatch: "*"
153
+ ContentLength: buffer.length
177
154
  })
178
155
  );
179
156
  }
@@ -183,7 +160,6 @@ class S3StorageDriver {
183
160
  async listObjects(prefix, maxKeys = 100, continuationToken, scope) {
184
161
  const activeScope = scope ?? this.scope;
185
162
  assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix);
186
- await this.assertEndpointSafe();
187
163
  const response = await this.client.send(
188
164
  new ListObjectsV2Command({
189
165
  Bucket: this.bucket,
@@ -203,31 +179,26 @@ class S3StorageDriver {
203
179
  nextContinuationToken: response.NextContinuationToken
204
180
  };
205
181
  }
206
- async getSignedUrl(storagePath, operation, expiresIn = 3600, contentType, contentLengthOrScope, createOnly = false, explicitScope) {
207
- const contentLength = typeof contentLengthOrScope === "number" ? contentLengthOrScope : void 0;
208
- const scope = typeof contentLengthOrScope === "number" ? explicitScope : contentLengthOrScope;
182
+ /**
183
+ * Generate a pre-signed URL for direct browser upload or download.
184
+ */
185
+ async getSignedUrl(storagePath, operation, expiresIn = 3600, contentType, scope) {
209
186
  if (operation === "upload") {
210
187
  this.assertKeyScoped(storagePath, scope);
211
188
  } else {
212
189
  this.assertKeyAddressable(storagePath, scope);
213
190
  }
214
- await this.assertEndpointSafe();
215
191
  if (operation === "upload") {
216
192
  const command2 = new PutObjectCommand({
217
193
  Bucket: this.bucket,
218
194
  Key: storagePath,
219
- ContentType: contentType,
220
- ContentLength: contentLength,
221
- ...createOnly ? { IfNoneMatch: "*" } : {}
195
+ ContentType: contentType
222
196
  });
223
197
  return getSignedUrl(this.client, command2, { expiresIn });
224
198
  }
225
199
  const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath });
226
200
  return getSignedUrl(this.client, command, { expiresIn });
227
201
  }
228
- async assertEndpointSafe() {
229
- await assertSafeS3Endpoint(this.endpoint);
230
- }
231
202
  }
232
203
  export {
233
204
  S3StorageDriver
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/storage_s3/lib/s3-driver.ts"],
4
- "sourcesContent": ["import { randomUUID } from 'crypto'\nimport os from 'os'\nimport path from 'path'\nimport { promises as fs } from 'fs'\nimport {\n S3Client,\n PutObjectCommand,\n GetObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n} from '@aws-sdk/client-s3'\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner'\nimport type { PrepareFilePayload, StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'\nimport {\n assertSafeS3Endpoint,\n assertStaticallySafeS3Endpoint,\n createSafeS3RequestHandler,\n} from './endpoint-safety'\nimport {\n assertS3KeyAddressableByTenantScope,\n assertS3KeyScopedToTenant,\n assertS3ListPrefixScopedToTenant,\n filterS3ObjectsToTenant,\n type S3TenantScope,\n} from './key-scope'\n\nexport type S3DriverConfig = {\n bucket: string\n region?: string\n endpoint?: string\n pathPrefix?: string\n forcePathStyle?: boolean\n /**\n * authMode:\n * - access_keys: use explicit credentials (direct values or env prefix)\n * - ambient: let AWS SDK resolve credentials from the default chain (STS/IRSA/instance profile/etc.)\n *\n * Backward compat: missing authMode behaves like access_keys when keys are provided; otherwise ambient.\n */\n authMode?: 'access_keys' | 'ambient'\n /** Resolve access key + secret from env vars: {PREFIX}_ACCESS_KEY_ID / {PREFIX}_SECRET_ACCESS_KEY */\n credentialsEnvPrefix?: string\n /** Direct credentials (used when passed from Integration Marketplace) */\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\nfunction sanitizeFileName(fileName: string): string {\n if (!fileName) return 'file'\n return fileName.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction resolveOrgSegment(orgId: string | null | undefined): string {\n if (typeof orgId === 'string' && orgId.trim().length > 0) return `org_${orgId}`\n return 'org_shared'\n}\n\nfunction resolveTenantSegment(tenantId: string | null | undefined): string {\n if (typeof tenantId === 'string' && tenantId.trim().length > 0) return `tenant_${tenantId}`\n return 'tenant_shared'\n}\n\nasync function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = []\n stream.on('data', (chunk: Buffer) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))\n stream.on('end', () => resolve(Buffer.concat(chunks)))\n stream.on('error', reject)\n })\n}\n\nexport class S3StorageDriver implements StorageDriver {\n readonly key = 's3'\n private readonly client: S3Client\n private readonly bucket: string\n private readonly endpoint?: string\n private readonly pathPrefix: string\n private readonly scope: S3TenantScope | null\n\n constructor(config: Record<string, unknown>) {\n const cfg = config as S3DriverConfig\n this.bucket = cfg.bucket\n this.endpoint = cfg.endpoint\n this.pathPrefix = cfg.pathPrefix ?? ''\n this.scope = cfg.organizationId && cfg.tenantId\n ? { organizationId: cfg.organizationId, tenantId: cfg.tenantId }\n : null\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentials = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentials = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n }\n }\n\n assertStaticallySafeS3Endpoint(cfg.endpoint)\n const requestHandler = createSafeS3RequestHandler(cfg.endpoint)\n\n this.client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials,\n ...(requestHandler ? { requestHandler } : {}),\n })\n }\n\n private assertKeyScoped(storagePath: string, scope?: S3TenantScope | null): void {\n assertS3KeyScopedToTenant(storagePath, scope ?? this.scope, this.pathPrefix)\n }\n\n private assertKeyAddressable(storagePath: string, scope?: S3TenantScope | null): void {\n assertS3KeyAddressableByTenantScope(storagePath, scope ?? this.scope, this.pathPrefix)\n }\n\n private assertPartitionScoped(partitionCode: string, storagePath: string): void {\n if (!partitionCode) return\n const prefix = this.pathPrefix && storagePath.startsWith(this.pathPrefix)\n ? storagePath.slice(this.pathPrefix.length).replace(/^\\/+/, '')\n : storagePath.replace(/^\\/+/, '')\n const firstSegment = prefix.split('/').filter(Boolean)[0]\n if (firstSegment !== partitionCode) {\n throw new Error('[internal] S3 key is not scoped to the requested partition')\n }\n }\n\n prepareStoragePath(payload: PrepareFilePayload): string {\n const orgSegment = resolveOrgSegment(payload.orgId ?? null)\n const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)\n const safeName = sanitizeFileName(payload.fileName || 'file')\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`\n return `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`\n }\n\n async store(payload: StoreFilePayload): Promise<StoredFile> {\n const key = payload.storagePath ?? this.prepareStoragePath(payload)\n this.assertPartitionScoped(payload.partitionCode, key)\n this.assertKeyScoped(key)\n await this.assertEndpointSafe()\n\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: payload.buffer,\n ContentLength: payload.buffer.length,\n IfNoneMatch: '*',\n }),\n )\n\n return { storagePath: key }\n }\n\n async read(partitionCode: string, storagePath: string): Promise<ReadFileResult> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n await this.assertEndpointSafe()\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n if (!response.Body) {\n throw new Error(`[internal] S3 object body is empty for key: ${storagePath}`)\n }\n const buffer = await streamToBuffer(response.Body as NodeJS.ReadableStream)\n return { buffer, contentType: response.ContentType }\n }\n\n async delete(partitionCode: string, storagePath: string): Promise<void> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n try {\n await this.deleteStrict(partitionCode, storagePath)\n } catch {\n // Backward-compatible best-effort deletion.\n }\n }\n\n async deleteStrict(partitionCode: string, storagePath: string): Promise<void> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n await this.assertEndpointSafe()\n await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }))\n }\n\n async toLocalPath(\n partitionCode: string,\n storagePath: string,\n ): Promise<{ filePath: string; cleanup: () => Promise<void> }> {\n const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 's3-tmp-'))\n const cleanup = async () => {\n await fs.rm(tmpDir, { recursive: true, force: true })\n }\n\n try {\n const fileName = path.basename(storagePath) || 'download'\n const filePath = path.join(tmpDir, fileName)\n const { buffer } = await this.read(partitionCode, storagePath)\n await fs.writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 })\n return { filePath, cleanup }\n } catch (error) {\n await Promise.allSettled([cleanup()])\n throw error\n }\n }\n\n /**\n * Put an object directly at a specific S3 key (for standalone usage).\n */\n async putObject(key: string, buffer: Buffer, contentType?: string): Promise<void> {\n this.assertKeyScoped(key)\n await this.assertEndpointSafe()\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: buffer,\n ContentType: contentType,\n ContentLength: buffer.length,\n IfNoneMatch: '*',\n }),\n )\n }\n\n getBucket(): string {\n return this.bucket\n }\n\n async listObjects(\n prefix: string,\n maxKeys = 100,\n continuationToken?: string,\n scope?: S3TenantScope | null,\n ): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\n const activeScope = scope ?? this.scope\n assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix)\n await this.assertEndpointSafe()\n const response = await this.client.send(\n new ListObjectsV2Command({\n Bucket: this.bucket,\n Prefix: prefix,\n MaxKeys: maxKeys,\n ContinuationToken: continuationToken,\n }),\n )\n const files = (response.Contents ?? []).map((obj) => ({\n key: obj.Key ?? '',\n size: obj.Size ?? 0,\n lastModified: obj.LastModified ?? new Date(0),\n }))\n return {\n files: filterS3ObjectsToTenant(files, activeScope, this.pathPrefix),\n truncated: response.IsTruncated ?? false,\n nextContinuationToken: response.NextContinuationToken,\n }\n }\n\n /**\n * Generate a pre-signed URL for direct browser upload or download.\n */\n async getSignedUrl(\n storagePath: string,\n operation: 'upload' | 'download',\n expiresIn?: number,\n contentType?: string,\n contentLength?: number,\n createOnly?: boolean,\n scope?: S3TenantScope | null,\n ): Promise<string>\n async getSignedUrl(\n storagePath: string,\n operation: 'upload' | 'download',\n expiresIn: number,\n contentType: string | undefined,\n scope?: S3TenantScope | null,\n ): Promise<string>\n async getSignedUrl(\n storagePath: string,\n operation: 'upload' | 'download',\n expiresIn = 3600,\n contentType?: string,\n contentLengthOrScope?: number | S3TenantScope | null,\n createOnly = false,\n explicitScope?: S3TenantScope | null,\n ): Promise<string> {\n const contentLength = typeof contentLengthOrScope === 'number' ? contentLengthOrScope : undefined\n const scope = typeof contentLengthOrScope === 'number' ? explicitScope : contentLengthOrScope\n if (operation === 'upload') {\n this.assertKeyScoped(storagePath, scope)\n } else {\n this.assertKeyAddressable(storagePath, scope)\n }\n await this.assertEndpointSafe()\n if (operation === 'upload') {\n const command = new PutObjectCommand({\n Bucket: this.bucket,\n Key: storagePath,\n ContentType: contentType,\n ContentLength: contentLength,\n ...(createOnly ? { IfNoneMatch: '*' } : {}),\n })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n\n private async assertEndpointSafe(): Promise<void> {\n await assertSafeS3Endpoint(this.endpoint)\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { randomUUID } from 'crypto'\nimport os from 'os'\nimport path from 'path'\nimport { promises as fs } from 'fs'\nimport {\n S3Client,\n PutObjectCommand,\n GetObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n} from '@aws-sdk/client-s3'\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner'\nimport type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'\nimport {\n assertS3KeyAddressableByTenantScope,\n assertS3KeyScopedToTenant,\n assertS3ListPrefixScopedToTenant,\n filterS3ObjectsToTenant,\n type S3TenantScope,\n} from './key-scope'\n\nexport type S3DriverConfig = {\n bucket: string\n region?: string\n endpoint?: string\n pathPrefix?: string\n forcePathStyle?: boolean\n /**\n * authMode:\n * - access_keys: use explicit credentials (direct values or env prefix)\n * - ambient: let AWS SDK resolve credentials from the default chain (STS/IRSA/instance profile/etc.)\n *\n * Backward compat: missing authMode behaves like access_keys when keys are provided; otherwise ambient.\n */\n authMode?: 'access_keys' | 'ambient'\n /** Resolve access key + secret from env vars: {PREFIX}_ACCESS_KEY_ID / {PREFIX}_SECRET_ACCESS_KEY */\n credentialsEnvPrefix?: string\n /** Direct credentials (used when passed from Integration Marketplace) */\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\nfunction sanitizeFileName(fileName: string): string {\n if (!fileName) return 'file'\n return fileName.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction resolveOrgSegment(orgId: string | null | undefined): string {\n if (typeof orgId === 'string' && orgId.trim().length > 0) return `org_${orgId}`\n return 'org_shared'\n}\n\nfunction resolveTenantSegment(tenantId: string | null | undefined): string {\n if (typeof tenantId === 'string' && tenantId.trim().length > 0) return `tenant_${tenantId}`\n return 'tenant_shared'\n}\n\nasync function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = []\n stream.on('data', (chunk: Buffer) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))\n stream.on('end', () => resolve(Buffer.concat(chunks)))\n stream.on('error', reject)\n })\n}\n\nexport class S3StorageDriver implements StorageDriver {\n readonly key = 's3'\n private readonly client: S3Client\n private readonly bucket: string\n private readonly pathPrefix: string\n private readonly scope: S3TenantScope | null\n\n constructor(config: Record<string, unknown>) {\n const cfg = config as S3DriverConfig\n this.bucket = cfg.bucket\n this.pathPrefix = cfg.pathPrefix ?? ''\n this.scope = cfg.organizationId && cfg.tenantId\n ? { organizationId: cfg.organizationId, tenantId: cfg.tenantId }\n : null\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentials = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentials = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n }\n }\n\n this.client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials,\n })\n }\n\n private assertKeyScoped(storagePath: string, scope?: S3TenantScope | null): void {\n assertS3KeyScopedToTenant(storagePath, scope ?? this.scope, this.pathPrefix)\n }\n\n private assertKeyAddressable(storagePath: string, scope?: S3TenantScope | null): void {\n assertS3KeyAddressableByTenantScope(storagePath, scope ?? this.scope, this.pathPrefix)\n }\n\n private assertPartitionScoped(partitionCode: string, storagePath: string): void {\n if (!partitionCode) return\n const prefix = this.pathPrefix && storagePath.startsWith(this.pathPrefix)\n ? storagePath.slice(this.pathPrefix.length).replace(/^\\/+/, '')\n : storagePath.replace(/^\\/+/, '')\n const firstSegment = prefix.split('/').filter(Boolean)[0]\n if (firstSegment !== partitionCode) {\n throw new Error('S3 key is not scoped to the requested partition')\n }\n }\n\n async store(payload: StoreFilePayload): Promise<StoredFile> {\n const orgSegment = resolveOrgSegment(payload.orgId ?? null)\n const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)\n const safeName = sanitizeFileName(payload.fileName || 'file')\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`\n const key = `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`\n\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: payload.buffer,\n ContentLength: payload.buffer.length,\n }),\n )\n\n return { storagePath: key }\n }\n\n async read(partitionCode: string, storagePath: string): Promise<ReadFileResult> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n if (!response.Body) {\n throw new Error(`S3 object body is empty for key: ${storagePath}`)\n }\n const buffer = await streamToBuffer(response.Body as NodeJS.ReadableStream)\n return { buffer, contentType: response.ContentType }\n }\n\n async delete(partitionCode: string, storagePath: string): Promise<void> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n try {\n await this.client.send(\n new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n } catch {\n // best-effort removal\n }\n }\n\n async toLocalPath(\n partitionCode: string,\n storagePath: string,\n ): Promise<{ filePath: string; cleanup: () => Promise<void> }> {\n const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 's3-tmp-'))\n const cleanup = async () => {\n await fs.rm(tmpDir, { recursive: true, force: true })\n }\n\n try {\n const fileName = path.basename(storagePath) || 'download'\n const filePath = path.join(tmpDir, fileName)\n const { buffer } = await this.read(partitionCode, storagePath)\n await fs.writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 })\n return { filePath, cleanup }\n } catch (error) {\n await Promise.allSettled([cleanup()])\n throw error\n }\n }\n\n /**\n * Put an object directly at a specific S3 key (for standalone usage).\n */\n async putObject(key: string, buffer: Buffer, contentType?: string): Promise<void> {\n this.assertKeyScoped(key)\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: buffer,\n ContentType: contentType,\n ContentLength: buffer.length,\n }),\n )\n }\n\n getBucket(): string {\n return this.bucket\n }\n\n async listObjects(\n prefix: string,\n maxKeys = 100,\n continuationToken?: string,\n scope?: S3TenantScope | null,\n ): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\n const activeScope = scope ?? this.scope\n assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix)\n const response = await this.client.send(\n new ListObjectsV2Command({\n Bucket: this.bucket,\n Prefix: prefix,\n MaxKeys: maxKeys,\n ContinuationToken: continuationToken,\n }),\n )\n const files = (response.Contents ?? []).map((obj) => ({\n key: obj.Key ?? '',\n size: obj.Size ?? 0,\n lastModified: obj.LastModified ?? new Date(0),\n }))\n return {\n files: filterS3ObjectsToTenant(files, activeScope, this.pathPrefix),\n truncated: response.IsTruncated ?? false,\n nextContinuationToken: response.NextContinuationToken,\n }\n }\n\n /**\n * Generate a pre-signed URL for direct browser upload or download.\n */\n async getSignedUrl(\n storagePath: string,\n operation: 'upload' | 'download',\n expiresIn = 3600,\n contentType?: string,\n scope?: S3TenantScope | null,\n ): Promise<string> {\n if (operation === 'upload') {\n this.assertKeyScoped(storagePath, scope)\n } else {\n this.assertKeyAddressable(storagePath, scope)\n }\n if (operation === 'upload') {\n const command = new PutObjectCommand({\n Bucket: this.bucket,\n Key: storagePath,\n ContentType: contentType,\n })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["command"]
7
7
  }