@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,4 +1,4 @@
1
- import { randomBytes } from 'node:crypto'
1
+ import { randomBytes, createHash } from 'node:crypto'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import { hash, compare } from 'bcryptjs'
4
4
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
@@ -227,6 +227,17 @@ export function generateSessionToken(): string {
227
227
  return `sess_${randomBytes(16).toString('hex')}`
228
228
  }
229
229
 
230
+ /**
231
+ * Hash a session token for storage/lookup (#2254). The session token is a bearer credential —
232
+ * it unlocks the decrypted API key secret and the user's ACL on the MCP server — so, mirroring
233
+ * `customer_accounts`' `hashToken`, only its hash is ever persisted. 128 bits of entropy from
234
+ * `generateSessionToken` is sufficient for a plain SHA-256 (no bcrypt cost needed, and a slow
235
+ * hash would just add latency to every MCP tool call that resolves a session).
236
+ */
237
+ export function hashSessionToken(sessionToken: string): string {
238
+ return createHash('sha256').update(sessionToken).digest('hex')
239
+ }
240
+
230
241
  /**
231
242
  * Create an ephemeral API key scoped to a chat session.
232
243
  * The key inherits the user's roles and expires after ttlMinutes (default 30).
@@ -244,12 +255,16 @@ export async function createSessionApiKey(
244
255
  // Encrypt the secret for later retrieval (used by MCP server for API calls)
245
256
  const encryptedSecret = await encryptSessionSecret(secret, input.tenantId ?? null)
246
257
 
258
+ // Hash before storage (#2254) — the row (and its `name` column, previously embedding the raw
259
+ // token for operator visibility) must never carry the live bearer credential in the clear.
260
+ const sessionTokenHash = hashSessionToken(input.sessionToken)
261
+
247
262
  const roleIds = Array.from(new Set(input.userRoles.filter(Boolean))).sort((left, right) => left.localeCompare(right))
248
263
  let record!: ApiKey
249
264
  await withAtomicFlush(em, [async () => {
250
265
  await lockRoleWriterAuthorizationState(em, roleIds)
251
266
  record = em.create(ApiKey, {
252
- name: `__session_${input.sessionToken}__`,
267
+ name: `__session_${sessionTokenHash.slice(0, 12)}__`,
253
268
  description: 'Ephemeral session API key for AI chat',
254
269
  tenantId: input.tenantId ?? null,
255
270
  organizationId: input.organizationId ?? null,
@@ -257,7 +272,7 @@ export async function createSessionApiKey(
257
272
  keyPrefix: prefix,
258
273
  rolesJson: roleIds,
259
274
  createdBy: input.userId,
260
- sessionToken: input.sessionToken,
275
+ sessionTokenHash,
261
276
  sessionUserId: input.userId,
262
277
  sessionSecretEncrypted: encryptedSecret,
263
278
  expiresAt,
@@ -283,10 +298,26 @@ export async function findApiKeyBySessionToken(
283
298
  ): Promise<ApiKey | null> {
284
299
  if (!sessionToken) return null
285
300
 
286
- const record = await em.findOne(ApiKey, {
287
- sessionToken,
288
- deletedAt: null,
289
- })
301
+ // Looked up by hash, never by the raw token (#2254).
302
+ const sessionTokenHash = hashSessionToken(sessionToken)
303
+ let record = await em.findOne(ApiKey, { sessionTokenHash, deletedAt: null })
304
+
305
+ if (!record) {
306
+ // Version-skew fallback (#7304): during a rolling deploy/rollback window the app and the
307
+ // separate MCP process can briefly run different code versions. A row minted by
308
+ // pre-#2254 code in that window carries only the plaintext token (sessionTokenHash is
309
+ // still null) — fall back to it so the session still resolves instead of answering a
310
+ // false SESSION_EXPIRED, then self-heal the row onto the hash immediately so this is the
311
+ // only lookup that ever sees the plaintext, and the window closes as soon as each
312
+ // outstanding legacy row is touched once.
313
+ const legacyRecord = await em.findOne(ApiKey, { sessionToken, deletedAt: null })
314
+ if (legacyRecord) {
315
+ legacyRecord.sessionTokenHash = sessionTokenHash
316
+ legacyRecord.sessionToken = null
317
+ await em.persist(legacyRecord).flush()
318
+ record = legacyRecord
319
+ }
320
+ }
290
321
 
291
322
  if (!record) return null
292
323
  if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null
@@ -392,14 +423,29 @@ export async function deleteSessionApiKey(
392
423
  em: EntityManager,
393
424
  sessionToken: string
394
425
  ): Promise<void> {
395
- const candidate = await em.findOne(ApiKey, { sessionToken, deletedAt: null })
426
+ // Looked up and re-checked by hash, never by the raw token (#2254). Deliberately does not
427
+ // call findApiKeyBySessionToken: that helper also drops already-expired rows, but deleting
428
+ // an expired-but-not-yet-cleaned-up row must still work here.
429
+ const sessionTokenHash = hashSessionToken(sessionToken)
430
+ let candidate = await em.findOne(ApiKey, { sessionTokenHash, deletedAt: null })
431
+ if (!candidate) {
432
+ // Version-skew fallback (#7304): see findApiKeyBySessionToken for the full rationale — a
433
+ // row minted by pre-#2254 code during a deploy window is plaintext-only until touched.
434
+ const legacyCandidate = await em.findOne(ApiKey, { sessionToken, deletedAt: null })
435
+ if (legacyCandidate) {
436
+ legacyCandidate.sessionTokenHash = sessionTokenHash
437
+ legacyCandidate.sessionToken = null
438
+ await em.persist(legacyCandidate).flush()
439
+ candidate = legacyCandidate
440
+ }
441
+ }
396
442
  if (!candidate) return
397
443
  let deletedId: string | null = null
398
444
  await withAtomicFlush(em, [async () => {
399
445
  const records = await lockAuthorizationApiKeyRows(em, [String(candidate.id)])
400
446
  const record = records.find((entry) => (
401
447
  String(entry.id) === String(candidate.id)
402
- && entry.sessionToken === sessionToken
448
+ && entry.sessionTokenHash === sessionTokenHash
403
449
  && !entry.deletedAt
404
450
  ))
405
451
  if (!record) return
@@ -1,3 +1,5 @@
1
+ import { createAttachmentAccessContext, evaluateAttachmentAccess } from '../../../lib/access-runner'
2
+ import { attachmentAccessErrorResponse } from '../../../lib/access-errors'
1
3
  import { NextRequest, NextResponse } from "next/server";
2
4
  import type { OpenApiRouteDoc } from "@open-mercato/shared/lib/openapi";
3
5
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
@@ -69,11 +71,14 @@ export async function GET(
69
71
  );
70
72
  }
71
73
 
72
- const access = checkAttachmentAccess(scopedAuth, attachment, partition);
73
- if (!access.ok) {
74
- const message = access.status === 401 ? "Unauthorized" : "Forbidden";
75
- return NextResponse.json({ error: message }, { status: access.status });
76
- }
74
+ const url = new URL(req.url);
75
+ const forceDownload = url.searchParams.get("download") === "1";
76
+ const renderInline = !forceDownload && canRenderInlineAttachment(attachment.mimeType);
77
+ const access = await evaluateAttachmentAccess({
78
+ auth: scopedAuth, attachment, partition, action: renderInline ? 'render' : 'read',
79
+ context: createAttachmentAccessContext(container),
80
+ });
81
+ if (!access.ok) return attachmentAccessErrorResponse(access);
77
82
 
78
83
  const driver = await storageDriverFactory.resolveForPartition(attachment.partitionCode, {
79
84
  tenantId: attachment.tenantId ?? '',
@@ -87,11 +92,8 @@ export async function GET(
87
92
  return NextResponse.json({ error: "File not available" }, { status: 404 });
88
93
  }
89
94
 
90
- const url = new URL(req.url);
91
- const forceDownload = url.searchParams.get("download") === "1";
92
- const renderInline = !forceDownload && canRenderInlineAttachment(attachment.mimeType);
93
95
  const headers: Record<string, string> = {
94
- "Cache-Control": checkAttachmentAccess(null, attachment, partition).ok
96
+ "Cache-Control": access.protected ? "private, no-store" : checkAttachmentAccess(null, attachment, partition).ok
95
97
  ? "public, max-age=86400"
96
98
  : "private, max-age=60",
97
99
  "Content-Security-Policy": "default-src 'none'; sandbox",
@@ -155,6 +157,7 @@ export const openApi: OpenApiRouteDoc = {
155
157
  description: "Partition misconfigured",
156
158
  schema: attachmentErrorSchema,
157
159
  },
160
+ { status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
158
161
  ],
159
162
  },
160
163
  },
@@ -1,3 +1,5 @@
1
+ import { createAttachmentAccessContext, evaluateAttachmentAccess } from '../../../../lib/access-runner'
2
+ import { attachmentAccessErrorResponse } from '../../../../lib/access-errors'
1
3
  import { NextRequest, NextResponse } from 'next/server'
2
4
  import sharp, { type ResizeOptions } from 'sharp'
3
5
  import { z } from 'zod'
@@ -71,17 +73,16 @@ export async function GET(
71
73
  if (!attachment) {
72
74
  return NextResponse.json({ error: 'Not found' }, { status: 404 })
73
75
  }
74
- if (!canRenderInlineAttachment(attachment.mimeType)) {
75
- return NextResponse.json({ error: 'Unsupported media type' }, { status: 400 })
76
- }
77
76
  const partition = await em.findOne(AttachmentPartition, { code: attachment.partitionCode })
78
77
  if (!partition) {
79
78
  return NextResponse.json({ error: 'Partition misconfigured' }, { status: 500 })
80
79
  }
81
- const access = checkAttachmentAccess(scopedAuth, attachment, partition)
82
- if (!access.ok) {
83
- const message = access.status === 401 ? 'Unauthorized' : 'Forbidden'
84
- return NextResponse.json({ error: message }, { status: access.status })
80
+ const access = await evaluateAttachmentAccess({
81
+ auth: scopedAuth, attachment, partition, action: 'render', context: createAttachmentAccessContext(container),
82
+ })
83
+ if (!access.ok) return attachmentAccessErrorResponse(access)
84
+ if (!canRenderInlineAttachment(attachment.mimeType)) {
85
+ return NextResponse.json({ error: 'Unsupported media type' }, { status: 400 })
85
86
  }
86
87
 
87
88
  const driver = await storageDriverFactory.resolveForPartition(attachment.partitionCode, {
@@ -136,7 +137,7 @@ export async function GET(
136
137
  return new NextResponse(responseBody, {
137
138
  headers: {
138
139
  'Content-Type': attachment.mimeType || 'image/jpeg',
139
- 'Cache-Control': checkAttachmentAccess(null, attachment, partition).ok ? 'public, max-age=3600' : 'private, max-age=60',
140
+ 'Cache-Control': access.protected ? 'private, no-store' : checkAttachmentAccess(null, attachment, partition).ok ? 'public, max-age=3600' : 'private, max-age=60',
140
141
  'X-Content-Type-Options': 'nosniff',
141
142
  },
142
143
  })
@@ -166,6 +167,7 @@ export const openApi: OpenApiRouteDoc = {
166
167
  { status: 403, description: 'Forbidden - insufficient permissions', schema: attachmentErrorSchema },
167
168
  { status: 404, description: 'Image not found', schema: attachmentErrorSchema },
168
169
  { status: 500, description: 'Partition misconfigured or image rendering failed', schema: attachmentErrorSchema },
170
+ { status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
169
171
  ],
170
172
  },
171
173
  },
@@ -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',