@open-mercato/core 0.10.1-develop.7736.1.88e07906bf → 0.10.1-develop.7748.1.d745ba61aa

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 (198) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/api_key/index.js +2 -0
  3. package/dist/generated/entities/api_key/index.js.map +2 -2
  4. package/dist/generated/entities/attachment_partition/index.js +2 -0
  5. package/dist/generated/entities/attachment_partition/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +2 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/helpers/integration/attachmentAccessFixtures.js +144 -0
  9. package/dist/helpers/integration/attachmentAccessFixtures.js.map +7 -0
  10. package/dist/modules/api_keys/data/entities.js +7 -0
  11. package/dist/modules/api_keys/data/entities.js.map +2 -2
  12. package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js +30 -0
  13. package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js.map +7 -0
  14. package/dist/modules/api_keys/services/apiKeyService.js +31 -9
  15. package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
  16. package/dist/modules/attachments/api/file/[id]/route.js +16 -10
  17. package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
  18. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +15 -9
  19. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
  20. package/dist/modules/attachments/api/library/[id]/route.js +100 -56
  21. package/dist/modules/attachments/api/library/[id]/route.js.map +3 -3
  22. package/dist/modules/attachments/api/library/route.js +56 -17
  23. package/dist/modules/attachments/api/library/route.js.map +2 -2
  24. package/dist/modules/attachments/api/partitions/route.js +2 -0
  25. package/dist/modules/attachments/api/partitions/route.js.map +2 -2
  26. package/dist/modules/attachments/api/route.js +74 -34
  27. package/dist/modules/attachments/api/route.js.map +3 -3
  28. package/dist/modules/attachments/api/transfer/route.js +63 -37
  29. package/dist/modules/attachments/api/transfer/route.js.map +2 -2
  30. package/dist/modules/attachments/data/entities.js +3 -0
  31. package/dist/modules/attachments/data/entities.js.map +2 -2
  32. package/dist/modules/attachments/data/validators.js +41 -0
  33. package/dist/modules/attachments/data/validators.js.map +7 -0
  34. package/dist/modules/attachments/di.js +5 -2
  35. package/dist/modules/attachments/di.js.map +2 -2
  36. package/dist/modules/attachments/generators.js +29 -0
  37. package/dist/modules/attachments/generators.js.map +7 -0
  38. package/dist/modules/attachments/index.js +8 -1
  39. package/dist/modules/attachments/index.js.map +2 -2
  40. package/dist/modules/attachments/lib/access-errors.js +35 -0
  41. package/dist/modules/attachments/lib/access-errors.js.map +7 -0
  42. package/dist/modules/attachments/lib/access-mutation.js +23 -0
  43. package/dist/modules/attachments/lib/access-mutation.js.map +7 -0
  44. package/dist/modules/attachments/lib/access-protection.js +73 -0
  45. package/dist/modules/attachments/lib/access-protection.js.map +7 -0
  46. package/dist/modules/attachments/lib/access-query.js +74 -0
  47. package/dist/modules/attachments/lib/access-query.js.map +7 -0
  48. package/dist/modules/attachments/lib/access-registry.js +87 -0
  49. package/dist/modules/attachments/lib/access-registry.js.map +7 -0
  50. package/dist/modules/attachments/lib/access-runner.js +161 -0
  51. package/dist/modules/attachments/lib/access-runner.js.map +7 -0
  52. package/dist/modules/attachments/lib/access-types.js +1 -0
  53. package/dist/modules/attachments/lib/access-types.js.map +7 -0
  54. package/dist/modules/attachments/lib/attachment-service.js +14 -6
  55. package/dist/modules/attachments/lib/attachment-service.js.map +2 -2
  56. package/dist/modules/attachments/lib/createFromBuffer.js +2 -0
  57. package/dist/modules/attachments/lib/createFromBuffer.js.map +2 -2
  58. package/dist/modules/attachments/lib/partitions.js +3 -2
  59. package/dist/modules/attachments/lib/partitions.js.map +2 -2
  60. package/dist/modules/attachments/lib/scoped-upload-service.js +2 -0
  61. package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
  62. package/dist/modules/attachments/lib/target-access-service.js +12 -3
  63. package/dist/modules/attachments/lib/target-access-service.js.map +2 -2
  64. package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js +38 -0
  65. package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js.map +7 -0
  66. package/dist/modules/attachments/setup.js +10 -0
  67. package/dist/modules/attachments/setup.js.map +2 -2
  68. package/dist/modules/notifications/api/[id]/action/route.js +8 -0
  69. package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
  70. package/dist/modules/notifications/api/route.js +17 -0
  71. package/dist/modules/notifications/api/route.js.map +2 -2
  72. package/dist/modules/notifications/data/validators.js +1 -1
  73. package/dist/modules/notifications/data/validators.js.map +2 -2
  74. package/dist/modules/notifications/lib/notificationService.js +45 -3
  75. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  76. package/dist/modules/notifications/lib/routeHelpers.js +27 -0
  77. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  78. package/dist/modules/payment_gateways/lib/gateway-service.js +50 -34
  79. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  80. package/dist/modules/payment_gateways/lib/status-machine.js +10 -1
  81. package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
  82. package/dist/modules/payment_gateways/lib/webhook-processor.js +1 -1
  83. package/dist/modules/payment_gateways/lib/webhook-processor.js.map +2 -2
  84. package/dist/modules/staff/notifications.js +4 -2
  85. package/dist/modules/staff/notifications.js.map +2 -2
  86. package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js +1 -1
  87. package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js.map +2 -2
  88. package/dist/modules/workflows/api/tasks/[id]/complete/route.js +7 -1
  89. package/dist/modules/workflows/api/tasks/[id]/complete/route.js.map +2 -2
  90. package/dist/modules/workflows/backend/tasks/[id]/page.js +2 -1
  91. package/dist/modules/workflows/backend/tasks/[id]/page.js.map +2 -2
  92. package/dist/modules/workflows/data/entities.js.map +2 -2
  93. package/dist/modules/workflows/lib/activity-executor.js +18 -4
  94. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  95. package/dist/modules/workflows/lib/activity-types.js +2 -2
  96. package/dist/modules/workflows/lib/activity-types.js.map +2 -2
  97. package/dist/modules/workflows/lib/condition-handler.js +7 -1
  98. package/dist/modules/workflows/lib/condition-handler.js.map +2 -2
  99. package/dist/modules/workflows/lib/duration.js +13 -0
  100. package/dist/modules/workflows/lib/duration.js.map +2 -2
  101. package/dist/modules/workflows/lib/instance-status.js +16 -0
  102. package/dist/modules/workflows/lib/instance-status.js.map +7 -0
  103. package/dist/modules/workflows/lib/step-handler.js +9 -2
  104. package/dist/modules/workflows/lib/step-handler.js.map +2 -2
  105. package/dist/modules/workflows/lib/task-handler.js +16 -8
  106. package/dist/modules/workflows/lib/task-handler.js.map +2 -2
  107. package/dist/modules/workflows/lib/task-quick-action.js +13 -10
  108. package/dist/modules/workflows/lib/task-quick-action.js.map +2 -2
  109. package/dist/modules/workflows/lib/task-sla.js +10 -0
  110. package/dist/modules/workflows/lib/task-sla.js.map +2 -2
  111. package/dist/modules/workflows/lib/transition-handler.js +2 -1
  112. package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
  113. package/dist/modules/workflows/lib/workflow-executor.js +29 -1
  114. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  115. package/dist/modules/workflows/notifications.js +4 -0
  116. package/dist/modules/workflows/notifications.js.map +2 -2
  117. package/generated/entities/api_key/index.ts +1 -0
  118. package/generated/entities/attachment_partition/index.ts +1 -0
  119. package/generated/entity-fields-registry.ts +2 -0
  120. package/package.json +7 -7
  121. package/src/helpers/integration/attachmentAccessFixtures.ts +141 -0
  122. package/src/modules/api_keys/data/entities.ts +25 -1
  123. package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +234 -68
  124. package/src/modules/api_keys/migrations/Migration20261012202110_api_keys.ts +47 -0
  125. package/src/modules/api_keys/services/apiKeyService.ts +55 -9
  126. package/src/modules/attachments/api/file/[id]/route.ts +12 -9
  127. package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +10 -8
  128. package/src/modules/attachments/api/library/[id]/route.ts +87 -58
  129. package/src/modules/attachments/api/library/route.ts +40 -11
  130. package/src/modules/attachments/api/partitions/route.ts +2 -0
  131. package/src/modules/attachments/api/route.ts +68 -35
  132. package/src/modules/attachments/api/transfer/route.ts +65 -42
  133. package/src/modules/attachments/data/entities.ts +4 -0
  134. package/src/modules/attachments/data/validators.ts +36 -0
  135. package/src/modules/attachments/di.ts +4 -1
  136. package/src/modules/attachments/generators.ts +27 -0
  137. package/src/modules/attachments/i18n/de.json +4 -0
  138. package/src/modules/attachments/i18n/en.json +4 -0
  139. package/src/modules/attachments/i18n/es.json +4 -0
  140. package/src/modules/attachments/i18n/ko.json +4 -0
  141. package/src/modules/attachments/i18n/pl.json +4 -0
  142. package/src/modules/attachments/index.ts +18 -0
  143. package/src/modules/attachments/lib/access-errors.ts +32 -0
  144. package/src/modules/attachments/lib/access-mutation.ts +26 -0
  145. package/src/modules/attachments/lib/access-protection.ts +83 -0
  146. package/src/modules/attachments/lib/access-query.ts +86 -0
  147. package/src/modules/attachments/lib/access-registry.ts +117 -0
  148. package/src/modules/attachments/lib/access-runner.ts +183 -0
  149. package/src/modules/attachments/lib/access-types.ts +78 -0
  150. package/src/modules/attachments/lib/attachment-service.ts +8 -4
  151. package/src/modules/attachments/lib/createFromBuffer.ts +3 -0
  152. package/src/modules/attachments/lib/partitions.ts +3 -2
  153. package/src/modules/attachments/lib/scoped-upload-service.ts +2 -0
  154. package/src/modules/attachments/lib/target-access-service.ts +10 -3
  155. package/src/modules/attachments/migrations/.snapshot-open-mercato.json +17 -0
  156. package/src/modules/attachments/migrations/Migration20261001120000_attachments.ts +35 -0
  157. package/src/modules/attachments/setup.ts +10 -0
  158. package/src/modules/auth/i18n/de.json +3 -0
  159. package/src/modules/auth/i18n/en.json +3 -0
  160. package/src/modules/auth/i18n/es.json +3 -0
  161. package/src/modules/auth/i18n/ko.json +3 -0
  162. package/src/modules/auth/i18n/pl.json +3 -0
  163. package/src/modules/notifications/api/[id]/action/route.ts +8 -0
  164. package/src/modules/notifications/api/route.ts +17 -0
  165. package/src/modules/notifications/data/validators.ts +1 -1
  166. package/src/modules/notifications/i18n/de.json +2 -0
  167. package/src/modules/notifications/i18n/en.json +2 -0
  168. package/src/modules/notifications/i18n/es.json +2 -0
  169. package/src/modules/notifications/i18n/ko.json +2 -0
  170. package/src/modules/notifications/i18n/pl.json +2 -0
  171. package/src/modules/notifications/lib/notificationService.ts +67 -1
  172. package/src/modules/notifications/lib/routeHelpers.ts +38 -0
  173. package/src/modules/payment_gateways/lib/gateway-service.ts +50 -33
  174. package/src/modules/payment_gateways/lib/status-machine.ts +19 -0
  175. package/src/modules/payment_gateways/lib/webhook-processor.ts +1 -1
  176. package/src/modules/staff/notifications.ts +2 -0
  177. package/src/modules/workflows/AGENTS.md +2 -2
  178. package/src/modules/workflows/api/portal/tasks/[id]/complete/route.ts +5 -1
  179. package/src/modules/workflows/api/tasks/[id]/complete/route.ts +7 -1
  180. package/src/modules/workflows/backend/tasks/[id]/page.tsx +6 -2
  181. package/src/modules/workflows/data/entities.ts +6 -0
  182. package/src/modules/workflows/i18n/de.json +2 -0
  183. package/src/modules/workflows/i18n/en.json +2 -0
  184. package/src/modules/workflows/i18n/es.json +2 -0
  185. package/src/modules/workflows/i18n/ko.json +2 -0
  186. package/src/modules/workflows/i18n/pl.json +2 -0
  187. package/src/modules/workflows/lib/activity-executor.ts +36 -4
  188. package/src/modules/workflows/lib/activity-types.ts +2 -2
  189. package/src/modules/workflows/lib/condition-handler.ts +16 -1
  190. package/src/modules/workflows/lib/duration.ts +25 -0
  191. package/src/modules/workflows/lib/instance-status.ts +29 -0
  192. package/src/modules/workflows/lib/step-handler.ts +16 -1
  193. package/src/modules/workflows/lib/task-handler.ts +18 -10
  194. package/src/modules/workflows/lib/task-quick-action.ts +19 -10
  195. package/src/modules/workflows/lib/task-sla.ts +13 -0
  196. package/src/modules/workflows/lib/transition-handler.ts +2 -1
  197. package/src/modules/workflows/lib/workflow-executor.ts +44 -1
  198. package/src/modules/workflows/notifications.ts +4 -0
@@ -0,0 +1,83 @@
1
+ import { LockMode } from '@mikro-orm/core'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import { AttachmentPartition } from '../data/entities'
5
+ import { attachmentAccessRequirementsSchema } from '../data/validators'
6
+ import { getAttachmentAccessRegistry, matchesAttachmentAccessSelector } from './access-registry'
7
+ import type { AttachmentAccessRequirement } from './access-types'
8
+
9
+ export function getDeclaredAttachmentAccessRequirements(partitionCode: string): AttachmentAccessRequirement[] {
10
+ const registry = getAttachmentAccessRegistry()
11
+ if (registry.invalid) throw new Error('[internal] Attachment access registry is invalid')
12
+ return registry.protectedTargets
13
+ .filter((target) => matchesAttachmentAccessSelector(target.targetPartition, partitionCode))
14
+ .map(({ resolverId, targetEntity }) => ({ resolverId, targetEntity }))
15
+ }
16
+
17
+ export function mergeAttachmentAccessRequirements(
18
+ current: unknown,
19
+ required: readonly AttachmentAccessRequirement[],
20
+ ): AttachmentAccessRequirement[] {
21
+ const existing = attachmentAccessRequirementsSchema.parse(current ?? [])
22
+ const union = new Map<string, AttachmentAccessRequirement>()
23
+ for (const requirement of [...existing, ...required]) {
24
+ union.set(JSON.stringify([requirement.resolverId, requirement.targetEntity]), { ...requirement })
25
+ }
26
+ return [...union.values()]
27
+ }
28
+
29
+ export async function syncAttachmentAccessProtection(
30
+ em: EntityManager,
31
+ options: { partitionCode?: string } = {},
32
+ ): Promise<number> {
33
+ const registry = getAttachmentAccessRegistry()
34
+ if (registry.invalid) throw new Error('[internal] Attachment access registry is invalid')
35
+ if (!registry.protectedTargets.length) return 0
36
+ const isolated = em.fork({ clear: true, useContext: false })
37
+ const candidates = await findWithDecryption(
38
+ isolated,
39
+ AttachmentPartition,
40
+ options.partitionCode ? { code: options.partitionCode } : {},
41
+ { orderBy: { id: 'asc' } },
42
+ { tenantId: null, organizationId: null },
43
+ )
44
+ if (options.partitionCode && getDeclaredAttachmentAccessRequirements(options.partitionCode).length && !candidates.length) {
45
+ throw new Error('[internal] Protected attachment partition is not durably configured')
46
+ }
47
+ const pending = candidates.filter((partition) => {
48
+ const required = getDeclaredAttachmentAccessRequirements(partition.code)
49
+ if (!required.length) return false
50
+ const existing = attachmentAccessRequirementsSchema.parse(partition.accessResolverRequirements ?? [])
51
+ return JSON.stringify(mergeAttachmentAccessRequirements(existing, required)) !== JSON.stringify(existing)
52
+ })
53
+ if (!pending.length) return 0
54
+ return isolated.transactional(async (transaction) => {
55
+ let changed = 0
56
+ for (const candidate of pending) {
57
+ const partition = await findOneWithDecryption(transaction, AttachmentPartition, { id: candidate.id }, {
58
+ lockMode: LockMode.PESSIMISTIC_WRITE,
59
+ refresh: true,
60
+ }, { tenantId: null, organizationId: null })
61
+ if (!partition) throw new Error('[internal] Attachment partition disappeared during access protection synchronization')
62
+ const existing = attachmentAccessRequirementsSchema.parse(partition.accessResolverRequirements ?? [])
63
+ const merged = mergeAttachmentAccessRequirements(existing, getDeclaredAttachmentAccessRequirements(partition.code))
64
+ if (JSON.stringify(merged) === JSON.stringify(existing)) continue
65
+ partition.accessResolverRequirements = merged
66
+ changed += 1
67
+ }
68
+ if (changed) await transaction.flush()
69
+ return changed
70
+ })
71
+ }
72
+
73
+ export async function ensureAttachmentPartitionProtection(
74
+ em: EntityManager,
75
+ partition: AttachmentPartition,
76
+ ): Promise<void> {
77
+ const required = getDeclaredAttachmentAccessRequirements(partition.code)
78
+ if (!required.length) return
79
+ const existing = attachmentAccessRequirementsSchema.parse(partition.accessResolverRequirements ?? [])
80
+ const merged = mergeAttachmentAccessRequirements(existing, required)
81
+ if (JSON.stringify(existing) === JSON.stringify(merged)) return
82
+ await syncAttachmentAccessProtection(em, { partitionCode: partition.code })
83
+ }
@@ -0,0 +1,86 @@
1
+ import { ensureAttachmentPartitionProtection } from './access-protection'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
4
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { AttachmentPartition, type Attachment } from '../data/entities'
6
+ import { attachmentAccessRequirementsSchema } from '../data/validators'
7
+ import { getAttachmentAccessRegistry } from './access-registry'
8
+ import { evaluateAttachmentAccess, type AttachmentAccessContext } from './access-runner'
9
+ import type { AttachmentAccessAction, AttachmentAccessRecord } from './access-types'
10
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
11
+ import { attachmentAccessErrorBody, throwAttachmentAccessError } from './access-errors'
12
+ import { readAttachmentMetadata } from './metadata'
13
+
14
+ export function requiresAttachmentAccessScan(partitions: readonly AttachmentPartition[]): boolean {
15
+ const registry = getAttachmentAccessRegistry()
16
+ return registry.invalid || registry.resolvers.length > 0 || registry.protectedTargets.length > 0
17
+ || partitions.some((partition) => {
18
+ const parsed = attachmentAccessRequirementsSchema.safeParse(partition.accessResolverRequirements ?? [])
19
+ return !parsed.success || parsed.data.length > 0
20
+ })
21
+ }
22
+
23
+ export async function assertAttachmentOwnerAccess(input: {
24
+ em: EntityManager
25
+ context: AttachmentAccessContext
26
+ auth: NonNullable<AuthContext>
27
+ attachment: AttachmentAccessRecord
28
+ action: AttachmentAccessAction
29
+ persistProtection?: boolean
30
+ }): Promise<void> {
31
+ const partition = await findOneWithDecryption(input.em, AttachmentPartition, { code: input.attachment.partitionCode }, undefined, {
32
+ tenantId: input.attachment.tenantId, organizationId: input.attachment.organizationId,
33
+ })
34
+ if (!partition) await throwAttachmentAccessError(404)
35
+ if (input.persistProtection) await ensureAttachmentPartitionProtection(input.em, partition!)
36
+ const result = await evaluateAttachmentAccess({ ...input, partition: partition!, requireAuthForPublic: true })
37
+ if (!result.ok) await throwAttachmentAccessError(result.status)
38
+ }
39
+
40
+ export async function scanAuthorizedAttachments(input: {
41
+ auth: NonNullable<AuthContext>
42
+ context: AttachmentAccessContext
43
+ partitions: readonly AttachmentPartition[]
44
+ loadBatch(offset: number, limit: number): Promise<Attachment[]>
45
+ offset?: number
46
+ limit?: number
47
+ collectTags?: boolean
48
+ deadline?: number
49
+ }): Promise<{ records: Attachment[]; total: number; tags: string[] }> {
50
+ const deadline = input.deadline ?? Date.now() + 15_000
51
+ const timeoutBody = await attachmentAccessErrorBody(504)
52
+ let timer: ReturnType<typeof setTimeout> | undefined
53
+ const scan = async () => {
54
+ const partitions = new Map(input.partitions.map((partition) => [partition.code, partition]))
55
+ const records: Attachment[] = []
56
+ const tags = new Set<string>()
57
+ let total = 0
58
+ for (let batchOffset = 0; ; batchOffset += 100) {
59
+ if (Date.now() >= deadline) await throwAttachmentAccessError(504)
60
+ const batch = await input.loadBatch(batchOffset, 100)
61
+ for (const attachment of batch) {
62
+ if (Date.now() >= deadline) await throwAttachmentAccessError(504)
63
+ const partition = partitions.get(attachment.partitionCode)
64
+ if (!partition) continue
65
+ const access = await evaluateAttachmentAccess({
66
+ auth: input.auth, attachment, partition, action: 'metadata', context: input.context,
67
+ requireAuthForPublic: true,
68
+ })
69
+ if (Date.now() >= deadline || (!access.ok && access.status === 504)) await throwAttachmentAccessError(504)
70
+ if (!access.ok) continue
71
+ if (input.collectTags) for (const tag of readAttachmentMetadata(attachment.storageMetadata).tags ?? []) tags.add(tag)
72
+ if (total >= (input.offset ?? 0) && (input.limit === undefined || records.length < input.limit)) records.push(attachment)
73
+ total += 1
74
+ }
75
+ if (batch.length < 100) break
76
+ }
77
+ return { records, total, tags: [...tags].sort((left, right) => left < right ? -1 : left > right ? 1 : 0) }
78
+ }
79
+ try {
80
+ return await Promise.race([scan(), new Promise<never>((_resolve, reject) => {
81
+ timer = setTimeout(() => reject(new CrudHttpError(504, timeoutBody)), Math.max(1, deadline - Date.now()))
82
+ })])
83
+ } finally {
84
+ if (timer) clearTimeout(timer)
85
+ }
86
+ }
@@ -0,0 +1,117 @@
1
+ import { matchesEntity } from '@open-mercato/shared/lib/crud/mutation-guard-registry'
2
+ import { attachmentAccessResolverSchema, protectedAttachmentTargetSchema } from '../data/validators'
3
+ import type {
4
+ AttachmentAccessAction,
5
+ AttachmentAccessRecord,
6
+ AttachmentAccessResolver,
7
+ AttachmentAccessResolverEntry,
8
+ AttachmentAccessTarget,
9
+ ProtectedAttachmentTarget,
10
+ } from './access-types'
11
+
12
+ const GLOBAL_KEY = '__openMercatoAttachmentAccessRegistry__'
13
+
14
+ type RegisteredResolver = AttachmentAccessResolver & {
15
+ moduleOrder: number
16
+ declarationOrder: number
17
+ }
18
+
19
+ type Registry = {
20
+ invalid: boolean
21
+ resolvers: readonly RegisteredResolver[]
22
+ protectedTargets: readonly ProtectedAttachmentTarget[]
23
+ }
24
+
25
+ const emptyRegistry: Registry = Object.freeze({ invalid: false, resolvers: [], protectedTargets: [] })
26
+
27
+ export function getAttachmentAccessRegistry(): Registry {
28
+ return (globalThis as Record<string, unknown>)[GLOBAL_KEY] as Registry | undefined ?? emptyRegistry
29
+ }
30
+
31
+ export function registerAttachmentAccessResolvers(entries: readonly AttachmentAccessResolverEntry[]): void {
32
+ const globalState = globalThis as Record<string, unknown>
33
+ globalState[GLOBAL_KEY] = { invalid: true, resolvers: [], protectedTargets: [] } satisfies Registry
34
+ const resolvers: RegisteredResolver[] = []
35
+ const protectedTargets: ProtectedAttachmentTarget[] = []
36
+ const ids = new Set<string>()
37
+ for (const [moduleOrder, entry] of entries.entries()) {
38
+ if (typeof entry.moduleId !== 'string' || !entry.moduleId || !Array.isArray(entry.resolvers)) {
39
+ throw new Error('[internal] Invalid attachment access registry entry')
40
+ }
41
+ for (const [declarationOrder, raw] of entry.resolvers.entries()) {
42
+ const resolver = attachmentAccessResolverSchema.parse(raw)
43
+ if (!resolver.id.startsWith(`${entry.moduleId}.`) || ids.has(resolver.id)) {
44
+ throw new Error('[internal] Attachment access resolver IDs must be unique and owned by their module')
45
+ }
46
+ ids.add(resolver.id)
47
+ resolvers.push(Object.freeze({
48
+ ...resolver,
49
+ actions: resolver.actions ? Object.freeze([...resolver.actions]) : undefined,
50
+ moduleOrder,
51
+ declarationOrder,
52
+ }))
53
+ }
54
+ for (const raw of entry.protectedTargets ?? []) {
55
+ const target = protectedAttachmentTargetSchema.parse(raw)
56
+ if (!target.resolverId.startsWith(`${entry.moduleId}.`)) {
57
+ throw new Error('[internal] Attachment access requirements must name their module resolver')
58
+ }
59
+ protectedTargets.push(Object.freeze(target))
60
+ }
61
+ }
62
+ resolvers.sort((left, right) => (
63
+ (left.priority ?? 50) - (right.priority ?? 50)
64
+ || left.moduleOrder - right.moduleOrder
65
+ || left.declarationOrder - right.declarationOrder
66
+ ))
67
+ globalState[GLOBAL_KEY] = Object.freeze({
68
+ invalid: false,
69
+ resolvers: Object.freeze(resolvers),
70
+ protectedTargets: Object.freeze(protectedTargets),
71
+ }) satisfies Registry
72
+ }
73
+
74
+ export function matchesAttachmentAccessSelector(selector: string, value: string): boolean {
75
+ return matchesEntity(selector, value)
76
+ }
77
+
78
+ export function matchesAttachmentAccessTargets(selector: string, targets: readonly AttachmentAccessTarget[]): boolean {
79
+ return selector === '*' || targets.some((target) => matchesAttachmentAccessSelector(selector, target.entityId))
80
+ }
81
+
82
+ export function matchesAttachmentAccessResolver(
83
+ resolver: AttachmentAccessResolver,
84
+ partitionCode: string,
85
+ targets: readonly AttachmentAccessTarget[],
86
+ action: AttachmentAccessAction,
87
+ ): boolean {
88
+ return matchesAttachmentAccessSelector(resolver.targetPartition, partitionCode)
89
+ && matchesAttachmentAccessTargets(resolver.targetEntity ?? '*', targets)
90
+ && (!resolver.actions || resolver.actions.includes(action))
91
+ }
92
+
93
+ function isRecord(value: unknown): value is Record<string, unknown> {
94
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
95
+ }
96
+
97
+ export function collectAttachmentAccessTargets(attachment: AttachmentAccessRecord): readonly AttachmentAccessTarget[] {
98
+ const targets: AttachmentAccessTarget[] = []
99
+ function append(entityId: unknown, recordId: unknown, origin: AttachmentAccessTarget['origin']) {
100
+ if (typeof entityId !== 'string' || !entityId.trim()) return
101
+ const target = Object.freeze({
102
+ entityId: entityId.trim(),
103
+ recordId: typeof recordId === 'string' ? recordId.trim() : '',
104
+ origin,
105
+ })
106
+ if (!targets.some((candidate) => candidate.entityId === target.entityId && candidate.recordId === target.recordId)) {
107
+ targets.push(target)
108
+ }
109
+ }
110
+ append(attachment.entityId, attachment.recordId, 'primary')
111
+ const assignments = isRecord(attachment.storageMetadata) ? attachment.storageMetadata.assignments : undefined
112
+ const entries = Array.isArray(assignments) ? assignments : isRecord(assignments) ? [assignments] : []
113
+ for (const assignment of entries) {
114
+ if (isRecord(assignment)) append(assignment.type, assignment.id, 'assignment')
115
+ }
116
+ return Object.freeze(targets)
117
+ }
@@ -0,0 +1,183 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
3
+ import { createLogger } from '@open-mercato/shared/lib/logger'
4
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
5
+ import type { Attachment, AttachmentPartition } from '../data/entities'
6
+ import { attachmentAccessDecisionSchema, attachmentAccessRequirementsSchema, attachmentAccessSubjectFeaturesSchema } from '../data/validators'
7
+ import { checkAttachmentAccess } from './access'
8
+ import {
9
+ collectAttachmentAccessTargets,
10
+ getAttachmentAccessRegistry,
11
+ matchesAttachmentAccessResolver,
12
+ matchesAttachmentAccessSelector,
13
+ matchesAttachmentAccessTargets,
14
+ } from './access-registry'
15
+ import type {
16
+ AttachmentAccessAction, AttachmentAccessDecision, AttachmentAccessPartition,
17
+ AttachmentAccessRecord, AttachmentAccessSubject,
18
+ } from './access-types'
19
+
20
+ const logger = createLogger('attachments').child({ component: 'access' })
21
+ const DEFAULT_TIMEOUT_MS = 1500
22
+
23
+ type RbacProjectionService = {
24
+ loadAcl(subject: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<{
25
+ isSuperAdmin: boolean; organizations: string[] | null
26
+ }>
27
+ getEffectiveFeatures(subject: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<string[]>
28
+ }
29
+
30
+ export type AttachmentAccessContext = {
31
+ container: AwilixContainer
32
+ subjects: Map<string, Promise<AttachmentAccessSubject>>
33
+ resolverCaches: Map<string, Map<string, unknown>>
34
+ }
35
+
36
+ export function createAttachmentAccessContext(container: AwilixContainer): AttachmentAccessContext {
37
+ return { container, subjects: new Map(), resolverCaches: new Map() }
38
+ }
39
+
40
+ export type AttachmentAccessResult = AttachmentAccessDecision & { protected: boolean }
41
+
42
+ class AccessTimeout extends Error {}
43
+
44
+ async function withinBudget<Result>(work: () => Promise<Result>, timeoutMs: number, controller: AbortController): Promise<Result> {
45
+ let timer: ReturnType<typeof setTimeout> | undefined
46
+ try {
47
+ return await Promise.race([
48
+ Promise.resolve().then(work),
49
+ new Promise<never>((_resolve, reject) => {
50
+ timer = setTimeout(() => {
51
+ controller.abort()
52
+ reject(new AccessTimeout('[internal] Attachment access evaluation timed out'))
53
+ }, timeoutMs)
54
+ }),
55
+ ])
56
+ } finally {
57
+ if (timer) clearTimeout(timer)
58
+ }
59
+ }
60
+
61
+ async function projectSubject(
62
+ context: AttachmentAccessContext,
63
+ auth: NonNullable<AuthContext>,
64
+ attachment: AttachmentAccessRecord,
65
+ ): Promise<AttachmentAccessSubject> {
66
+ const scope = {
67
+ tenantId: attachment.tenantId ?? auth.tenantId ?? null,
68
+ organizationId: attachment.organizationId ?? auth.orgId ?? null,
69
+ }
70
+ const key = JSON.stringify([auth.sub, scope.tenantId, scope.organizationId])
71
+ let pending = context.subjects.get(key)
72
+ if (!pending) {
73
+ pending = (async () => {
74
+ const rbac = context.container.resolve<RbacProjectionService>('rbacService')
75
+ const acl = await rbac.loadAcl(auth.sub, scope)
76
+ const userFeatures = attachmentAccessSubjectFeaturesSchema.parse(await rbac.getEffectiveFeatures(auth.sub, scope))
77
+ if (typeof acl.isSuperAdmin !== 'boolean') {
78
+ throw new Error('[internal] Invalid attachment subject projection')
79
+ }
80
+ if (!acl.isSuperAdmin && acl.organizations && scope.organizationId
81
+ && !acl.organizations.includes(scope.organizationId) && !acl.organizations.includes('__all__')) {
82
+ throw new Error('[internal] Attachment subject is outside its current organization grants')
83
+ }
84
+ return Object.freeze({
85
+ auth: Object.freeze({
86
+ sub: auth.sub, tenantId: scope.tenantId, orgId: scope.organizationId,
87
+ isApiKey: auth.isApiKey === true, userId: auth.userId, keyId: auth.keyId,
88
+ isSuperAdmin: acl.isSuperAdmin,
89
+ }),
90
+ ...scope,
91
+ userFeatures: Object.freeze([...userFeatures]),
92
+ isSuperAdmin: acl.isSuperAdmin,
93
+ })
94
+ })()
95
+ context.subjects.set(key, pending)
96
+ }
97
+ return pending
98
+ }
99
+
100
+ export async function evaluateAttachmentAccess(input: {
101
+ auth: AuthContext | undefined
102
+ attachment: AttachmentAccessRecord
103
+ partition: AttachmentAccessPartition
104
+ action: AttachmentAccessAction
105
+ context?: AttachmentAccessContext | null
106
+ requireAuthForPublic?: boolean
107
+ }): Promise<AttachmentAccessResult> {
108
+ const { auth, attachment, partition, action, context } = input
109
+ const baseline = checkAttachmentAccess(auth, attachment as Attachment, partition as AttachmentPartition, { requireAuthForPublic: input.requireAuthForPublic })
110
+ if (!baseline.ok) return { ok: false, status: baseline.status === 401 ? 401 : 403, reason: 'scope', protected: false }
111
+ const deny = (status: 401 | 403 | 404 | 504, reason: string, resolverId?: string): AttachmentAccessResult => {
112
+ logger.info('Attachment owner access denied', { resolverId, action, attachmentId: attachment.id, reason })
113
+ return { ok: false, status, reason, protected: true }
114
+ }
115
+ const registry = getAttachmentAccessRegistry()
116
+ if (registry.invalid) return deny(403, 'invalid_registry')
117
+ const parsedRequirements = attachmentAccessRequirementsSchema.safeParse(partition.accessResolverRequirements ?? [])
118
+ if (!parsedRequirements.success) return deny(403, 'invalid_requirements')
119
+ const targets = collectAttachmentAccessTargets(attachment)
120
+ const requirements = [...parsedRequirements.data, ...registry.protectedTargets.filter((target) => (
121
+ matchesAttachmentAccessSelector(target.targetPartition, partition.code)
122
+ ))].filter((requirement) => matchesAttachmentAccessTargets(requirement.targetEntity, targets))
123
+ const resolvers = registry.resolvers.filter((resolver) => matchesAttachmentAccessResolver(resolver, partition.code, targets, action))
124
+ if (!requirements.length && !resolvers.length) return { ok: true, protected: false }
125
+ if (!auth) return deny(401, 'authentication_required')
126
+ for (const requirement of requirements) {
127
+ const requiredTargets = targets.filter((target) => matchesAttachmentAccessSelector(requirement.targetEntity, target.entityId))
128
+ const provider = resolvers.find((resolver) => resolver.id === requirement.resolverId)
129
+ if (!provider || (requiredTargets.length
130
+ ? requiredTargets.some((target) => !matchesAttachmentAccessSelector(provider.targetEntity ?? '*', target.entityId))
131
+ : (provider.targetEntity ?? '*') !== '*')) return deny(403, 'required_resolver_missing', requirement.resolverId)
132
+ if (targets.some((target) => matchesAttachmentAccessSelector(requirement.targetEntity, target.entityId) && !target.recordId)) {
133
+ return deny(403, 'invalid_owner', requirement.resolverId)
134
+ }
135
+ }
136
+ if (!context) return deny(403, 'policy_context_missing')
137
+ let subject: AttachmentAccessSubject
138
+ try {
139
+ subject = await withinBudget(() => projectSubject(context, auth, attachment), DEFAULT_TIMEOUT_MS, new AbortController())
140
+ } catch (error) {
141
+ getTelemetryRuntime()?.reportError(new Error('[internal] Attachment subject projection failed'), {
142
+ module: 'attachments', code: 'attachments.subject_projection_failed', attributes: { attachmentId: attachment.id, action },
143
+ })
144
+ return deny(error instanceof AccessTimeout ? 504 : 403, 'subject_projection_failed')
145
+ }
146
+ const liveBaseline = checkAttachmentAccess({ ...auth, roles: [], isSuperAdmin: subject.isSuperAdmin }, attachment as Attachment, partition as AttachmentPartition, { requireAuthForPublic: input.requireAuthForPublic })
147
+ if (!liveBaseline.ok) return deny(403, 'current_scope')
148
+ for (const resolver of resolvers) {
149
+ const controller = new AbortController()
150
+ let cache = context.resolverCaches.get(resolver.id)
151
+ if (!cache) {
152
+ cache = new Map()
153
+ context.resolverCaches.set(resolver.id, cache)
154
+ }
155
+ try {
156
+ const raw = await withinBudget(() => resolver.resolve(Object.freeze({
157
+ action, subject,
158
+ attachment: Object.freeze({
159
+ id: attachment.id, entityId: attachment.entityId, recordId: attachment.recordId,
160
+ partitionCode: attachment.partitionCode, tenantId: attachment.tenantId,
161
+ organizationId: attachment.organizationId, fileName: attachment.fileName, mimeType: attachment.mimeType,
162
+ }),
163
+ partition: Object.freeze({
164
+ code: partition.code, isPublic: partition.isPublic,
165
+ tenantId: partition.tenantId, organizationId: partition.organizationId,
166
+ }),
167
+ targets, container: context.container, cache, signal: controller.signal,
168
+ })), resolver.timeoutMs ?? DEFAULT_TIMEOUT_MS, controller)
169
+ const decision = attachmentAccessDecisionSchema.parse(raw)
170
+ if (!decision.ok) return deny(decision.status, decision.reason, resolver.id)
171
+ } catch (error) {
172
+ controller.abort()
173
+ getTelemetryRuntime()?.reportError(new Error(error instanceof AccessTimeout
174
+ ? '[internal] Attachment resolver exceeded its time budget'
175
+ : '[internal] Attachment resolver failed or returned an invalid decision'), {
176
+ module: 'attachments', code: 'attachments.resolver_failed',
177
+ attributes: { resolverId: resolver.id, attachmentId: attachment.id, action },
178
+ })
179
+ return deny(error instanceof AccessTimeout ? 504 : 403, error instanceof AccessTimeout ? 'resolver_timeout' : 'resolver_failed', resolver.id)
180
+ }
181
+ }
182
+ return { ok: true, protected: true }
183
+ }
@@ -0,0 +1,78 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
3
+
4
+ export type AttachmentAccessAction = 'read' | 'render' | 'metadata' | 'reassign' | 'delete' | 'export'
5
+
6
+ export type AttachmentAccessTarget = Readonly<{
7
+ entityId: string
8
+ recordId: string
9
+ origin: 'primary' | 'assignment'
10
+ }>
11
+
12
+ export type AttachmentAccessRequirement = Readonly<{
13
+ resolverId: string
14
+ targetEntity: string
15
+ }>
16
+
17
+ export type ProtectedAttachmentTarget = AttachmentAccessRequirement & Readonly<{
18
+ targetPartition: string
19
+ }>
20
+
21
+ export type AttachmentAccessRecord = {
22
+ id: string
23
+ entityId: string
24
+ recordId: string
25
+ partitionCode: string
26
+ tenantId?: string | null
27
+ organizationId?: string | null
28
+ fileName: string
29
+ mimeType: string
30
+ storageMetadata?: unknown
31
+ }
32
+
33
+ export type AttachmentAccessPartition = {
34
+ code: string
35
+ isPublic: boolean
36
+ tenantId?: string | null
37
+ organizationId?: string | null
38
+ accessResolverRequirements?: unknown
39
+ }
40
+
41
+ export type AttachmentAccessSubject = Readonly<{
42
+ auth: Readonly<NonNullable<AuthContext>>
43
+ userFeatures: readonly string[]
44
+ tenantId: string | null
45
+ organizationId: string | null
46
+ isSuperAdmin: boolean
47
+ }>
48
+
49
+ export type AttachmentAccessInput = Readonly<{
50
+ action: AttachmentAccessAction
51
+ subject: AttachmentAccessSubject
52
+ attachment: Readonly<Omit<AttachmentAccessRecord, 'storageMetadata'>>
53
+ partition: Readonly<Omit<AttachmentAccessPartition, 'accessResolverRequirements'>>
54
+ targets: readonly AttachmentAccessTarget[]
55
+ container: AwilixContainer
56
+ cache: Map<string, unknown>
57
+ signal: AbortSignal
58
+ }>
59
+
60
+ export type AttachmentAccessDecision =
61
+ | { ok: true }
62
+ | { ok: false; status: 401 | 403 | 404 | 504; reason: string }
63
+
64
+ export type AttachmentAccessResolver = Readonly<{
65
+ id: string
66
+ targetPartition: string
67
+ targetEntity?: string
68
+ actions?: readonly AttachmentAccessAction[]
69
+ priority?: number
70
+ timeoutMs?: number
71
+ resolve(input: AttachmentAccessInput): Promise<AttachmentAccessDecision>
72
+ }>
73
+
74
+ export type AttachmentAccessResolverEntry = {
75
+ moduleId: string
76
+ resolvers: readonly unknown[]
77
+ protectedTargets?: readonly unknown[]
78
+ }
@@ -1,3 +1,5 @@
1
+ import { evaluateAttachmentAccess, type AttachmentAccessContext } from './access-runner'
2
+ import { throwAttachmentAccessError } from './access-errors'
1
3
  import type { EntityManager } from '@mikro-orm/postgresql'
2
4
  import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
3
5
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
@@ -249,6 +251,7 @@ export class DefaultAttachmentService implements AttachmentService {
249
251
  private readonly em: EntityManager,
250
252
  private readonly storageDriverFactory: StorageDriverFactory,
251
253
  private readonly resolveScopedUploadService?: (() => ScopedAttachmentUploadService | null) | null,
254
+ private readonly resolveAccessContext?: (() => AttachmentAccessContext) | null,
252
255
  ) {}
253
256
 
254
257
  validateUpload(input: {
@@ -368,10 +371,11 @@ export class DefaultAttachmentService implements AttachmentService {
368
371
  )
369
372
  if (!partition) throw new CrudHttpError(500, { error: 'Attachment partition is not configured' })
370
373
 
371
- const access = checkAttachmentAccess(input.auth, attachment, partition, { requireAuthForPublic: true })
372
- if (!access.ok) {
373
- throw new CrudHttpError(access.status, { error: access.status === 401 ? 'Unauthorized' : 'Forbidden' })
374
- }
374
+ const access = await evaluateAttachmentAccess({
375
+ auth: input.auth, attachment, partition, action: 'read',
376
+ context: this.resolveAccessContext?.(), requireAuthForPublic: true,
377
+ })
378
+ if (!access.ok) await throwAttachmentAccessError(access.status)
375
379
  if (!partitionMatchesScope(partition, input.auth.tenantId, input.auth.orgId)) {
376
380
  throw new CrudHttpError(403, { error: 'Attachment partition is not accessible for this scope' })
377
381
  }
@@ -8,6 +8,7 @@ import { buildAttachmentFileUrl } from './imageUrls'
8
8
  import { mergeAttachmentMetadata, upsertAssignment } from './metadata'
9
9
  import { assertAttachmentScopeInvariant } from './access'
10
10
  import { attachmentCrudEvents, attachmentCrudIndexer } from './crud'
11
+ import { ensureAttachmentPartitionProtection } from './access-protection'
11
12
 
12
13
  /** DataEngine handle whose exact type is derived from `emitCrudSideEffects` (no `any`). */
13
14
  type CrudDataEngine = Parameters<typeof emitCrudSideEffects>[0]['dataEngine']
@@ -58,6 +59,8 @@ export async function createAttachmentFromBuffer(input: CreateAttachmentFromBuff
58
59
  }
59
60
  if (!partition) throw new Error(`[internal] attachment partition "${code}" is not available`)
60
61
 
62
+ await ensureAttachmentPartitionProtection(em, partition)
63
+
61
64
  const driver = await new StorageDriverFactory(em).resolveForPartition(partition.code, {
62
65
  tenantId: input.tenantId,
63
66
  organizationId: input.organizationId,
@@ -3,6 +3,7 @@ import { AttachmentPartition } from '../data/entities'
3
3
  import { resolveDefaultAttachmentOcrEnabled } from './ocrConfig'
4
4
  import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
5
5
  import { E } from '#generated/entities.ids.generated'
6
+ import { getDeclaredAttachmentAccessRequirements } from './access-protection'
6
7
 
7
8
  export type AttachmentPartitionSeed = {
8
9
  code: string
@@ -43,7 +44,6 @@ export async function ensureDefaultPartitions(em: EntityManager): Promise<void>
43
44
  const existing = await repo.findAll({ fields: ['code'] })
44
45
  const existingCodes = new Set(existing.map((entry) => entry.code))
45
46
  const pending = DEFAULT_ATTACHMENT_PARTITIONS.filter((seed) => !existingCodes.has(seed.code))
46
- if (!pending.length) return
47
47
  for (const seed of pending) {
48
48
  const record = repo.create({
49
49
  code: seed.code,
@@ -51,11 +51,12 @@ export async function ensureDefaultPartitions(em: EntityManager): Promise<void>
51
51
  description: seed.description ?? null,
52
52
  storageDriver: 'local',
53
53
  isPublic: seed.isPublic ?? false,
54
+ accessResolverRequirements: getDeclaredAttachmentAccessRequirements(seed.code),
54
55
  requiresOcr: resolveDefaultAttachmentOcrEnabled(),
55
56
  })
56
57
  em.persist(record)
57
58
  }
58
- await em.flush()
59
+ if (pending.length) await em.flush()
59
60
  }
60
61
 
61
62
  export function sanitizePartitionCode(input: string): string {
@@ -8,6 +8,7 @@ import { Attachment, AttachmentPartition } from '../data/entities'
8
8
  import { attachmentCrudEvents, attachmentCrudIndexer } from './crud'
9
9
  import type { StorageDriverFactory } from './drivers'
10
10
  import { buildAttachmentFileUrl } from './imageUrls'
11
+ import { ensureAttachmentPartitionProtection } from './access-protection'
11
12
  import { ensureDefaultPartitions, resolveDefaultPartitionCode } from './partitions'
12
13
  import { extractAttachmentContent } from './textExtraction'
13
14
  import { requestOcrProcessing } from './ocrQueue'
@@ -142,6 +143,7 @@ export class ScopedAttachmentUploadService {
142
143
  throw new ScopedAttachmentUploadError('partition_unavailable', 403)
143
144
  }
144
145
 
146
+ await ensureAttachmentPartitionProtection(em, partition)
145
147
  const driver = await storageDriverFactory.resolveForPartition(partition.code, {
146
148
  tenantId: input.tenantId,
147
149
  organizationId: input.organizationId,