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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js +20 -5
  3. package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js.map +2 -2
  4. package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js +7 -5
  5. package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js.map +2 -2
  6. package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js +6 -4
  7. package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js.map +2 -2
  8. package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js +70 -8
  9. package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js.map +3 -3
  10. package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js +92 -15
  11. package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
  12. package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js +145 -0
  13. package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js.map +7 -0
  14. package/dist/modules/storage_s3/di.js +6 -2
  15. package/dist/modules/storage_s3/di.js.map +2 -2
  16. package/dist/modules/storage_s3/integration.js +1 -1
  17. package/dist/modules/storage_s3/integration.js.map +1 -1
  18. package/dist/modules/storage_s3/lib/endpoint-safety.js +133 -0
  19. package/dist/modules/storage_s3/lib/endpoint-safety.js.map +7 -0
  20. package/dist/modules/storage_s3/lib/health.js +18 -1
  21. package/dist/modules/storage_s3/lib/health.js.map +2 -2
  22. package/dist/modules/storage_s3/lib/key-scope.js +4 -4
  23. package/dist/modules/storage_s3/lib/key-scope.js.map +2 -2
  24. package/dist/modules/storage_s3/lib/quota-accounting.js +24 -0
  25. package/dist/modules/storage_s3/lib/quota-accounting.js.map +7 -0
  26. package/dist/modules/storage_s3/lib/quota-recovery-queue.js +9 -0
  27. package/dist/modules/storage_s3/lib/quota-recovery-queue.js.map +7 -0
  28. package/dist/modules/storage_s3/lib/s3-driver.js +44 -15
  29. package/dist/modules/storage_s3/lib/s3-driver.js.map +2 -2
  30. package/dist/modules/storage_s3/lib/storage-service.js +126 -22
  31. package/dist/modules/storage_s3/lib/storage-service.js.map +3 -3
  32. package/dist/modules/storage_s3/workers/quota-recovery.js +70 -0
  33. package/dist/modules/storage_s3/workers/quota-recovery.js.map +7 -0
  34. package/jest.config.cjs +1 -0
  35. package/package.json +13 -6
  36. package/src/modules/storage_s3/__tests__/endpoint-safety.test.ts +71 -0
  37. package/src/modules/storage_s3/__tests__/health.test.ts +38 -0
  38. package/src/modules/storage_s3/__tests__/i18nPackageExports.test.ts +13 -0
  39. package/src/modules/storage_s3/__tests__/quota-lifecycle.test.ts +216 -0
  40. package/src/modules/storage_s3/__tests__/quota-recovery.test.ts +83 -0
  41. package/src/modules/storage_s3/__tests__/s3Driver.test.ts +23 -8
  42. package/src/modules/storage_s3/__tests__/s3ListRoute.test.ts +7 -1
  43. package/src/modules/storage_s3/__tests__/s3Routes.test.ts +26 -0
  44. package/src/modules/storage_s3/__tests__/storageService.test.ts +20 -5
  45. package/src/modules/storage_s3/__tests__/upload.test.ts +93 -0
  46. package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +22 -5
  47. package/src/modules/storage_s3/api/get/storage-providers/s3/download.ts +7 -5
  48. package/src/modules/storage_s3/api/get/storage-providers/s3/list.ts +6 -4
  49. package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +77 -6
  50. package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +101 -16
  51. package/src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].ts +150 -0
  52. package/src/modules/storage_s3/di.ts +14 -2
  53. package/src/modules/storage_s3/i18n/de.json +19 -0
  54. package/src/modules/storage_s3/i18n/en.json +19 -0
  55. package/src/modules/storage_s3/i18n/es.json +19 -0
  56. package/src/modules/storage_s3/i18n/ko.json +19 -0
  57. package/src/modules/storage_s3/i18n/pl.json +19 -0
  58. package/src/modules/storage_s3/integration.ts +1 -1
  59. package/src/modules/storage_s3/lib/endpoint-safety.ts +179 -0
  60. package/src/modules/storage_s3/lib/health.ts +18 -0
  61. package/src/modules/storage_s3/lib/key-scope.ts +4 -4
  62. package/src/modules/storage_s3/lib/quota-accounting.ts +29 -0
  63. package/src/modules/storage_s3/lib/quota-recovery-queue.ts +17 -0
  64. package/src/modules/storage_s3/lib/s3-driver.ts +65 -10
  65. package/src/modules/storage_s3/lib/storage-service.ts +131 -22
  66. package/src/modules/storage_s3/workers/quota-recovery.ts +82 -0
@@ -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("S3 key is not scoped to the active tenant");
60
+ throw new Error("[internal] 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("S3 key is not scoped to the active tenant");
65
+ throw new Error("[internal] 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("S3 prefix is not scoped to the active tenant");
75
+ throw new Error("[internal] 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("S3 prefix is not scoped to the active tenant");
80
+ throw new Error("[internal] 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('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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",
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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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,24 @@
1
+ import { isS3KeyScopedToTenant } from "./key-scope.js";
2
+ async function reconcileTenantS3Objects(input) {
3
+ const objects = [];
4
+ let continuationToken;
5
+ do {
6
+ const page = await input.driver.listObjects("", 1e3, continuationToken);
7
+ for (const file of page.files) {
8
+ if (isS3KeyScopedToTenant(file.key, input.organizationId, input.tenantId)) {
9
+ objects.push({ path: file.key, bytes: file.size });
10
+ }
11
+ }
12
+ continuationToken = page.truncated ? page.nextContinuationToken : void 0;
13
+ } while (continuationToken);
14
+ await input.quotaService.reconcileStandaloneObjects({
15
+ tenantId: input.tenantId,
16
+ organizationId: input.organizationId,
17
+ storageDriver: "s3",
18
+ objects
19
+ });
20
+ }
21
+ export {
22
+ reconcileTenantS3Objects
23
+ };
24
+ //# sourceMappingURL=quota-accounting.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/storage_s3/lib/quota-accounting.ts"],
4
+ "sourcesContent": ["import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { isS3KeyScopedToTenant } from './key-scope'\nimport type { S3StorageDriver } from './s3-driver'\n\nexport async function reconcileTenantS3Objects(input: {\n driver: S3StorageDriver\n quotaService: AttachmentQuotaService\n tenantId: string\n organizationId: string\n}): Promise<void> {\n const objects: Array<{ path: string; bytes: number }> = []\n let continuationToken: string | undefined\n do {\n const page = await input.driver.listObjects('', 1000, continuationToken)\n for (const file of page.files) {\n if (isS3KeyScopedToTenant(file.key, input.organizationId, input.tenantId)) {\n objects.push({ path: file.key, bytes: file.size })\n }\n }\n continuationToken = page.truncated ? page.nextContinuationToken : undefined\n } while (continuationToken)\n\n await input.quotaService.reconcileStandaloneObjects({\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n storageDriver: 's3',\n objects,\n })\n}\n"],
5
+ "mappings": "AACA,SAAS,6BAA6B;AAGtC,eAAsB,yBAAyB,OAK7B;AAChB,QAAM,UAAkD,CAAC;AACzD,MAAI;AACJ,KAAG;AACD,UAAM,OAAO,MAAM,MAAM,OAAO,YAAY,IAAI,KAAM,iBAAiB;AACvE,eAAW,QAAQ,KAAK,OAAO;AAC7B,UAAI,sBAAsB,KAAK,KAAK,MAAM,gBAAgB,MAAM,QAAQ,GAAG;AACzE,gBAAQ,KAAK,EAAE,MAAM,KAAK,KAAK,OAAO,KAAK,KAAK,CAAC;AAAA,MACnD;AAAA,IACF;AACA,wBAAoB,KAAK,YAAY,KAAK,wBAAwB;AAAA,EACpE,SAAS;AAET,QAAM,MAAM,aAAa,2BAA2B;AAAA,IAClD,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM;AAAA,IACtB,eAAe;AAAA,IACf;AAAA,EACF,CAAC;AACH;",
6
+ "names": []
7
+ }
@@ -0,0 +1,9 @@
1
+ import { createModuleQueue } from "@open-mercato/queue";
2
+ const queue = createModuleQueue("storage-s3-quota-recovery", { concurrency: 2 });
3
+ async function scheduleStorageS3QuotaRecovery(payload, delayMs) {
4
+ await queue.enqueue(payload, { delayMs: Math.max(1e3, delayMs) });
5
+ }
6
+ export {
7
+ scheduleStorageS3QuotaRecovery
8
+ };
9
+ //# sourceMappingURL=quota-recovery-queue.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/storage_s3/lib/quota-recovery-queue.ts"],
4
+ "sourcesContent": ["import { createModuleQueue } from '@open-mercato/queue'\n\nexport type StorageS3QuotaRecoveryJob = {\n reservationId: string\n tenantId: string\n organizationId: string\n absenceCheck?: number\n}\n\nconst queue = createModuleQueue<StorageS3QuotaRecoveryJob>('storage-s3-quota-recovery', { concurrency: 2 })\n\nexport async function scheduleStorageS3QuotaRecovery(\n payload: StorageS3QuotaRecoveryJob,\n delayMs: number,\n): Promise<void> {\n await queue.enqueue(payload, { delayMs: Math.max(1_000, delayMs) })\n}\n"],
5
+ "mappings": "AAAA,SAAS,yBAAyB;AASlC,MAAM,QAAQ,kBAA6C,6BAA6B,EAAE,aAAa,EAAE,CAAC;AAE1G,eAAsB,+BACpB,SACA,SACe;AACf,QAAM,MAAM,QAAQ,SAAS,EAAE,SAAS,KAAK,IAAI,KAAO,OAAO,EAAE,CAAC;AACpE;",
6
+ "names": []
7
+ }
@@ -10,6 +10,11 @@ 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";
13
18
  import {
14
19
  assertS3KeyAddressableByTenantScope,
15
20
  assertS3KeyScopedToTenant,
@@ -41,6 +46,7 @@ class S3StorageDriver {
41
46
  this.key = "s3";
42
47
  const cfg = config;
43
48
  this.bucket = cfg.bucket;
49
+ this.endpoint = cfg.endpoint;
44
50
  this.pathPrefix = cfg.pathPrefix ?? "";
45
51
  this.scope = cfg.organizationId && cfg.tenantId ? { organizationId: cfg.organizationId, tenantId: cfg.tenantId } : null;
46
52
  const authMode = cfg.authMode;
@@ -63,11 +69,14 @@ class S3StorageDriver {
63
69
  };
64
70
  }
65
71
  }
72
+ assertStaticallySafeS3Endpoint(cfg.endpoint);
73
+ const requestHandler = createSafeS3RequestHandler(cfg.endpoint);
66
74
  this.client = new S3Client({
67
75
  region: cfg.region ?? "us-east-1",
68
76
  endpoint: cfg.endpoint,
69
77
  forcePathStyle: cfg.forcePathStyle ?? false,
70
- credentials
78
+ credentials,
79
+ ...requestHandler ? { requestHandler } : {}
71
80
  });
72
81
  }
73
82
  assertKeyScoped(storagePath, scope) {
@@ -81,22 +90,29 @@ class S3StorageDriver {
81
90
  const prefix = this.pathPrefix && storagePath.startsWith(this.pathPrefix) ? storagePath.slice(this.pathPrefix.length).replace(/^\/+/, "") : storagePath.replace(/^\/+/, "");
82
91
  const firstSegment = prefix.split("/").filter(Boolean)[0];
83
92
  if (firstSegment !== partitionCode) {
84
- throw new Error("S3 key is not scoped to the requested partition");
93
+ throw new Error("[internal] S3 key is not scoped to the requested partition");
85
94
  }
86
95
  }
87
- async store(payload) {
96
+ prepareStoragePath(payload) {
88
97
  const orgSegment = resolveOrgSegment(payload.orgId ?? null);
89
98
  const tenantSegment = resolveTenantSegment(payload.tenantId ?? null);
90
99
  const safeName = sanitizeFileName(payload.fileName || "file");
91
100
  const uniqueSuffix = randomUUID().replace(/-/g, "").slice(0, 12);
92
101
  const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`;
93
- const key = `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`;
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();
94
109
  await this.client.send(
95
110
  new PutObjectCommand({
96
111
  Bucket: this.bucket,
97
112
  Key: key,
98
113
  Body: payload.buffer,
99
- ContentLength: payload.buffer.length
114
+ ContentLength: payload.buffer.length,
115
+ IfNoneMatch: "*"
100
116
  })
101
117
  );
102
118
  return { storagePath: key };
@@ -104,11 +120,12 @@ class S3StorageDriver {
104
120
  async read(partitionCode, storagePath) {
105
121
  this.assertPartitionScoped(partitionCode, storagePath);
106
122
  this.assertKeyAddressable(storagePath);
123
+ await this.assertEndpointSafe();
107
124
  const response = await this.client.send(
108
125
  new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })
109
126
  );
110
127
  if (!response.Body) {
111
- throw new Error(`S3 object body is empty for key: ${storagePath}`);
128
+ throw new Error(`[internal] S3 object body is empty for key: ${storagePath}`);
112
129
  }
113
130
  const buffer = await streamToBuffer(response.Body);
114
131
  return { buffer, contentType: response.ContentType };
@@ -117,12 +134,16 @@ class S3StorageDriver {
117
134
  this.assertPartitionScoped(partitionCode, storagePath);
118
135
  this.assertKeyAddressable(storagePath);
119
136
  try {
120
- await this.client.send(
121
- new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath })
122
- );
137
+ await this.deleteStrict(partitionCode, storagePath);
123
138
  } catch {
124
139
  }
125
140
  }
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
+ }
126
147
  async toLocalPath(partitionCode, storagePath) {
127
148
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "s3-tmp-"));
128
149
  const cleanup = async () => {
@@ -144,13 +165,15 @@ class S3StorageDriver {
144
165
  */
145
166
  async putObject(key, buffer, contentType) {
146
167
  this.assertKeyScoped(key);
168
+ await this.assertEndpointSafe();
147
169
  await this.client.send(
148
170
  new PutObjectCommand({
149
171
  Bucket: this.bucket,
150
172
  Key: key,
151
173
  Body: buffer,
152
174
  ContentType: contentType,
153
- ContentLength: buffer.length
175
+ ContentLength: buffer.length,
176
+ IfNoneMatch: "*"
154
177
  })
155
178
  );
156
179
  }
@@ -160,6 +183,7 @@ class S3StorageDriver {
160
183
  async listObjects(prefix, maxKeys = 100, continuationToken, scope) {
161
184
  const activeScope = scope ?? this.scope;
162
185
  assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix);
186
+ await this.assertEndpointSafe();
163
187
  const response = await this.client.send(
164
188
  new ListObjectsV2Command({
165
189
  Bucket: this.bucket,
@@ -179,26 +203,31 @@ class S3StorageDriver {
179
203
  nextContinuationToken: response.NextContinuationToken
180
204
  };
181
205
  }
182
- /**
183
- * Generate a pre-signed URL for direct browser upload or download.
184
- */
185
- async getSignedUrl(storagePath, operation, expiresIn = 3600, contentType, scope) {
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;
186
209
  if (operation === "upload") {
187
210
  this.assertKeyScoped(storagePath, scope);
188
211
  } else {
189
212
  this.assertKeyAddressable(storagePath, scope);
190
213
  }
214
+ await this.assertEndpointSafe();
191
215
  if (operation === "upload") {
192
216
  const command2 = new PutObjectCommand({
193
217
  Bucket: this.bucket,
194
218
  Key: storagePath,
195
- ContentType: contentType
219
+ ContentType: contentType,
220
+ ContentLength: contentLength,
221
+ ...createOnly ? { IfNoneMatch: "*" } : {}
196
222
  });
197
223
  return getSignedUrl(this.client, command2, { expiresIn });
198
224
  }
199
225
  const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath });
200
226
  return getSignedUrl(this.client, command, { expiresIn });
201
227
  }
228
+ async assertEndpointSafe() {
229
+ await assertSafeS3Endpoint(this.endpoint);
230
+ }
202
231
  }
203
232
  export {
204
233
  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 { 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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",
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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",
6
6
  "names": ["command"]
7
7
  }
@@ -1,5 +1,7 @@
1
- import { randomUUID } from "crypto";
1
+ import { createHash, randomBytes } from "crypto";
2
2
  import { S3StorageDriver } from "./s3-driver.js";
3
+ import { resolveAttachmentMaxBytes } from "@open-mercato/core/modules/attachments/lib/upload-limits";
4
+ import { reconcileTenantS3Objects } from "./quota-accounting.js";
3
5
  import {
4
6
  assertS3KeyAddressableByTenantScope,
5
7
  assertS3KeyScopedToTenant,
@@ -8,32 +10,89 @@ import {
8
10
  function sanitizeSegment(value) {
9
11
  return value.replace(/[^a-zA-Z0-9._-]/g, "_");
10
12
  }
11
- function buildKey(namespace, scope, fileName) {
12
- const orgSeg = scope.organizationId ? `org_${scope.organizationId}` : "org_shared";
13
- const tenantSeg = scope.tenantId ? `tenant_${scope.tenantId}` : "tenant_shared";
14
- const uniqueSuffix = randomUUID().replace(/-/g, "").slice(0, 12);
15
- const safeName = sanitizeSegment(fileName || "file");
16
- return `${sanitizeSegment(namespace)}/${orgSeg}/${tenantSeg}/${Date.now()}_${uniqueSuffix}_${safeName}`;
17
- }
18
13
  function createStorageService(config) {
19
14
  const driver = new S3StorageDriver(config);
15
+ const quotaService = config.quotaService;
16
+ const quotaRecoveryScheduler = config.quotaRecoveryScheduler;
20
17
  const pathPrefix = config.pathPrefix;
21
18
  return {
22
19
  async upload({ namespace, fileName, buffer, contentType, scope }) {
23
- const stored = await driver.store({
24
- partitionCode: namespace,
20
+ const storagePath = driver.prepareStoragePath({
21
+ partitionCode: sanitizeSegment(namespace),
25
22
  orgId: scope.organizationId,
26
23
  tenantId: scope.tenantId,
27
- fileName,
28
- buffer
24
+ fileName
29
25
  });
30
- return {
31
- key: stored.storagePath,
32
- namespace,
33
- fileName,
34
- size: buffer.length,
35
- contentType
36
- };
26
+ assertS3KeyScopedToTenant(storagePath, scope, pathPrefix);
27
+ let reservation = null;
28
+ let storageStarted = false;
29
+ let objectStored = false;
30
+ try {
31
+ if (quotaService && scope.tenantId && scope.organizationId) {
32
+ await reconcileTenantS3Objects({
33
+ driver,
34
+ quotaService,
35
+ tenantId: scope.tenantId,
36
+ organizationId: scope.organizationId
37
+ });
38
+ reservation = await quotaService.reserve({
39
+ tenantId: scope.tenantId,
40
+ organizationId: scope.organizationId,
41
+ bytes: buffer.length,
42
+ source: "storage_service",
43
+ storageDriver: "s3",
44
+ storagePath,
45
+ partitionCode: namespace
46
+ });
47
+ }
48
+ if (reservation) {
49
+ if (!quotaRecoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
50
+ await quotaRecoveryScheduler(
51
+ {
52
+ reservationId: reservation.id,
53
+ tenantId: scope.tenantId,
54
+ organizationId: scope.organizationId
55
+ },
56
+ Math.max(1e3, reservation.expiresAt.getTime() - Date.now())
57
+ );
58
+ await quotaService.beginStorage(reservation.id, reservation.leaseToken);
59
+ storageStarted = true;
60
+ }
61
+ const stored = await driver.store({
62
+ partitionCode: namespace,
63
+ orgId: scope.organizationId,
64
+ tenantId: scope.tenantId,
65
+ fileName,
66
+ buffer,
67
+ storagePath
68
+ });
69
+ objectStored = true;
70
+ if (reservation) {
71
+ await quotaService.markStored(reservation.id, reservation.leaseToken);
72
+ await quotaService.completeStandalone(reservation.id, reservation.leaseToken, buffer.length);
73
+ }
74
+ return {
75
+ key: stored.storagePath,
76
+ namespace,
77
+ fileName,
78
+ size: buffer.length,
79
+ contentType
80
+ };
81
+ } catch (error) {
82
+ if (reservation) {
83
+ if (objectStored) {
84
+ try {
85
+ await driver.deleteStrict("", storagePath);
86
+ await quotaService.release(reservation.id, reservation.leaseToken);
87
+ } catch {
88
+ }
89
+ } else if (!storageStarted) {
90
+ await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
91
+ });
92
+ }
93
+ }
94
+ throw error;
95
+ }
37
96
  },
38
97
  async download({ key, scope }) {
39
98
  assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
@@ -41,16 +100,61 @@ function createStorageService(config) {
41
100
  },
42
101
  async delete({ key, scope }) {
43
102
  assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
44
- return driver.delete("", key);
103
+ return driver.deleteStrict("", key).then(async () => {
104
+ if (quotaService && scope.tenantId) {
105
+ await quotaService.releaseCommittedByPath({
106
+ tenantId: scope.tenantId,
107
+ storageDriver: "s3",
108
+ storagePath: key
109
+ });
110
+ }
111
+ });
45
112
  },
46
- async getSignedUrl({ key, operation, expiresIn = 3600, contentType, scope }) {
113
+ async getSignedUrl({ key, operation, expiresIn = 3600, contentType, size, scope }) {
47
114
  if (operation === "upload") {
48
115
  assertS3KeyScopedToTenant(key, scope, pathPrefix);
49
116
  } else {
50
117
  assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
51
118
  }
119
+ const expiresAt = new Date(Date.now() + expiresIn * 1e3);
120
+ if (operation === "upload" && quotaService && scope.tenantId && scope.organizationId) {
121
+ const compatibilityToken = randomBytes(32).toString("base64url");
122
+ let reservation = null;
123
+ try {
124
+ await reconcileTenantS3Objects({
125
+ driver,
126
+ quotaService,
127
+ tenantId: scope.tenantId,
128
+ organizationId: scope.organizationId
129
+ });
130
+ reservation = await quotaService.reserve({
131
+ tenantId: scope.tenantId,
132
+ organizationId: scope.organizationId,
133
+ bytes: size ?? resolveAttachmentMaxBytes(),
134
+ source: "storage_s3_signed",
135
+ storageDriver: "s3",
136
+ storagePath: key,
137
+ uploadTokenHash: createHash("sha256").update(compatibilityToken).digest("hex"),
138
+ ttlMs: expiresIn * 1e3
139
+ });
140
+ if (!quotaRecoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
141
+ await quotaRecoveryScheduler({
142
+ reservationId: reservation.id,
143
+ tenantId: scope.tenantId,
144
+ organizationId: scope.organizationId
145
+ }, Math.max(1e3, reservation.expiresAt.getTime() - Date.now()));
146
+ const url2 = `/api/storage-providers/s3/signed-upload/${compatibilityToken}`;
147
+ return { url: url2, expiresAt, reservationId: reservation.id };
148
+ } catch (error) {
149
+ if (reservation) {
150
+ await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
151
+ });
152
+ }
153
+ throw error;
154
+ }
155
+ }
52
156
  const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope);
53
- return { url, expiresAt: new Date(Date.now() + expiresIn * 1e3) };
157
+ return { url, expiresAt };
54
158
  },
55
159
  async list({ prefix, maxKeys = 100, continuationToken, scope }) {
56
160
  assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/storage_s3/lib/storage-service.ts"],
4
- "sourcesContent": ["import { randomUUID } from 'crypto'\nimport { S3StorageDriver } from './s3-driver'\nimport {\n assertS3KeyAddressableByTenantScope,\n assertS3KeyScopedToTenant,\n assertS3ListPrefixScopedToTenant,\n} from './key-scope'\n\ntype TenantScope = {\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n}\n\ntype UploadInput = {\n namespace: string\n fileName: string\n buffer: Buffer\n contentType?: string\n scope: TenantScope\n}\n\ntype StorageResult = {\n key: string\n namespace: string\n fileName: string\n size: number\n contentType?: string\n}\n\ntype DownloadInput = { key: string; scope: TenantScope }\ntype DeleteInput = { key: string; scope: TenantScope }\ntype SignedUrlInput = {\n key: string\n operation: 'upload' | 'download'\n expiresIn?: number\n contentType?: string\n scope: TenantScope\n}\ntype ListInput = {\n prefix: string\n maxKeys?: number\n continuationToken?: string\n scope: TenantScope\n}\n\ntype S3Config = {\n bucket: string\n region?: string\n endpoint?: string\n forcePathStyle?: boolean\n authMode?: 'access_keys' | 'ambient'\n credentialsEnvPrefix?: string\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n pathPrefix?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\nfunction sanitizeSegment(value: string): string {\n return value.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction buildKey(namespace: string, scope: TenantScope, fileName: string): string {\n const orgSeg = scope.organizationId ? `org_${scope.organizationId}` : 'org_shared'\n const tenantSeg = scope.tenantId ? `tenant_${scope.tenantId}` : 'tenant_shared'\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const safeName = sanitizeSegment(fileName || 'file')\n return `${sanitizeSegment(namespace)}/${orgSeg}/${tenantSeg}/${Date.now()}_${uniqueSuffix}_${safeName}`\n}\n\nexport interface StorageService {\n upload(input: UploadInput): Promise<StorageResult>\n download(input: DownloadInput): Promise<{ buffer: Buffer; contentType?: string }>\n delete(input: DeleteInput): Promise<void>\n getSignedUrl(input: SignedUrlInput): Promise<{ url: string; expiresAt: Date }>\n list(input: ListInput): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }>\n toLocalPath(input: { key: string; scope: TenantScope }): Promise<{ filePath: string; cleanup: () => Promise<void> }>\n}\n\nexport function createStorageService(config: S3Config): StorageService {\n const driver = new S3StorageDriver(config as Record<string, unknown>)\n const pathPrefix = config.pathPrefix\n\n return {\n async upload({ namespace, fileName, buffer, contentType, scope }): Promise<StorageResult> {\n const stored = await driver.store({\n partitionCode: namespace,\n orgId: scope.organizationId,\n tenantId: scope.tenantId,\n fileName,\n buffer,\n })\n return {\n key: stored.storagePath,\n namespace,\n fileName,\n size: buffer.length,\n contentType,\n }\n },\n\n async download({ key, scope }): Promise<{ buffer: Buffer; contentType?: string }> {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.read('', key)\n },\n\n async delete({ key, scope }): Promise<void> {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.delete('', key)\n },\n\n async getSignedUrl({ key, operation, expiresIn = 3600, contentType, scope }): Promise<{ url: string; expiresAt: Date }> {\n if (operation === 'upload') {\n assertS3KeyScopedToTenant(key, scope, pathPrefix)\n } else {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n }\n const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope)\n return { url, expiresAt: new Date(Date.now() + expiresIn * 1000) }\n },\n\n async list({ prefix, maxKeys = 100, continuationToken, scope }): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\n assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix)\n return driver.listObjects(prefix, maxKeys, continuationToken, scope)\n },\n\n async toLocalPath({ key, scope }) {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.toLocalPath('', key)\n },\n }\n}\n"],
5
- "mappings": "AAAA,SAAS,kBAAkB;AAC3B,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAsDP,SAAS,gBAAgB,OAAuB;AAC9C,SAAO,MAAM,QAAQ,oBAAoB,GAAG;AAC9C;AAEA,SAAS,SAAS,WAAmB,OAAoB,UAA0B;AACjF,QAAM,SAAS,MAAM,iBAAiB,OAAO,MAAM,cAAc,KAAK;AACtE,QAAM,YAAY,MAAM,WAAW,UAAU,MAAM,QAAQ,KAAK;AAChE,QAAM,eAAe,WAAW,EAAE,QAAQ,MAAM,EAAE,EAAE,MAAM,GAAG,EAAE;AAC/D,QAAM,WAAW,gBAAgB,YAAY,MAAM;AACnD,SAAO,GAAG,gBAAgB,SAAS,CAAC,IAAI,MAAM,IAAI,SAAS,IAAI,KAAK,IAAI,CAAC,IAAI,YAAY,IAAI,QAAQ;AACvG;AAeO,SAAS,qBAAqB,QAAkC;AACrE,QAAM,SAAS,IAAI,gBAAgB,MAAiC;AACpE,QAAM,aAAa,OAAO;AAE1B,SAAO;AAAA,IACL,MAAM,OAAO,EAAE,WAAW,UAAU,QAAQ,aAAa,MAAM,GAA2B;AACxF,YAAM,SAAS,MAAM,OAAO,MAAM;AAAA,QAChC,eAAe;AAAA,QACf,OAAO,MAAM;AAAA,QACb,UAAU,MAAM;AAAA,QAChB;AAAA,QACA;AAAA,MACF,CAAC;AACD,aAAO;AAAA,QACL,KAAK,OAAO;AAAA,QACZ;AAAA,QACA;AAAA,QACA,MAAM,OAAO;AAAA,QACb;AAAA,MACF;AAAA,IACF;AAAA,IAEA,MAAM,SAAS,EAAE,KAAK,MAAM,GAAsD;AAChF,0CAAoC,KAAK,OAAO,UAAU;AAC1D,aAAO,OAAO,KAAK,IAAI,GAAG;AAAA,IAC5B;AAAA,IAEA,MAAM,OAAO,EAAE,KAAK,MAAM,GAAkB;AAC1C,0CAAoC,KAAK,OAAO,UAAU;AAC1D,aAAO,OAAO,OAAO,IAAI,GAAG;AAAA,IAC9B;AAAA,IAEA,MAAM,aAAa,EAAE,KAAK,WAAW,YAAY,MAAM,aAAa,MAAM,GAA8C;AACtH,UAAI,cAAc,UAAU;AAC1B,kCAA0B,KAAK,OAAO,UAAU;AAAA,MAClD,OAAO;AACL,4CAAoC,KAAK,OAAO,UAAU;AAAA,MAC5D;AACA,YAAM,MAAM,MAAM,OAAO,aAAa,KAAK,WAAW,WAAW,aAAa,KAAK;AACnF,aAAO,EAAE,KAAK,WAAW,IAAI,KAAK,KAAK,IAAI,IAAI,YAAY,GAAI,EAAE;AAAA,IACnE;AAAA,IAEA,MAAM,KAAK,EAAE,QAAQ,UAAU,KAAK,mBAAmB,MAAM,GAI1D;AACD,uCAAiC,QAAQ,OAAO,UAAU;AAC1D,aAAO,OAAO,YAAY,QAAQ,SAAS,mBAAmB,KAAK;AAAA,IACrE;AAAA,IAEA,MAAM,YAAY,EAAE,KAAK,MAAM,GAAG;AAChC,0CAAoC,KAAK,OAAO,UAAU;AAC1D,aAAO,OAAO,YAAY,IAAI,GAAG;AAAA,IACnC;AAAA,EACF;AACF;",
6
- "names": []
4
+ "sourcesContent": ["import { createHash, randomBytes } from 'crypto'\nimport { S3StorageDriver } from './s3-driver'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { resolveAttachmentMaxBytes } from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport { reconcileTenantS3Objects } from './quota-accounting'\nimport {\n assertS3KeyAddressableByTenantScope,\n assertS3KeyScopedToTenant,\n assertS3ListPrefixScopedToTenant,\n} from './key-scope'\n\ntype TenantScope = {\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n}\n\ntype UploadInput = {\n namespace: string\n fileName: string\n buffer: Buffer\n contentType?: string\n scope: TenantScope\n}\n\ntype StorageResult = {\n key: string\n namespace: string\n fileName: string\n size: number\n contentType?: string\n}\n\ntype DownloadInput = { key: string; scope: TenantScope }\ntype DeleteInput = { key: string; scope: TenantScope }\ntype SignedUrlInput = {\n key: string\n operation: 'upload' | 'download'\n expiresIn?: number\n contentType?: string\n size?: number\n scope: TenantScope\n}\ntype ListInput = {\n prefix: string\n maxKeys?: number\n continuationToken?: string\n scope: TenantScope\n}\n\ntype S3Config = {\n bucket: string\n region?: string\n endpoint?: string\n forcePathStyle?: boolean\n authMode?: 'access_keys' | 'ambient'\n credentialsEnvPrefix?: string\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n quotaService?: AttachmentQuotaService\n quotaRecoveryScheduler?: (\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>\n pathPrefix?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\nfunction sanitizeSegment(value: string): string {\n return value.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nexport interface StorageService {\n upload(input: UploadInput): Promise<StorageResult>\n download(input: DownloadInput): Promise<{ buffer: Buffer; contentType?: string }>\n delete(input: DeleteInput): Promise<void>\n getSignedUrl(input: SignedUrlInput): Promise<{ url: string; expiresAt: Date; reservationId?: string }>\n list(input: ListInput): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }>\n toLocalPath(input: { key: string; scope: TenantScope }): Promise<{ filePath: string; cleanup: () => Promise<void> }>\n}\n\nexport function createStorageService(config: S3Config): StorageService {\n const driver = new S3StorageDriver(config as Record<string, unknown>)\n const quotaService = config.quotaService\n const quotaRecoveryScheduler = config.quotaRecoveryScheduler\n const pathPrefix = config.pathPrefix\n\n return {\n async upload({ namespace, fileName, buffer, contentType, scope }): Promise<StorageResult> {\n const storagePath = driver.prepareStoragePath({\n partitionCode: sanitizeSegment(namespace),\n orgId: scope.organizationId,\n tenantId: scope.tenantId,\n fileName,\n })\n assertS3KeyScopedToTenant(storagePath, scope, pathPrefix)\n let reservation = null as Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null\n let storageStarted = false\n let objectStored = false\n try {\n if (quotaService && scope.tenantId && scope.organizationId) {\n await reconcileTenantS3Objects({\n driver,\n quotaService,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n })\n reservation = await quotaService.reserve({\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n bytes: buffer.length,\n source: 'storage_service',\n storageDriver: 's3',\n storagePath,\n partitionCode: namespace,\n })\n }\n if (reservation) {\n if (!quotaRecoveryScheduler) throw new Error('Storage quota recovery is unavailable.')\n await quotaRecoveryScheduler({\n reservationId: reservation.id,\n tenantId: scope.tenantId!,\n organizationId: scope.organizationId!,\n },\n Math.max(1_000, reservation.expiresAt.getTime() - Date.now()),\n )\n await quotaService!.beginStorage(reservation.id, reservation.leaseToken)\n storageStarted = true\n }\n const stored = await driver.store({\n partitionCode: namespace,\n orgId: scope.organizationId,\n tenantId: scope.tenantId,\n fileName,\n buffer,\n storagePath,\n })\n objectStored = true\n if (reservation) {\n await quotaService!.markStored(reservation.id, reservation.leaseToken)\n await quotaService!.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)\n }\n return {\n key: stored.storagePath,\n namespace,\n fileName,\n size: buffer.length,\n contentType,\n }\n } catch (error) {\n if (reservation) {\n if (objectStored) {\n try {\n await driver.deleteStrict('', storagePath)\n await quotaService!.release(reservation.id, reservation.leaseToken)\n } catch {\n // Keep the reservation counted for recovery when absence is ambiguous.\n }\n } else if (!storageStarted) {\n await quotaService!.release(reservation.id, reservation.leaseToken).catch(() => {})\n }\n }\n throw error\n }\n },\n\n async download({ key, scope }): Promise<{ buffer: Buffer; contentType?: string }> {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.read('', key)\n },\n\n async delete({ key, scope }): Promise<void> {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.deleteStrict('', key)\n .then(async () => {\n if (quotaService && scope.tenantId) {\n await quotaService.releaseCommittedByPath({\n tenantId: scope.tenantId,\n storageDriver: 's3',\n storagePath: key,\n })\n }\n })\n },\n\n async getSignedUrl({ key, operation, expiresIn = 3600, contentType, size, scope }) {\n if (operation === 'upload') {\n assertS3KeyScopedToTenant(key, scope, pathPrefix)\n } else {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n }\n const expiresAt = new Date(Date.now() + expiresIn * 1000)\n if (operation === 'upload' && quotaService && scope.tenantId && scope.organizationId) {\n const compatibilityToken = randomBytes(32).toString('base64url')\n let reservation: Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null = null\n try {\n await reconcileTenantS3Objects({\n driver,\n quotaService,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n })\n reservation = await quotaService.reserve({\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n bytes: size ?? resolveAttachmentMaxBytes(),\n source: 'storage_s3_signed',\n storageDriver: 's3',\n storagePath: key,\n uploadTokenHash: createHash('sha256').update(compatibilityToken).digest('hex'),\n ttlMs: expiresIn * 1000,\n })\n if (!quotaRecoveryScheduler) throw new Error('Storage quota recovery is unavailable.')\n await quotaRecoveryScheduler({\n reservationId: reservation.id,\n tenantId: scope.tenantId!,\n organizationId: scope.organizationId!,\n }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))\n const url = `/api/storage-providers/s3/signed-upload/${compatibilityToken}`\n return { url, expiresAt, reservationId: reservation.id }\n } catch (error) {\n if (reservation) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n }\n throw error\n }\n }\n const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope)\n return { url, expiresAt }\n },\n\n async list({ prefix, maxKeys = 100, continuationToken, scope }): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\n assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix)\n return driver.listObjects(prefix, maxKeys, continuationToken, scope)\n },\n\n async toLocalPath({ key, scope }) {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.toLocalPath('', key)\n },\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["url"]
7
7
  }