@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
@@ -1,3 +1,10 @@
1
+ import { LockMode } from '@mikro-orm/core'
2
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
+ import { prepareAttachmentMutation } from '../lib/access-mutation'
4
+ import { throwAttachmentAccessError } from '../lib/access-errors'
5
+ import { createAttachmentAccessContext } from '../lib/access-runner'
6
+ import { assertAttachmentOwnerAccess, requiresAttachmentAccessScan, scanAuthorizedAttachments } from '../lib/access-query'
7
+ import { withAttachmentAccessErrors } from '../lib/access-errors'
1
8
  import { NextResponse } from 'next/server'
2
9
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
10
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
@@ -5,6 +12,7 @@ import { z } from 'zod'
5
12
  import { sql } from 'kysely'
6
13
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
7
14
  import { buildAttachmentFileUrl, buildAttachmentImageUrl, slugifyAttachmentFileName } from '../lib/imageUrls'
15
+ import { ensureAttachmentPartitionProtection } from '../lib/access-protection'
8
16
  import { ensureDefaultPartitions, resolveDefaultPartitionCode, sanitizePartitionCode } from '../lib/partitions'
9
17
  import { Attachment, AttachmentPartition } from '../data/entities'
10
18
  import { extractAttachmentContent } from '../lib/textExtraction'
@@ -12,7 +20,7 @@ import { requestOcrProcessing } from '../lib/ocrQueue'
12
20
  import { StorageDriverFactory } from '../lib/drivers'
13
21
  import { OcrService, shouldUseLlmOcr } from '../lib/ocrService'
14
22
  import { clearAttachmentThumbnailCache } from '../lib/thumbnailCache'
15
- import { removeAttachmentRecord } from '../lib/storageReferences'
23
+ import { lockAttachmentStorageReferences, resolveAttachmentStorageRelease } from '../lib/storageReferences'
16
24
  import { assertAttachmentScopeInvariant } from '../lib/access'
17
25
  import { resolveAttachmentRequestScope } from '../lib/requestScope'
18
26
  import {
@@ -193,7 +201,7 @@ function parseFormAssignments(value: FormDataEntryValue | null): AttachmentAssig
193
201
  }
194
202
  }
195
203
 
196
- export async function GET(req: Request) {
204
+ async function listRecordAttachments(req: Request) {
197
205
  const auth = await getAuthFromRequest(req)
198
206
  if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
199
207
  const url = new URL(req.url)
@@ -217,29 +225,29 @@ export async function GET(req: Request) {
217
225
  if (orgId) filter.organizationId = orgId
218
226
  const orderBy: Record<string, 'ASC' | 'DESC'> = { createdAt: 'DESC', id: 'DESC' }
219
227
  const usePaging = typeof page === 'number' && typeof pageSize === 'number'
220
- const total = usePaging ? await em.count(Attachment, filter) : null
221
228
  const currentPage = usePaging ? Math.max(1, page) : null
222
229
  const currentPageSize = usePaging ? pageSize : null
223
- const totalPages = usePaging && total !== null ? Math.max(1, Math.ceil(total / currentPageSize!)) : null
224
230
  const pageOffset = usePaging ? (currentPage! - 1) * currentPageSize! : undefined
225
- const items = await findWithDecryption(
226
- em,
227
- Attachment,
228
- filter,
229
- {
230
- orderBy,
231
- ...(usePaging
232
- ? {
233
- limit: currentPageSize!,
234
- offset: pageOffset,
235
- }
236
- : { limit: ATTACHMENT_LIST_MAX_PAGE_SIZE }),
237
- },
238
- {
239
- tenantId: auth.tenantId ?? null,
240
- organizationId: orgId ?? null,
241
- },
242
- )
231
+ const partitions = await em.find(AttachmentPartition, {})
232
+ const protectedScan = requiresAttachmentAccessScan(partitions)
233
+ const scope = { tenantId: auth.tenantId ?? null, organizationId: orgId ?? null }
234
+ let items: Attachment[]
235
+ let total: number | null
236
+ if (protectedScan) {
237
+ const scanned = await scanAuthorizedAttachments({
238
+ auth: { ...auth, orgId }, context: createAttachmentAccessContext(container), partitions,
239
+ offset: pageOffset, limit: currentPageSize ?? ATTACHMENT_LIST_MAX_PAGE_SIZE,
240
+ loadBatch: (offset, limit) => findWithDecryption(em, Attachment, filter, { orderBy, offset, limit }, scope),
241
+ })
242
+ items = scanned.records
243
+ total = usePaging ? scanned.total : null
244
+ } else {
245
+ total = usePaging ? await em.count(Attachment, filter) : null
246
+ items = await findWithDecryption(em, Attachment, filter, {
247
+ orderBy, ...(usePaging ? { limit: currentPageSize!, offset: pageOffset } : { limit: ATTACHMENT_LIST_MAX_PAGE_SIZE }),
248
+ }, scope)
249
+ }
250
+ const totalPages = usePaging && total !== null ? Math.max(1, Math.ceil(total / currentPageSize!)) : null
243
251
  return NextResponse.json({
244
252
  items: items.map((a: any) => {
245
253
  const metadata = readAttachmentMetadata(a.storageMetadata)
@@ -269,9 +277,11 @@ export async function GET(req: Request) {
269
277
  totalPages,
270
278
  }
271
279
  : {}),
272
- })
280
+ }, { headers: protectedScan ? { 'Cache-Control': 'private, no-store' } : undefined })
273
281
  }
274
282
 
283
+ export const GET = withAttachmentAccessErrors(listRecordAttachments)
284
+
275
285
  export async function POST(req: Request) {
276
286
  const { t } = await resolveTranslations()
277
287
  const auth = await getAuthFromRequest(req)
@@ -458,6 +468,7 @@ export async function POST(req: Request) {
458
468
  if (!partition) {
459
469
  return NextResponse.json({ error: 'Storage partition is not configured.' }, { status: 400 })
460
470
  }
471
+ await ensureAttachmentPartitionProtection(em, partition)
461
472
  const requestedPublicOverride =
462
473
  typeof partitionOverride === 'string' &&
463
474
  partitionOverride.length > 0 &&
@@ -703,7 +714,7 @@ async function readTenantAttachmentUsageBytes(em: EntityManager, tenantId: strin
703
714
  }
704
715
  }
705
716
 
706
- export async function DELETE(req: Request) {
717
+ async function deleteAttachment(req: Request) {
707
718
  const auth = await getAuthFromRequest(req)
708
719
  if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
709
720
  const url = new URL(req.url)
@@ -724,20 +735,34 @@ export async function DELETE(req: Request) {
724
735
  const orgId = deleteScope.organizationId
725
736
  const deleteFilter: Record<string, unknown> = { id, tenantId: auth.tenantId! }
726
737
  if (orgId) deleteFilter.organizationId = orgId
727
- const record = await em.findOne(Attachment, deleteFilter)
728
- if (!record) return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
729
- const removal = await removeAttachmentRecord(em, record)
730
- if (!removal.removed) return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
738
+ const scopedAuth = { ...auth, orgId }
739
+ const outcome = await em.transactional(async (tx) => {
740
+ const candidate = await findOneWithDecryption(tx, Attachment, deleteFilter, undefined, {
741
+ tenantId: auth.tenantId, organizationId: orgId,
742
+ })
743
+ if (!candidate) return throwAttachmentAccessError(404)
744
+ const referenceIds = await lockAttachmentStorageReferences(tx, candidate)
745
+ const removal = resolveAttachmentStorageRelease(referenceIds, candidate.id)
746
+ if (!removal.removed) return throwAttachmentAccessError(404)
747
+ const record = await findOneWithDecryption(tx, Attachment, deleteFilter, {
748
+ lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true,
749
+ }, { tenantId: auth.tenantId, organizationId: orgId })
750
+ if (!record) return throwAttachmentAccessError(404)
751
+ const guard = await prepareAttachmentMutation({ container, req, auth: scopedAuth, recordId: record.id, operation: 'delete' })
752
+ await assertAttachmentOwnerAccess({ em: tx, context: createAttachmentAccessContext(container), auth: scopedAuth, attachment: record, action: 'delete' })
753
+ const driver = record.storagePath ? await storageDriverFactory.resolveForPartition(record.partitionCode, {
754
+ tenantId: record.tenantId ?? auth.tenantId!, organizationId: record.organizationId ?? orgId ?? '',
755
+ }) : null
756
+ tx.remove(record)
757
+ await tx.flush()
758
+ return { record, driver, guard, releaseStorage: removal.releaseStorage }
759
+ })
760
+ const { record } = outcome
731
761
  await clearAttachmentThumbnailCache(record.partitionCode, record.id).catch((error) => {
732
762
  logger.error('Failed to cleanup cached thumbnails', { err: error })
733
763
  })
734
- if (record.storagePath && removal.releaseStorage) {
735
- const delDriver = await storageDriverFactory.resolveForPartition(record.partitionCode, {
736
- tenantId: record.tenantId ?? auth.tenantId!,
737
- organizationId: record.organizationId ?? orgId ?? '',
738
- })
739
- await delDriver.delete(record.partitionCode, record.storagePath)
740
- }
764
+ if (outcome.driver && outcome.releaseStorage) await outcome.driver.delete(record.partitionCode, record.storagePath)
765
+ await outcome.guard.runAfterSuccess()
741
766
  if (dataEngine) {
742
767
  await emitCrudSideEffects({
743
768
  dataEngine,
@@ -756,6 +781,8 @@ export async function DELETE(req: Request) {
756
781
  return NextResponse.json({ ok: true })
757
782
  }
758
783
 
784
+ export const DELETE = withAttachmentAccessErrors(deleteAttachment)
785
+
759
786
  export const openApi: OpenApiRouteDoc = {
760
787
  summary: 'Manage entity attachments',
761
788
  description: 'Upload and list attachments associated with module entities and records.',
@@ -770,6 +797,9 @@ export const openApi: OpenApiRouteDoc = {
770
797
  errors: [
771
798
  { status: 400, description: 'Missing entity or record identifiers', schema: errorSchema },
772
799
  { status: 401, description: 'Unauthorized', schema: errorSchema },
800
+ { status: 403, description: 'Owner policy denies access', schema: errorSchema },
801
+ { status: 404, description: 'Attachment or owner is unavailable', schema: errorSchema },
802
+ { status: 504, description: 'Owner authorization timed out', schema: errorSchema },
773
803
  ],
774
804
  },
775
805
  POST: {
@@ -799,6 +829,9 @@ export const openApi: OpenApiRouteDoc = {
799
829
  errors: [
800
830
  { status: 400, description: 'Missing attachment identifier', schema: errorSchema },
801
831
  { status: 401, description: 'Unauthorized', schema: errorSchema },
832
+ { status: 403, description: 'Owner policy denies access', schema: errorSchema },
833
+ { status: 404, description: 'Attachment or owner is unavailable', schema: errorSchema },
834
+ { status: 504, description: 'Owner authorization timed out', schema: errorSchema },
802
835
  ],
803
836
  },
804
837
  },
@@ -1,3 +1,12 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { LockMode } from '@mikro-orm/core'
3
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import { Attachment } from '../../data/entities'
5
+ import { createAttachmentAccessContext } from '../../lib/access-runner'
6
+ import { assertAttachmentOwnerAccess } from '../../lib/access-query'
7
+ import { withAttachmentAccessErrors, throwAttachmentAccessError } from '../../lib/access-errors'
8
+ import { prepareAttachmentMutation } from '../../lib/access-mutation'
9
+ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
1
10
  import { NextResponse } from 'next/server'
2
11
  import { z } from 'zod'
3
12
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
@@ -22,60 +31,72 @@ export const metadata = {
22
31
  POST: { requireAuth: true, requireFeatures: ['attachments.manage'] },
23
32
  }
24
33
 
25
- export async function POST(req: Request) {
34
+ async function transferAttachments(req: Request) {
26
35
  const auth = await getAuthFromRequest(req)
27
36
  if (!auth || !auth.tenantId || !auth.orgId) {
28
37
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
29
38
  }
30
- const json = await req.json().catch(() => null)
39
+ const json = await readJsonSafe(req, null)
31
40
  const parsed = transferSchema.safeParse(json)
32
41
  if (!parsed.success) {
33
42
  return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
34
43
  }
35
- const { attachmentIds, entityId, fromRecordId, toRecordId } = parsed.data
36
- const { resolve } = await createRequestContainer()
37
- const em = resolve('em') as any
38
- let AttachmentEntity: any
39
- try {
40
- const mod = await import('@open-mercato/core/modules/attachments/data/entities')
41
- AttachmentEntity = mod.Attachment
42
- } catch {
43
- return NextResponse.json({ error: 'Attachment model missing' }, { status: 500 })
44
- }
44
+ const container = await createRequestContainer()
45
+ const em = container.resolve<EntityManager>('em')
46
+ const guard = await prepareAttachmentMutation({ container, req, auth, operation: 'update', payload: parsed.data })
47
+ const effective = transferSchema.safeParse(guard.modifiedPayload ?? parsed.data)
48
+ if (!effective.success) return throwAttachmentAccessError(403)
49
+ const { attachmentIds, entityId, fromRecordId } = effective.data
45
50
  const filters: Record<string, unknown> = {
46
- id: { $in: attachmentIds },
47
- entityId,
48
- tenantId: auth.tenantId,
49
- organizationId: auth.orgId,
50
- }
51
- if (fromRecordId) {
52
- filters.recordId = fromRecordId
53
- }
54
- const records = await em.find(AttachmentEntity, filters)
55
- if (!records.length) {
56
- return NextResponse.json({ error: 'Attachments not found' }, { status: 404 })
51
+ id: { $in: [...new Set(attachmentIds)].sort((left, right) => left < right ? -1 : left > right ? 1 : 0) }, entityId,
52
+ tenantId: auth.tenantId, organizationId: auth.orgId,
53
+ ...(fromRecordId ? { recordId: fromRecordId } : {}),
57
54
  }
58
- for (const record of records) {
59
- const previousRecordId = record.recordId
60
- record.recordId = toRecordId
61
- const metadata = readAttachmentMetadata(record.storageMetadata)
62
- const nextAssignments =
63
- metadata.assignments?.map((assignment) => {
64
- const matchesType = assignment.type === entityId
65
- const matchesRecord = fromRecordId
66
- ? assignment.id === fromRecordId
67
- : assignment.id === previousRecordId
68
- if (matchesType && matchesRecord) {
69
- return { ...assignment, id: toRecordId }
70
- }
71
- return assignment
72
- }) ?? []
73
- record.storageMetadata = mergeAttachmentMetadata(record.storageMetadata, { assignments: nextAssignments })
74
- }
75
- await em.persist(records).flush()
76
- return NextResponse.json({ ok: true, updated: records.length })
55
+ const accessContext = createAttachmentAccessContext(container)
56
+ const outcome = await em.transactional(async (tx) => {
57
+ const records = await findWithDecryption(tx, Attachment, filters, {
58
+ orderBy: { id: 'asc' }, lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true,
59
+ }, { tenantId: auth.tenantId, organizationId: auth.orgId })
60
+ if (records.length !== new Set(attachmentIds).size) return throwAttachmentAccessError(404)
61
+ const changes = []
62
+ for (const record of records) {
63
+ const recordPayload = { ...effective.data, attachmentIds: [record.id] }
64
+ const recordGuard = await prepareAttachmentMutation({
65
+ container, req, auth, recordId: record.id, operation: 'update', payload: recordPayload,
66
+ })
67
+ const recordEffective = transferSchema.safeParse(recordGuard.modifiedPayload ?? recordPayload)
68
+ if (!recordEffective.success
69
+ || recordEffective.data.attachmentIds.length !== 1
70
+ || recordEffective.data.attachmentIds[0] !== record.id
71
+ || recordEffective.data.entityId !== record.entityId
72
+ || (recordEffective.data.fromRecordId && recordEffective.data.fromRecordId !== record.recordId)) {
73
+ return throwAttachmentAccessError(403)
74
+ }
75
+ const destinationId = recordEffective.data.toRecordId
76
+ const metadata = readAttachmentMetadata(record.storageMetadata)
77
+ const assignments = metadata.assignments?.map((assignment) => (
78
+ assignment.type === entityId && assignment.id === (fromRecordId ?? record.recordId)
79
+ ? { ...assignment, id: destinationId } : assignment
80
+ )) ?? []
81
+ const storageMetadata = mergeAttachmentMetadata(record.storageMetadata, { assignments })
82
+ await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: record, action: 'reassign', persistProtection: true })
83
+ await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: { ...record, recordId: destinationId, storageMetadata }, action: 'reassign' })
84
+ changes.push({ record, storageMetadata, destinationId, guard: recordGuard })
85
+ }
86
+ for (const { record, storageMetadata, destinationId } of changes) {
87
+ record.recordId = destinationId
88
+ record.storageMetadata = storageMetadata
89
+ }
90
+ await tx.flush()
91
+ return { updated: records.length, guards: changes.map((change) => change.guard) }
92
+ })
93
+ await guard.runAfterSuccess()
94
+ for (const recordGuard of outcome.guards) await recordGuard.runAfterSuccess()
95
+ return NextResponse.json({ ok: true, updated: outcome.updated })
77
96
  }
78
97
 
98
+ export const POST = withAttachmentAccessErrors(transferAttachments)
99
+
79
100
  export const openApi: OpenApiRouteDoc = {
80
101
  tag: attachmentsTag,
81
102
  summary: 'Transfer attachments between records',
@@ -95,6 +116,8 @@ export const openApi: OpenApiRouteDoc = {
95
116
  { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },
96
117
  { status: 404, description: 'Attachments not found', schema: attachmentErrorSchema },
97
118
  { status: 500, description: 'Attachment model missing', schema: attachmentErrorSchema },
119
+ { status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },
120
+ { status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
98
121
  ],
99
122
  },
100
123
  },
@@ -1,6 +1,7 @@
1
1
  import { BigIntType, OptionalProps } from '@mikro-orm/core'
2
2
  import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'
3
3
  import { resolveDefaultAttachmentOcrEnabled } from '../lib/ocrConfig'
4
+ import type { AttachmentAccessRequirement } from '../lib/access-types'
4
5
 
5
6
  @Entity({ tableName: 'attachment_partitions' })
6
7
  @Unique({ name: 'attachment_partitions_code_unique', properties: ['code'] })
@@ -25,6 +26,9 @@ export class AttachmentPartition {
25
26
  @Property({ name: 'config_json', type: 'json', nullable: true })
26
27
  configJson?: Record<string, unknown> | null
27
28
 
29
+ @Property({ name: 'access_resolver_requirements', type: 'jsonb', nullable: true })
30
+ accessResolverRequirements?: AttachmentAccessRequirement[] | null
31
+
28
32
  @Property({ name: 'is_public', type: 'boolean', default: false })
29
33
  isPublic: boolean = false
30
34
 
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod'
2
+ import type { AttachmentAccessResolver } from '../lib/access-types'
3
+
4
+ export const attachmentAccessActionSchema = z.enum(['read', 'render', 'metadata', 'reassign', 'delete', 'export'])
5
+ export const attachmentAccessSubjectFeaturesSchema = z.array(z.string())
6
+ export const attachmentAccessSelectorSchema = z.string().min(1).max(200).regex(/^(?:\*|[^*\s]+(?:\.\*)?)$/)
7
+ const resolverIdSchema = z.string().min(3).max(200).regex(/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_.-]+$/)
8
+
9
+ export const attachmentAccessRequirementSchema = z.object({
10
+ resolverId: resolverIdSchema,
11
+ targetEntity: attachmentAccessSelectorSchema,
12
+ }).strict()
13
+
14
+ export const protectedAttachmentTargetSchema = attachmentAccessRequirementSchema.extend({
15
+ targetPartition: attachmentAccessSelectorSchema,
16
+ })
17
+
18
+ export const attachmentAccessResolverSchema = z.object({
19
+ id: resolverIdSchema,
20
+ targetPartition: attachmentAccessSelectorSchema,
21
+ targetEntity: attachmentAccessSelectorSchema.optional(),
22
+ actions: z.array(attachmentAccessActionSchema).min(1).optional(),
23
+ priority: z.number().finite().optional(),
24
+ timeoutMs: z.number().int().min(1).max(15_000).optional(),
25
+ resolve: z.custom<AttachmentAccessResolver['resolve']>((value) => typeof value === 'function'),
26
+ }).strict()
27
+
28
+ export const attachmentAccessRequirementsSchema = z.array(attachmentAccessRequirementSchema)
29
+ export const attachmentAccessDecisionSchema = z.discriminatedUnion('ok', [
30
+ z.object({ ok: z.literal(true) }).strict(),
31
+ z.object({
32
+ ok: z.literal(false),
33
+ status: z.union([z.literal(401), z.literal(403), z.literal(404), z.literal(504)]),
34
+ reason: z.string().min(1).max(200).regex(/^[a-zA-Z0-9_.:-]+$/),
35
+ }).strict(),
36
+ ])
@@ -1,3 +1,4 @@
1
+ import { createAttachmentAccessContext } from './lib/access-runner'
1
2
  import { asFunction, asValue } from 'awilix'
2
3
  import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
4
  import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
@@ -9,6 +10,7 @@ import { AttachmentTargetAccessService } from './lib/target-access-service'
9
10
  import { ScopedAttachmentUploadService } from './lib/scoped-upload-service'
10
11
 
11
12
  export function register(container: AppContainer) {
13
+ const accessContext = () => createAttachmentAccessContext(container)
12
14
  container.register({
13
15
  attachmentQuotaRecoveryScheduler: asValue(scheduleAttachmentQuotaRecovery),
14
16
  attachmentQuotaService: asFunction(({ em }: { em: ConstructorParameters<typeof StorageDriverFactory>[0] }) =>
@@ -17,7 +19,7 @@ export function register(container: AppContainer) {
17
19
  .scoped()
18
20
  .proxy(),
19
21
  attachmentTargetAccessService: asFunction(({ em }: { em: ConstructorParameters<typeof StorageDriverFactory>[0] }) =>
20
- new AttachmentTargetAccessService(em),
22
+ new AttachmentTargetAccessService(em, accessContext),
21
23
  )
22
24
  .scoped()
23
25
  .proxy(),
@@ -59,6 +61,7 @@ export function register(container: AppContainer) {
59
61
  cradle.em,
60
62
  cradle.storageDriverFactory,
61
63
  () => cradle.attachmentScopedUploadService ?? null,
64
+ accessContext,
62
65
  ))
63
66
  .scoped()
64
67
  .proxy(),
@@ -0,0 +1,27 @@
1
+ import type { GeneratorPlugin } from '@open-mercato/shared/modules/generators'
2
+
3
+ const accessPlugin: GeneratorPlugin = {
4
+ id: 'attachments.access-resolvers',
5
+ conventionFile: 'data/attachment-access.ts',
6
+ importPrefix: 'ATTACHMENT_ACCESS',
7
+ configExpr: (importName, moduleId) =>
8
+ `{ moduleId: '${moduleId}', resolvers: (${importName}.attachmentAccessResolvers ?? []), protectedTargets: (${importName}.protectedAttachmentTargets ?? []) }`,
9
+ outputFileName: 'attachment-access.generated.ts',
10
+ buildOutput: ({ importSection, entriesLiteral }) => `// AUTO-GENERATED by mercato generate registry
11
+ import type { AttachmentAccessResolverEntry } from '@open-mercato/core/modules/attachments'
12
+ ${importSection}
13
+
14
+ export const attachmentAccessEntries: AttachmentAccessResolverEntry[] = [
15
+ ${entriesLiteral ? ` ${entriesLiteral}\n` : ''}]
16
+ `,
17
+ bootstrapRegistration: {
18
+ entriesExportName: 'attachmentAccessEntries',
19
+ registrationImports: [
20
+ "import { registerAttachmentAccessResolvers } from '@open-mercato/core/modules/attachments/lib/access-registry'",
21
+ ],
22
+ buildCall: (entries) => `registerAttachmentAccessResolvers(${entries})`,
23
+ },
24
+ }
25
+
26
+ export const generatorPlugins: GeneratorPlugin[] = [accessPlugin]
27
+ export default generatorPlugins
@@ -1,4 +1,8 @@
1
1
  {
2
+ "attachments.access.forbidden": "Zugriff verweigert",
3
+ "attachments.access.notFound": "Anhang nicht gefunden.",
4
+ "attachments.access.timeout": "Zeitüberschreitung bei der Zugriffsprüfung des Anhangs. Bitte versuchen Sie es erneut.",
5
+ "attachments.access.unauthorized": "Nicht autorisiert",
2
6
  "attachments.errors.activeContentBlocked": "Das Hochladen aktiver Inhalte ist nicht erlaubt.",
3
7
  "attachments.errors.dangerousExecutable": "Ausführbare Dateitypen sind als Anhänge nicht erlaubt.",
4
8
  "attachments.errors.maxUploadSize": "Der Anhang überschreitet die maximal zulässige Upload-Größe.",
@@ -1,4 +1,8 @@
1
1
  {
2
+ "attachments.access.forbidden": "Forbidden",
3
+ "attachments.access.notFound": "Attachment not found.",
4
+ "attachments.access.timeout": "Attachment authorization timed out. Please try again.",
5
+ "attachments.access.unauthorized": "Unauthorized",
2
6
  "attachments.errors.activeContentBlocked": "Active content uploads are not allowed.",
3
7
  "attachments.errors.dangerousExecutable": "Executable file types are not allowed as attachments.",
4
8
  "attachments.errors.maxUploadSize": "Attachment exceeds the maximum upload size.",
@@ -1,4 +1,8 @@
1
1
  {
2
+ "attachments.access.forbidden": "Acceso denegado",
3
+ "attachments.access.notFound": "No se encontró el archivo adjunto.",
4
+ "attachments.access.timeout": "Se agotó el tiempo de comprobación de acceso al archivo adjunto. Inténtalo de nuevo.",
5
+ "attachments.access.unauthorized": "No autorizado",
2
6
  "attachments.errors.activeContentBlocked": "No se permite la carga de contenido activo.",
3
7
  "attachments.errors.dangerousExecutable": "No se permiten archivos ejecutables como adjuntos.",
4
8
  "attachments.errors.maxUploadSize": "El archivo adjunto supera el tamaño máximo de carga permitido.",
@@ -1,4 +1,8 @@
1
1
  {
2
+ "attachments.access.forbidden": "접근이 거부되었습니다",
3
+ "attachments.access.notFound": "첨부 파일을 찾을 수 없습니다.",
4
+ "attachments.access.timeout": "첨부 파일 접근 권한 확인 시간이 초과되었습니다. 다시 시도해 주세요.",
5
+ "attachments.access.unauthorized": "인증이 필요합니다",
2
6
  "attachments.errors.activeContentBlocked": "활성 콘텐츠 업로드는 허용되지 않습니다.",
3
7
  "attachments.errors.dangerousExecutable": "실행 파일 형식은 첨부 파일로 허용되지 않습니다.",
4
8
  "attachments.errors.maxUploadSize": "첨부 파일이 최대 업로드 크기를 초과합니다.",
@@ -1,4 +1,8 @@
1
1
  {
2
+ "attachments.access.forbidden": "Brak dostępu",
3
+ "attachments.access.notFound": "Nie znaleziono załącznika.",
4
+ "attachments.access.timeout": "Przekroczono czas sprawdzania dostępu do załącznika. Spróbuj ponownie.",
5
+ "attachments.access.unauthorized": "Brak autoryzacji",
2
6
  "attachments.errors.activeContentBlocked": "Przesyłanie treści aktywnych nie jest dozwolone.",
3
7
  "attachments.errors.dangerousExecutable": "Pliki wykonywalne nie są dozwolone jako załączniki.",
4
8
  "attachments.errors.maxUploadSize": "Załącznik przekracza maksymalny dopuszczalny rozmiar przesyłania.",
@@ -19,3 +19,21 @@ export {
19
19
  type ReadScopedAttachmentResult,
20
20
  type ReleaseScopedAttachmentInput,
21
21
  } from './lib/attachment-service'
22
+
23
+ export type {
24
+ AttachmentAccessAction,
25
+ AttachmentAccessDecision,
26
+ AttachmentAccessInput,
27
+ AttachmentAccessPartition,
28
+ AttachmentAccessRecord,
29
+ AttachmentAccessRequirement,
30
+ AttachmentAccessResolver,
31
+ AttachmentAccessResolverEntry,
32
+ AttachmentAccessSubject,
33
+ AttachmentAccessTarget,
34
+ ProtectedAttachmentTarget,
35
+ } from './lib/access-types'
36
+ export { registerAttachmentAccessResolvers } from './lib/access-registry'
37
+ export { syncAttachmentAccessProtection } from './lib/access-protection'
38
+ export { createAttachmentAccessContext, evaluateAttachmentAccess } from './lib/access-runner'
39
+ export type { AttachmentAccessContext, AttachmentAccessResult } from './lib/access-runner'
@@ -0,0 +1,32 @@
1
+ import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
2
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
3
+ import type { AttachmentAccessDecision } from './access-types'
4
+
5
+ export async function attachmentAccessErrorBody(status: number): Promise<{ error: string }> {
6
+ const { t } = await resolveTranslations()
7
+ const key = status === 401 ? 'unauthorized' : status === 404 ? 'notFound' : status === 504 ? 'timeout' : 'forbidden'
8
+ return { error: t(`attachments.access.${key}`) }
9
+ }
10
+
11
+ export async function attachmentAccessErrorResponse(decision: Extract<AttachmentAccessDecision, { ok: false }>): Promise<Response> {
12
+ return Response.json(await attachmentAccessErrorBody(decision.status), {
13
+ status: decision.status, headers: { 'Cache-Control': 'private, no-store' },
14
+ })
15
+ }
16
+
17
+ export async function throwAttachmentAccessError(status: number): Promise<never> {
18
+ throw new CrudHttpError(status, await attachmentAccessErrorBody(status))
19
+ }
20
+
21
+ export function withAttachmentAccessErrors<Arguments extends unknown[]>(handler: (...args: Arguments) => Promise<Response>) {
22
+ return async (...args: Arguments): Promise<Response> => {
23
+ try {
24
+ return await handler(...args)
25
+ } catch (error) {
26
+ if (isCrudHttpError(error)) {
27
+ return Response.json(error.body, { status: error.status, headers: { 'Cache-Control': 'private, no-store' } })
28
+ }
29
+ throw error
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,26 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
3
+ import { throwAttachmentAccessError } from './access-errors'
4
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
+ import { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard'
6
+
7
+ export async function prepareAttachmentMutation(input: {
8
+ container: AwilixContainer
9
+ req: Request
10
+ auth: NonNullable<AuthContext>
11
+ recordId?: string
12
+ operation: 'update' | 'delete'
13
+ payload?: Record<string, unknown>
14
+ }) {
15
+ if (!input.auth.tenantId) return throwAttachmentAccessError(401)
16
+ const guard = await runRouteMutationGuards({
17
+ container: input.container, req: input.req,
18
+ auth: { tenantId: input.auth.tenantId, organizationId: input.auth.orgId ?? null, userId: input.auth.sub },
19
+ input: {
20
+ resourceKind: 'attachments:attachment', resourceId: input.recordId,
21
+ operation: input.operation, mutationPayload: input.payload,
22
+ },
23
+ })
24
+ if (!guard.ok) throw new CrudHttpError(guard.errorStatus, guard.errorBody)
25
+ return guard
26
+ }