@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js +5 -20
- package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js.map +2 -2
- package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js +5 -7
- package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js.map +2 -2
- package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js +4 -6
- package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js.map +2 -2
- package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js +8 -70
- package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js.map +3 -3
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js +15 -92
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
- package/dist/modules/storage_s3/di.js +2 -6
- package/dist/modules/storage_s3/di.js.map +2 -2
- package/dist/modules/storage_s3/integration.js +1 -1
- package/dist/modules/storage_s3/integration.js.map +1 -1
- package/dist/modules/storage_s3/lib/health.js +1 -18
- package/dist/modules/storage_s3/lib/health.js.map +2 -2
- package/dist/modules/storage_s3/lib/key-scope.js +4 -4
- package/dist/modules/storage_s3/lib/key-scope.js.map +2 -2
- package/dist/modules/storage_s3/lib/s3-driver.js +15 -44
- package/dist/modules/storage_s3/lib/s3-driver.js.map +2 -2
- package/dist/modules/storage_s3/lib/storage-service.js +22 -126
- package/dist/modules/storage_s3/lib/storage-service.js.map +3 -3
- package/jest.config.cjs +0 -1
- package/package.json +6 -13
- package/src/modules/storage_s3/__tests__/s3Driver.test.ts +8 -23
- package/src/modules/storage_s3/__tests__/s3ListRoute.test.ts +1 -7
- package/src/modules/storage_s3/__tests__/s3Routes.test.ts +0 -26
- package/src/modules/storage_s3/__tests__/storageService.test.ts +5 -20
- package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +5 -22
- package/src/modules/storage_s3/api/get/storage-providers/s3/download.ts +5 -7
- package/src/modules/storage_s3/api/get/storage-providers/s3/list.ts +4 -6
- package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +6 -77
- package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +16 -101
- package/src/modules/storage_s3/di.ts +2 -14
- package/src/modules/storage_s3/integration.ts +1 -1
- package/src/modules/storage_s3/lib/health.ts +0 -18
- package/src/modules/storage_s3/lib/key-scope.ts +4 -4
- package/src/modules/storage_s3/lib/s3-driver.ts +10 -65
- package/src/modules/storage_s3/lib/storage-service.ts +22 -131
- package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js +0 -145
- package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js.map +0 -7
- package/dist/modules/storage_s3/lib/endpoint-safety.js +0 -133
- package/dist/modules/storage_s3/lib/endpoint-safety.js.map +0 -7
- package/dist/modules/storage_s3/lib/quota-accounting.js +0 -24
- package/dist/modules/storage_s3/lib/quota-accounting.js.map +0 -7
- package/dist/modules/storage_s3/lib/quota-recovery-queue.js +0 -9
- package/dist/modules/storage_s3/lib/quota-recovery-queue.js.map +0 -7
- package/dist/modules/storage_s3/workers/quota-recovery.js +0 -70
- package/dist/modules/storage_s3/workers/quota-recovery.js.map +0 -7
- package/src/modules/storage_s3/__tests__/endpoint-safety.test.ts +0 -71
- package/src/modules/storage_s3/__tests__/health.test.ts +0 -38
- package/src/modules/storage_s3/__tests__/i18nPackageExports.test.ts +0 -13
- package/src/modules/storage_s3/__tests__/quota-lifecycle.test.ts +0 -216
- package/src/modules/storage_s3/__tests__/quota-recovery.test.ts +0 -83
- package/src/modules/storage_s3/__tests__/upload.test.ts +0 -93
- package/src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].ts +0 -150
- package/src/modules/storage_s3/i18n/de.json +0 -17
- package/src/modules/storage_s3/i18n/en.json +0 -17
- package/src/modules/storage_s3/i18n/es.json +0 -17
- package/src/modules/storage_s3/i18n/ko.json +0 -17
- package/src/modules/storage_s3/i18n/pl.json +0 -17
- package/src/modules/storage_s3/lib/endpoint-safety.ts +0 -179
- package/src/modules/storage_s3/lib/quota-accounting.ts +0 -29
- package/src/modules/storage_s3/lib/quota-recovery-queue.ts +0 -17
- 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": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC
|
|
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
|
|
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("
|
|
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
|
|
5
|
-
"mappings": "AAAA,SAAS,YAAY,eAAe;AAEpC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,uBAAuB;AAChC,SAAS,4BAA4B;
|
|
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.
|
|
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.
|
|
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
|
|
5
|
-
"mappings": "AAAA,SAAS,UAAU,yBAAyB;
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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('
|
|
5
|
-
"mappings": "AAcA,MAAM,qBAAqB;AAC3B,MAAM,wBAAwB;AAE9B,SAAS,eAAe,OAAkC,QAAyC;AACjG,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,MAAI,CAAC,QAAS,QAAO;AACrB,SAAO,GAAG,MAAM,IAAI,OAAO;AAC7B;AAEA,SAAS,qBAAqB,OAA+D;AAC3F,QAAM,aAAa,eAAe,OAAO,gBAAgB,KAAK;AAC9D,QAAM,gBAAgB,eAAe,OAAO,UAAU,QAAQ;AAC9D,MAAI,CAAC,cAAc,CAAC,cAAe,QAAO;AAC1C,SAAO,EAAE,YAAY,cAAc;AACrC;AAEA,SAAS,cAAc,OAAuB;AAC5C,SAAO,MAAM,QAAQ,QAAQ,EAAE;AACjC;AAEA,SAAS,sBAAsB,OAAe,YAA6B;AACzE,QAAM,aAAa,cAAc,KAAK;AACtC,QAAM,mBAAmB,cAAc,cAAc,EAAE;AACvD,MAAI,CAAC,iBAAkB,QAAO;AAC9B,SAAO,WAAW,WAAW,gBAAgB,IACzC,WAAW,MAAM,iBAAiB,MAAM,EAAE,QAAQ,QAAQ,EAAE,IAC5D;AACN;AAEA,SAAS,YAAY,OAAe,YAA+B;AACjE,SAAO,sBAAsB,OAAO,UAAU,EAAE,MAAM,GAAG,EAAE,OAAO,OAAO;AAC3E;AAEA,SAAS,sBAAsB,OAAyB;AACtD,SAAO,MAAM,UAAU,CAAC,MAAM,UAC5B,KAAK,WAAW,MAAM,KAAK,MAAM,QAAQ,CAAC,GAAG,WAAW,SAAS,CAClE;AACH;AAEA,SAAS,iBAAiB,OAAiB,YAAoB,eAAgC;AAC7F,QAAM,aAAa,sBAAsB,KAAK;AAC9C,SAAO,cAAc,KAChB,MAAM,UAAU,MAAM,cACtB,MAAM,aAAa,CAAC,MAAM;AACjC;AAEA,SAAS,6BACP,cACA,UACsB;AACtB,MAAI,OAAO,iBAAiB,UAAU;AACpC,WAAO,qBAAqB,EAAE,gBAAgB,cAAc,SAAS,CAAC;AAAA,EACxE;AACA,SAAO,qBAAqB,YAAY;AAC1C;AAQO,SAAS,sBACd,aACA,cACA,sBACS;AACT,QAAM,WAAW,6BAA6B,cAAc,oBAAoB;AAChF,MAAI,CAAC,SAAU,QAAO,OAAO,iBAAiB,WAAW,QAAQ;AAEjE,QAAM,aAAa,OAAO,iBAAiB,WAAW,SAAY;AAClE,QAAM,QAAQ,YAAY,aAAa,UAAU;AACjD,SAAO,iBAAiB,OAAO,SAAS,YAAY,SAAS,aAAa;AAC5E;AAEO,SAAS,cAAc,KAAsB;AAClD,SAAO,iBAAiB,YAAY,GAAG,GAAG,oBAAoB,qBAAqB;AACrF;AAEO,SAAS,0BAA0B,KAAa,OAAe,UAA2B;AAC/F,SAAO,sBAAsB,KAAK,OAAO,QAAQ,KAAK,cAAc,GAAG;AACzE;AAEO,SAAS,gCACd,aACA,OACA,YACS;AACT,MAAI,CAAC,qBAAqB,KAAK,EAAG,QAAO;AACzC,QAAM,QAAQ,YAAY,aAAa,UAAU;AACjD,SAAO,sBAAsB,aAAa,OAAO,UAAU,KACtD,iBAAiB,OAAO,oBAAoB,qBAAqB;AACxE;AAEO,SAAS,0BACd,aACA,OACA,YACM;AACN,MAAI,CAAC,sBAAsB,aAAa,OAAO,UAAU,GAAG;AAC1D,UAAM,IAAI,MAAM,
|
|
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("
|
|
84
|
+
throw new Error("S3 key is not scoped to the requested partition");
|
|
94
85
|
}
|
|
95
86
|
}
|
|
96
|
-
|
|
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
|
-
|
|
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(`
|
|
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.
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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": "AAAA,SAAS,kBAAkB;AAC3B,OAAO,QAAQ;AACf,OAAO,UAAU;AACjB,SAAS,YAAY,UAAU;AAC/B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,oBAAoB;AAE7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,
|
|
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
|
}
|