@open-mercato/core 0.10.1-develop.7741.1.61a2652671 → 0.10.1-develop.7749.1.ea3edf75e3

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 (159) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/attachment_partition/index.js +2 -0
  3. package/dist/generated/entities/attachment_partition/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/helpers/integration/attachmentAccessFixtures.js +144 -0
  7. package/dist/helpers/integration/attachmentAccessFixtures.js.map +7 -0
  8. package/dist/modules/attachments/api/file/[id]/route.js +16 -10
  9. package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
  10. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +15 -9
  11. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
  12. package/dist/modules/attachments/api/library/[id]/route.js +100 -56
  13. package/dist/modules/attachments/api/library/[id]/route.js.map +3 -3
  14. package/dist/modules/attachments/api/library/route.js +56 -17
  15. package/dist/modules/attachments/api/library/route.js.map +2 -2
  16. package/dist/modules/attachments/api/partitions/route.js +2 -0
  17. package/dist/modules/attachments/api/partitions/route.js.map +2 -2
  18. package/dist/modules/attachments/api/route.js +74 -34
  19. package/dist/modules/attachments/api/route.js.map +3 -3
  20. package/dist/modules/attachments/api/transfer/route.js +63 -37
  21. package/dist/modules/attachments/api/transfer/route.js.map +2 -2
  22. package/dist/modules/attachments/data/entities.js +3 -0
  23. package/dist/modules/attachments/data/entities.js.map +2 -2
  24. package/dist/modules/attachments/data/validators.js +41 -0
  25. package/dist/modules/attachments/data/validators.js.map +7 -0
  26. package/dist/modules/attachments/di.js +5 -2
  27. package/dist/modules/attachments/di.js.map +2 -2
  28. package/dist/modules/attachments/generators.js +29 -0
  29. package/dist/modules/attachments/generators.js.map +7 -0
  30. package/dist/modules/attachments/index.js +8 -1
  31. package/dist/modules/attachments/index.js.map +2 -2
  32. package/dist/modules/attachments/lib/access-errors.js +35 -0
  33. package/dist/modules/attachments/lib/access-errors.js.map +7 -0
  34. package/dist/modules/attachments/lib/access-mutation.js +23 -0
  35. package/dist/modules/attachments/lib/access-mutation.js.map +7 -0
  36. package/dist/modules/attachments/lib/access-protection.js +73 -0
  37. package/dist/modules/attachments/lib/access-protection.js.map +7 -0
  38. package/dist/modules/attachments/lib/access-query.js +74 -0
  39. package/dist/modules/attachments/lib/access-query.js.map +7 -0
  40. package/dist/modules/attachments/lib/access-registry.js +87 -0
  41. package/dist/modules/attachments/lib/access-registry.js.map +7 -0
  42. package/dist/modules/attachments/lib/access-runner.js +161 -0
  43. package/dist/modules/attachments/lib/access-runner.js.map +7 -0
  44. package/dist/modules/attachments/lib/access-types.js +1 -0
  45. package/dist/modules/attachments/lib/access-types.js.map +7 -0
  46. package/dist/modules/attachments/lib/attachment-service.js +14 -6
  47. package/dist/modules/attachments/lib/attachment-service.js.map +2 -2
  48. package/dist/modules/attachments/lib/createFromBuffer.js +2 -0
  49. package/dist/modules/attachments/lib/createFromBuffer.js.map +2 -2
  50. package/dist/modules/attachments/lib/partitions.js +3 -2
  51. package/dist/modules/attachments/lib/partitions.js.map +2 -2
  52. package/dist/modules/attachments/lib/scoped-upload-service.js +2 -0
  53. package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
  54. package/dist/modules/attachments/lib/target-access-service.js +12 -3
  55. package/dist/modules/attachments/lib/target-access-service.js.map +2 -2
  56. package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js +38 -0
  57. package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js.map +7 -0
  58. package/dist/modules/attachments/setup.js +10 -0
  59. package/dist/modules/attachments/setup.js.map +2 -2
  60. package/dist/modules/notifications/api/[id]/action/route.js +8 -0
  61. package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
  62. package/dist/modules/notifications/api/route.js +17 -0
  63. package/dist/modules/notifications/api/route.js.map +2 -2
  64. package/dist/modules/notifications/data/validators.js +1 -1
  65. package/dist/modules/notifications/data/validators.js.map +2 -2
  66. package/dist/modules/notifications/lib/notificationService.js +45 -3
  67. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  68. package/dist/modules/notifications/lib/routeHelpers.js +27 -0
  69. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  70. package/dist/modules/payment_gateways/lib/gateway-service.js +50 -34
  71. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  72. package/dist/modules/payment_gateways/lib/status-machine.js +10 -1
  73. package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
  74. package/dist/modules/payment_gateways/lib/webhook-processor.js +1 -1
  75. package/dist/modules/payment_gateways/lib/webhook-processor.js.map +2 -2
  76. package/dist/modules/staff/notifications.js +4 -2
  77. package/dist/modules/staff/notifications.js.map +2 -2
  78. package/dist/modules/workflows/data/entities.js.map +2 -2
  79. package/dist/modules/workflows/lib/activity-executor.js +18 -4
  80. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  81. package/dist/modules/workflows/lib/activity-types.js +2 -2
  82. package/dist/modules/workflows/lib/activity-types.js.map +2 -2
  83. package/dist/modules/workflows/lib/condition-handler.js +7 -1
  84. package/dist/modules/workflows/lib/condition-handler.js.map +2 -2
  85. package/dist/modules/workflows/lib/duration.js +13 -0
  86. package/dist/modules/workflows/lib/duration.js.map +2 -2
  87. package/dist/modules/workflows/lib/step-handler.js +9 -2
  88. package/dist/modules/workflows/lib/step-handler.js.map +2 -2
  89. package/dist/modules/workflows/lib/task-quick-action.js +13 -10
  90. package/dist/modules/workflows/lib/task-quick-action.js.map +2 -2
  91. package/dist/modules/workflows/lib/transition-handler.js +2 -1
  92. package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
  93. package/dist/modules/workflows/notifications.js +4 -0
  94. package/dist/modules/workflows/notifications.js.map +2 -2
  95. package/generated/entities/attachment_partition/index.ts +1 -0
  96. package/generated/entity-fields-registry.ts +1 -0
  97. package/package.json +7 -7
  98. package/src/helpers/integration/attachmentAccessFixtures.ts +141 -0
  99. package/src/modules/attachments/api/file/[id]/route.ts +12 -9
  100. package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +10 -8
  101. package/src/modules/attachments/api/library/[id]/route.ts +87 -58
  102. package/src/modules/attachments/api/library/route.ts +40 -11
  103. package/src/modules/attachments/api/partitions/route.ts +2 -0
  104. package/src/modules/attachments/api/route.ts +68 -35
  105. package/src/modules/attachments/api/transfer/route.ts +65 -42
  106. package/src/modules/attachments/data/entities.ts +4 -0
  107. package/src/modules/attachments/data/validators.ts +36 -0
  108. package/src/modules/attachments/di.ts +4 -1
  109. package/src/modules/attachments/generators.ts +27 -0
  110. package/src/modules/attachments/i18n/de.json +4 -0
  111. package/src/modules/attachments/i18n/en.json +4 -0
  112. package/src/modules/attachments/i18n/es.json +4 -0
  113. package/src/modules/attachments/i18n/ko.json +4 -0
  114. package/src/modules/attachments/i18n/pl.json +4 -0
  115. package/src/modules/attachments/index.ts +18 -0
  116. package/src/modules/attachments/lib/access-errors.ts +32 -0
  117. package/src/modules/attachments/lib/access-mutation.ts +26 -0
  118. package/src/modules/attachments/lib/access-protection.ts +83 -0
  119. package/src/modules/attachments/lib/access-query.ts +86 -0
  120. package/src/modules/attachments/lib/access-registry.ts +117 -0
  121. package/src/modules/attachments/lib/access-runner.ts +183 -0
  122. package/src/modules/attachments/lib/access-types.ts +78 -0
  123. package/src/modules/attachments/lib/attachment-service.ts +8 -4
  124. package/src/modules/attachments/lib/createFromBuffer.ts +3 -0
  125. package/src/modules/attachments/lib/partitions.ts +3 -2
  126. package/src/modules/attachments/lib/scoped-upload-service.ts +2 -0
  127. package/src/modules/attachments/lib/target-access-service.ts +10 -3
  128. package/src/modules/attachments/migrations/.snapshot-open-mercato.json +17 -0
  129. package/src/modules/attachments/migrations/Migration20261001120000_attachments.ts +35 -0
  130. package/src/modules/attachments/setup.ts +10 -0
  131. package/src/modules/auth/i18n/de.json +3 -0
  132. package/src/modules/auth/i18n/en.json +3 -0
  133. package/src/modules/auth/i18n/es.json +3 -0
  134. package/src/modules/auth/i18n/ko.json +3 -0
  135. package/src/modules/auth/i18n/pl.json +3 -0
  136. package/src/modules/notifications/api/[id]/action/route.ts +8 -0
  137. package/src/modules/notifications/api/route.ts +17 -0
  138. package/src/modules/notifications/data/validators.ts +1 -1
  139. package/src/modules/notifications/i18n/de.json +2 -0
  140. package/src/modules/notifications/i18n/en.json +2 -0
  141. package/src/modules/notifications/i18n/es.json +2 -0
  142. package/src/modules/notifications/i18n/ko.json +2 -0
  143. package/src/modules/notifications/i18n/pl.json +2 -0
  144. package/src/modules/notifications/lib/notificationService.ts +67 -1
  145. package/src/modules/notifications/lib/routeHelpers.ts +38 -0
  146. package/src/modules/payment_gateways/lib/gateway-service.ts +50 -33
  147. package/src/modules/payment_gateways/lib/status-machine.ts +19 -0
  148. package/src/modules/payment_gateways/lib/webhook-processor.ts +1 -1
  149. package/src/modules/staff/notifications.ts +2 -0
  150. package/src/modules/workflows/AGENTS.md +2 -2
  151. package/src/modules/workflows/data/entities.ts +6 -0
  152. package/src/modules/workflows/lib/activity-executor.ts +36 -4
  153. package/src/modules/workflows/lib/activity-types.ts +2 -2
  154. package/src/modules/workflows/lib/condition-handler.ts +16 -1
  155. package/src/modules/workflows/lib/duration.ts +25 -0
  156. package/src/modules/workflows/lib/step-handler.ts +16 -1
  157. package/src/modules/workflows/lib/task-quick-action.ts +19 -10
  158. package/src/modules/workflows/lib/transition-handler.ts +2 -1
  159. package/src/modules/workflows/notifications.ts +4 -0
@@ -1,3 +1,11 @@
1
+ import { LockMode } from '@mikro-orm/core'
2
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
+ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
4
+ import { prepareAttachmentMutation } from '../../../lib/access-mutation'
5
+ import { throwAttachmentAccessError } from '../../../lib/access-errors'
6
+ import { createAttachmentAccessContext } from '../../../lib/access-runner'
7
+ import { assertAttachmentOwnerAccess } from '../../../lib/access-query'
8
+ import { withAttachmentAccessErrors } from '../../../lib/access-errors'
1
9
  import { NextRequest, NextResponse } from 'next/server'
2
10
  import { z } from 'zod'
3
11
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
@@ -12,7 +20,7 @@ import {
12
20
  readAttachmentMetadata,
13
21
  } from '../../../lib/metadata'
14
22
  import type { StorageDriverFactory } from '../../../lib/drivers'
15
- import { removeAttachmentRecord } from '../../../lib/storageReferences'
23
+ import { lockAttachmentStorageReferences, resolveAttachmentStorageRelease } from '../../../lib/storageReferences'
16
24
  import { splitCustomFieldPayload, loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'
17
25
  import { emitCrudSideEffects, setCustomFieldsIfAny } from '@open-mercato/shared/lib/commands/helpers'
18
26
  import { normalizeCustomFieldResponse } from '@open-mercato/shared/lib/custom-fields/normalize'
@@ -26,9 +34,6 @@ import {
26
34
  attachmentDetailResponseSchema,
27
35
  attachmentErrorSchema,
28
36
  } from '../../openapi'
29
- import { createLogger } from '@open-mercato/shared/lib/logger'
30
-
31
- const logger = createLogger('attachments').child({ component: 'library' })
32
37
 
33
38
  const updateSchema = z.object({
34
39
  tags: z.array(z.string()).optional(),
@@ -66,7 +71,7 @@ async function resolveAttachmentId(ctx: RouteContext): Promise<string | null> {
66
71
  }
67
72
  }
68
73
 
69
- export async function GET(req: NextRequest, ctx: RouteContext) {
74
+ async function getAttachment(req: NextRequest, ctx: RouteContext) {
70
75
  const auth = await getAuthFromRequest(req)
71
76
  if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) {
72
77
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -75,7 +80,8 @@ export async function GET(req: NextRequest, ctx: RouteContext) {
75
80
  if (!attachmentId) {
76
81
  return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })
77
82
  }
78
- const { resolve } = await createRequestContainer()
83
+ const container = await createRequestContainer()
84
+ const { resolve } = container
79
85
  const em = resolve('em') as EntityManager
80
86
  let queryEngine: QueryEngine | null = null
81
87
  try {
@@ -94,6 +100,7 @@ export async function GET(req: NextRequest, ctx: RouteContext) {
94
100
  if (!record) {
95
101
  return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
96
102
  }
103
+ await assertAttachmentOwnerAccess({ em, context: createAttachmentAccessContext(container), auth, attachment: record, action: 'metadata' })
97
104
  const metadata = readAttachmentMetadata(record.storageMetadata)
98
105
  const partition = record.partitionCode
99
106
  ? await em.findOne(AttachmentPartition, { code: record.partitionCode })
@@ -127,10 +134,12 @@ export async function GET(req: NextRequest, ctx: RouteContext) {
127
134
  content: record.content && record.content.trim() ? record.content : null,
128
135
  customFields,
129
136
  },
130
- })
137
+ }, { headers: { 'Cache-Control': 'private, no-store' } })
131
138
  }
132
139
 
133
- export async function PATCH(req: NextRequest, ctx: RouteContext) {
140
+ export const GET = withAttachmentAccessErrors(getAttachment)
141
+
142
+ async function updateAttachment(req: NextRequest, ctx: RouteContext) {
134
143
  const auth = await getAuthFromRequest(req)
135
144
  if (!auth || !auth.tenantId || !auth.orgId) {
136
145
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -139,13 +148,14 @@ export async function PATCH(req: NextRequest, ctx: RouteContext) {
139
148
  if (!attachmentId) {
140
149
  return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })
141
150
  }
142
- const rawBody = await req.json().catch(() => null)
151
+ const rawBody = await readJsonSafe(req, null)
143
152
  const { base, custom } = splitCustomFieldPayload(rawBody)
144
153
  const parsed = updateSchema.safeParse(base)
145
154
  if (!parsed.success) {
146
155
  return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
147
156
  }
148
- const { resolve } = await createRequestContainer()
157
+ const container = await createRequestContainer()
158
+ const { resolve } = container
149
159
  const em = resolve('em') as EntityManager
150
160
  let queryEngine: QueryEngine | null = null
151
161
  try {
@@ -159,34 +169,38 @@ export async function PATCH(req: NextRequest, ctx: RouteContext) {
159
169
  tenantId: auth.tenantId,
160
170
  organizationId: auth.orgId,
161
171
  }
162
- const record = await em.findOne(Attachment, patchFilter)
163
- if (!record) {
164
- return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
165
- }
166
- const patch: Record<string, unknown> = {}
167
- if (parsed.data.tags) patch.tags = normalizeAttachmentTags(parsed.data.tags)
168
- if (parsed.data.assignments) patch.assignments = normalizeAttachmentAssignments(parsed.data.assignments)
169
- record.storageMetadata = mergeAttachmentMetadata(record.storageMetadata, patch)
170
- // Commit the metadata mutation and the custom-field write atomically so a
171
- // custom-field failure cannot leave the attachment metadata partially updated.
172
- try {
173
- await em.transactional(async (tx) => {
174
- await tx.flush()
175
- if (dataEngine && custom && Object.keys(custom).length) {
176
- await setCustomFieldsIfAny({
177
- dataEngine,
178
- entityId: E.attachments.attachment,
179
- recordId: record.id,
180
- tenantId: record.tenantId ?? auth.tenantId ?? null,
181
- organizationId: record.organizationId ?? auth.orgId ?? null,
182
- values: custom,
183
- })
184
- }
172
+ const accessContext = createAttachmentAccessContext(container)
173
+ const outcome = await em.transactional(async (tx) => {
174
+ const record = await findOneWithDecryption(tx, Attachment, patchFilter, {
175
+ lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true,
176
+ }, { tenantId: auth.tenantId, organizationId: auth.orgId })
177
+ if (!record) return throwAttachmentAccessError(404)
178
+ const guard = await prepareAttachmentMutation({
179
+ container, req, auth, recordId: record.id, operation: 'update',
180
+ payload: { ...parsed.data, customFields: custom },
185
181
  })
186
- } catch (error) {
187
- logger.error('Failed to persist custom attributes', { err: error })
188
- return NextResponse.json({ error: 'Failed to save attachment attributes.' }, { status: 500 })
189
- }
182
+ const effective = splitCustomFieldPayload(guard.modifiedPayload ?? { ...parsed.data, customFields: custom })
183
+ const effectiveParsed = updateSchema.safeParse(effective.base)
184
+ if (!effectiveParsed.success) return throwAttachmentAccessError(403)
185
+ const patch: Record<string, unknown> = {}
186
+ if (effectiveParsed.data.tags) patch.tags = normalizeAttachmentTags(effectiveParsed.data.tags)
187
+ if (effectiveParsed.data.assignments) patch.assignments = normalizeAttachmentAssignments(effectiveParsed.data.assignments)
188
+ const nextMetadata = mergeAttachmentMetadata(record.storageMetadata, patch)
189
+ await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: record, action: 'reassign', persistProtection: true })
190
+ await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: { ...record, storageMetadata: nextMetadata }, action: 'reassign' })
191
+ await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: { ...record, storageMetadata: nextMetadata }, action: 'metadata' })
192
+ record.storageMetadata = nextMetadata
193
+ await tx.flush()
194
+ if (dataEngine && Object.keys(effective.custom).length) {
195
+ await setCustomFieldsIfAny({ dataEngine, entityId: E.attachments.attachment, recordId: record.id,
196
+ tenantId: record.tenantId ?? auth.tenantId, organizationId: record.organizationId ?? auth.orgId,
197
+ values: effective.custom,
198
+ })
199
+ }
200
+ return { record, custom: effective.custom, guard }
201
+ })
202
+ const { record } = outcome
203
+ await outcome.guard.runAfterSuccess()
190
204
 
191
205
  if (dataEngine) {
192
206
  await emitCrudSideEffects({
@@ -218,12 +232,12 @@ export async function PATCH(req: NextRequest, ctx: RouteContext) {
218
232
  id: record.id,
219
233
  tags: metadata.tags ?? [],
220
234
  assignments: enrichedAssignments,
221
- customFields: normalizeCustomFieldResponse(custom ?? null),
235
+ customFields: normalizeCustomFieldResponse(outcome.custom ?? null),
222
236
  },
223
237
  })
224
238
  }
225
239
 
226
- export async function DELETE(req: NextRequest, ctx: RouteContext) {
240
+ async function deleteAttachment(req: NextRequest, ctx: RouteContext) {
227
241
  const auth = await getAuthFromRequest(req)
228
242
  if (!auth || !auth.tenantId || !auth.orgId) {
229
243
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -232,7 +246,8 @@ export async function DELETE(req: NextRequest, ctx: RouteContext) {
232
246
  if (!attachmentId) {
233
247
  return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })
234
248
  }
235
- const { resolve } = await createRequestContainer()
249
+ const container = await createRequestContainer()
250
+ const { resolve } = container
236
251
  const em = resolve('em') as EntityManager
237
252
  const dataEngine = resolve('dataEngine') as DataEngine
238
253
  const storageDriverFactory = resolve('storageDriverFactory') as StorageDriverFactory
@@ -241,25 +256,30 @@ export async function DELETE(req: NextRequest, ctx: RouteContext) {
241
256
  tenantId: auth.tenantId,
242
257
  organizationId: auth.orgId,
243
258
  }
244
- const record = await em.findOne(Attachment, deleteFilter)
245
- if (!record) {
246
- return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
247
- }
248
- const deleteDriver = await storageDriverFactory.resolveForPartition(record.partitionCode, {
249
- tenantId: record.tenantId ?? auth.tenantId,
250
- organizationId: record.organizationId ?? auth.orgId,
259
+ const outcome = await em.transactional(async (tx) => {
260
+ const candidate = await findOneWithDecryption(tx, Attachment, deleteFilter, undefined, {
261
+ tenantId: auth.tenantId, organizationId: auth.orgId,
262
+ })
263
+ if (!candidate) return throwAttachmentAccessError(404)
264
+ const referenceIds = await lockAttachmentStorageReferences(tx, candidate)
265
+ const removal = resolveAttachmentStorageRelease(referenceIds, candidate.id)
266
+ if (!removal.removed) return throwAttachmentAccessError(404)
267
+ const record = await findOneWithDecryption(tx, Attachment, deleteFilter, {
268
+ lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true,
269
+ }, { tenantId: auth.tenantId, organizationId: auth.orgId })
270
+ if (!record) return throwAttachmentAccessError(404)
271
+ const guard = await prepareAttachmentMutation({ container, req, auth, recordId: record.id, operation: 'delete' })
272
+ await assertAttachmentOwnerAccess({ em: tx, context: createAttachmentAccessContext(container), auth, attachment: record, action: 'delete' })
273
+ const driver = await storageDriverFactory.resolveForPartition(record.partitionCode, {
274
+ tenantId: record.tenantId ?? auth.tenantId!, organizationId: record.organizationId ?? auth.orgId!,
275
+ })
276
+ tx.remove(record)
277
+ await tx.flush()
278
+ return { record, driver, guard, releaseStorage: removal.releaseStorage }
251
279
  })
252
- // Commit the DB row removal before deleting the irreversible storage file so a
253
- // failed commit cannot leave a dangling record whose backing file is already gone.
254
- const recordPartitionCode = record.partitionCode
255
- const recordStoragePath = record.storagePath
256
- const removal = await removeAttachmentRecord(em, record)
257
- if (!removal.removed) {
258
- return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
259
- }
260
- if (removal.releaseStorage) {
261
- await deleteDriver.delete(recordPartitionCode, recordStoragePath)
262
- }
280
+ const { record } = outcome
281
+ if (outcome.releaseStorage) await outcome.driver.delete(record.partitionCode, record.storagePath)
282
+ await outcome.guard.runAfterSuccess()
263
283
 
264
284
  if (dataEngine) {
265
285
  await emitCrudSideEffects({
@@ -280,6 +300,9 @@ export async function DELETE(req: NextRequest, ctx: RouteContext) {
280
300
  return NextResponse.json({ ok: true })
281
301
  }
282
302
 
303
+ export const PATCH = withAttachmentAccessErrors(updateAttachment)
304
+ export const DELETE = withAttachmentAccessErrors(deleteAttachment)
305
+
283
306
  export const openApi: OpenApiRouteDoc = {
284
307
  tag: attachmentsTag,
285
308
  summary: 'Attachment detail management',
@@ -294,6 +317,8 @@ export const openApi: OpenApiRouteDoc = {
294
317
  { status: 400, description: 'Invalid attachment ID', schema: attachmentErrorSchema },
295
318
  { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },
296
319
  { status: 404, description: 'Attachment not found', schema: attachmentErrorSchema },
320
+ { status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },
321
+ { status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
297
322
  ],
298
323
  },
299
324
  PATCH: {
@@ -311,6 +336,8 @@ export const openApi: OpenApiRouteDoc = {
311
336
  { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },
312
337
  { status: 404, description: 'Attachment not found', schema: attachmentErrorSchema },
313
338
  { status: 500, description: 'Failed to save attributes', schema: attachmentErrorSchema },
339
+ { status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },
340
+ { status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
314
341
  ],
315
342
  },
316
343
  DELETE: {
@@ -323,6 +350,8 @@ export const openApi: OpenApiRouteDoc = {
323
350
  { status: 400, description: 'Invalid attachment ID', schema: attachmentErrorSchema },
324
351
  { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },
325
352
  { status: 404, description: 'Attachment not found', schema: attachmentErrorSchema },
353
+ { status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },
354
+ { status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
326
355
  ],
327
356
  },
328
357
  },
@@ -1,3 +1,6 @@
1
+ import { createAttachmentAccessContext } from '../../lib/access-runner'
2
+ import { requiresAttachmentAccessScan, scanAuthorizedAttachments } from '../../lib/access-query'
3
+ import { withAttachmentAccessErrors } from '../../lib/access-errors'
1
4
  import { NextResponse } from 'next/server'
2
5
  import { z } from 'zod'
3
6
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
@@ -55,7 +58,7 @@ function formatDateValue(value: unknown): string {
55
58
  return toDate().toISOString()
56
59
  }
57
60
 
58
- export async function GET(req: Request) {
61
+ async function listAttachments(req: Request) {
59
62
  const auth = await getAuthFromRequest(req)
60
63
  if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) {
61
64
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -69,7 +72,8 @@ export async function GET(req: Request) {
69
72
  const { page, pageSize, search, partition, tags, sortField, sortDir } = parsed.data
70
73
  const tagList = buildTagFilter(tags)
71
74
  const offset = (page - 1) * pageSize
72
- const { resolve } = await createRequestContainer()
75
+ const container = await createRequestContainer()
76
+ const { resolve } = container
73
77
  const em = resolve('em') as EntityManager
74
78
  await ensureDefaultPartitions(em)
75
79
  let queryEngine: QueryEngine | null = null
@@ -100,15 +104,32 @@ export async function GET(req: Request) {
100
104
  createdAt: 'a.created_at',
101
105
  }
102
106
  const orderColumn = orderMap[sortField ?? 'createdAt'] ?? 'a.created_at'
103
- qb.orderBy({ [orderColumn]: sortDir === 'asc' ? 'asc' : 'desc' })
107
+ qb.orderBy({ [orderColumn]: sortDir === 'asc' ? 'asc' : 'desc', 'a.id': 'asc' })
104
108
  qb.limit(pageSize).offset(offset)
105
109
 
106
- const partitionsPromise = em.find(
107
- AttachmentPartition,
108
- {},
109
- { orderBy: { title: 'asc' }, fields: ['code', 'title', 'description'] as any },
110
- )
111
- const [records, total, partitions] = await Promise.all([qb.getResultList(), countQb.getCount('a.id', true), partitionsPromise])
110
+ const partitions = await em.find(AttachmentPartition, {}, { orderBy: { title: 'asc' } })
111
+ const protectedScan = requiresAttachmentAccessScan(partitions)
112
+ const accessContext = createAttachmentAccessContext(container)
113
+ const deadline = Date.now() + 15_000
114
+ let records: Attachment[]
115
+ let total: number
116
+ let authorizedTags: string[] | null = null
117
+ if (protectedScan) {
118
+ const scanned = await scanAuthorizedAttachments({
119
+ auth, context: accessContext, partitions, offset, limit: pageSize, deadline,
120
+ loadBatch: (batchOffset, limit) => qb.clone().limit(limit).offset(batchOffset).getResultList(),
121
+ })
122
+ records = scanned.records
123
+ total = scanned.total
124
+ const facetQuery = em.createQueryBuilder(Attachment, 'a').where(baseFilter).orderBy({ 'a.id': 'asc' })
125
+ const facets = await scanAuthorizedAttachments({
126
+ auth, context: accessContext, partitions, limit: 0, collectTags: true, deadline,
127
+ loadBatch: (batchOffset, limit) => facetQuery.clone().limit(limit).offset(batchOffset).getResultList(),
128
+ })
129
+ authorizedTags = facets.tags
130
+ } else {
131
+ ;[records, total] = await Promise.all([qb.getResultList(), countQb.getCount('a.id', true)])
132
+ }
112
133
  const partitionTitleByCode = partitions.reduce<Record<string, string>>((acc, entry) => {
113
134
  if (entry.code) acc[entry.code] = entry.title ?? entry.code
114
135
  return acc
@@ -154,6 +175,8 @@ export async function GET(req: Request) {
154
175
  : items
155
176
 
156
177
  const totalPages = Math.max(1, Math.ceil(Number(total) / pageSize))
178
+ let availableTags = authorizedTags ?? []
179
+ if (!protectedScan) {
157
180
  const db = em.getKysely<any>() as any
158
181
  let tagQuery = db
159
182
  .selectFrom('attachments')
@@ -163,9 +186,10 @@ export async function GET(req: Request) {
163
186
  tagQuery = tagQuery.where('organization_id', '=', auth.orgId)
164
187
  }
165
188
  const tagRows = await tagQuery.orderBy('tag', 'asc').execute() as Array<{ tag?: string | null }>
166
- const availableTags = tagRows
189
+ availableTags = tagRows
167
190
  .map((row) => (typeof row.tag === 'string' ? row.tag.trim() : ''))
168
191
  .filter((tag) => tag.length > 0)
192
+ }
169
193
 
170
194
  return NextResponse.json({
171
195
  items: enrichedItems,
@@ -180,9 +204,11 @@ export async function GET(req: Request) {
180
204
  description: entry.description ?? null,
181
205
  isPublic: entry.isPublic ?? false,
182
206
  })),
183
- })
207
+ }, { headers: protectedScan ? { 'Cache-Control': 'private, no-store' } : undefined })
184
208
  }
185
209
 
210
+ export const GET = withAttachmentAccessErrors(listAttachments)
211
+
186
212
  export const openApi: OpenApiRouteDoc = {
187
213
  tag: attachmentsTag,
188
214
  summary: 'Attachment library management',
@@ -197,6 +223,9 @@ export const openApi: OpenApiRouteDoc = {
197
223
  errors: [
198
224
  { status: 400, description: 'Invalid query parameters', schema: attachmentErrorSchema },
199
225
  { status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },
226
+ { status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },
227
+ { status: 404, description: 'Attachment or owner is unavailable', schema: attachmentErrorSchema },
228
+ { status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
200
229
  ],
201
230
  },
202
231
  },
@@ -7,6 +7,7 @@ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/
7
7
  import { Attachment, AttachmentPartition } from '../../data/entities'
8
8
  import { ensureDefaultPartitions, DEFAULT_ATTACHMENT_PARTITIONS, sanitizePartitionCode, isPartitionSettingsLocked } from '../../lib/partitions'
9
9
  import { resolvePartitionEnvKey } from '../../lib/partitionEnv'
10
+ import { getDeclaredAttachmentAccessRequirements } from '../../lib/access-protection'
10
11
  import type { EntityManager } from '@mikro-orm/postgresql'
11
12
  import { resolveDefaultAttachmentOcrEnabled } from '../../lib/ocrConfig'
12
13
  import {
@@ -127,6 +128,7 @@ export async function POST(req: Request) {
127
128
  }
128
129
  const entry = em.create(AttachmentPartition, {
129
130
  code,
131
+ accessResolverRequirements: getDeclaredAttachmentAccessRequirements(code),
130
132
  title: parsed.data.title.trim(),
131
133
  description: parsed.data.description?.trim() ?? null,
132
134
  storageDriver: parsed.data.storageDriver ?? 'local',
@@ -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
  },