@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,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
+ }
@@ -0,0 +1,83 @@
1
+ import { LockMode } from '@mikro-orm/core'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import { AttachmentPartition } from '../data/entities'
5
+ import { attachmentAccessRequirementsSchema } from '../data/validators'
6
+ import { getAttachmentAccessRegistry, matchesAttachmentAccessSelector } from './access-registry'
7
+ import type { AttachmentAccessRequirement } from './access-types'
8
+
9
+ export function getDeclaredAttachmentAccessRequirements(partitionCode: string): AttachmentAccessRequirement[] {
10
+ const registry = getAttachmentAccessRegistry()
11
+ if (registry.invalid) throw new Error('[internal] Attachment access registry is invalid')
12
+ return registry.protectedTargets
13
+ .filter((target) => matchesAttachmentAccessSelector(target.targetPartition, partitionCode))
14
+ .map(({ resolverId, targetEntity }) => ({ resolverId, targetEntity }))
15
+ }
16
+
17
+ export function mergeAttachmentAccessRequirements(
18
+ current: unknown,
19
+ required: readonly AttachmentAccessRequirement[],
20
+ ): AttachmentAccessRequirement[] {
21
+ const existing = attachmentAccessRequirementsSchema.parse(current ?? [])
22
+ const union = new Map<string, AttachmentAccessRequirement>()
23
+ for (const requirement of [...existing, ...required]) {
24
+ union.set(JSON.stringify([requirement.resolverId, requirement.targetEntity]), { ...requirement })
25
+ }
26
+ return [...union.values()]
27
+ }
28
+
29
+ export async function syncAttachmentAccessProtection(
30
+ em: EntityManager,
31
+ options: { partitionCode?: string } = {},
32
+ ): Promise<number> {
33
+ const registry = getAttachmentAccessRegistry()
34
+ if (registry.invalid) throw new Error('[internal] Attachment access registry is invalid')
35
+ if (!registry.protectedTargets.length) return 0
36
+ const isolated = em.fork({ clear: true, useContext: false })
37
+ const candidates = await findWithDecryption(
38
+ isolated,
39
+ AttachmentPartition,
40
+ options.partitionCode ? { code: options.partitionCode } : {},
41
+ { orderBy: { id: 'asc' } },
42
+ { tenantId: null, organizationId: null },
43
+ )
44
+ if (options.partitionCode && getDeclaredAttachmentAccessRequirements(options.partitionCode).length && !candidates.length) {
45
+ throw new Error('[internal] Protected attachment partition is not durably configured')
46
+ }
47
+ const pending = candidates.filter((partition) => {
48
+ const required = getDeclaredAttachmentAccessRequirements(partition.code)
49
+ if (!required.length) return false
50
+ const existing = attachmentAccessRequirementsSchema.parse(partition.accessResolverRequirements ?? [])
51
+ return JSON.stringify(mergeAttachmentAccessRequirements(existing, required)) !== JSON.stringify(existing)
52
+ })
53
+ if (!pending.length) return 0
54
+ return isolated.transactional(async (transaction) => {
55
+ let changed = 0
56
+ for (const candidate of pending) {
57
+ const partition = await findOneWithDecryption(transaction, AttachmentPartition, { id: candidate.id }, {
58
+ lockMode: LockMode.PESSIMISTIC_WRITE,
59
+ refresh: true,
60
+ }, { tenantId: null, organizationId: null })
61
+ if (!partition) throw new Error('[internal] Attachment partition disappeared during access protection synchronization')
62
+ const existing = attachmentAccessRequirementsSchema.parse(partition.accessResolverRequirements ?? [])
63
+ const merged = mergeAttachmentAccessRequirements(existing, getDeclaredAttachmentAccessRequirements(partition.code))
64
+ if (JSON.stringify(merged) === JSON.stringify(existing)) continue
65
+ partition.accessResolverRequirements = merged
66
+ changed += 1
67
+ }
68
+ if (changed) await transaction.flush()
69
+ return changed
70
+ })
71
+ }
72
+
73
+ export async function ensureAttachmentPartitionProtection(
74
+ em: EntityManager,
75
+ partition: AttachmentPartition,
76
+ ): Promise<void> {
77
+ const required = getDeclaredAttachmentAccessRequirements(partition.code)
78
+ if (!required.length) return
79
+ const existing = attachmentAccessRequirementsSchema.parse(partition.accessResolverRequirements ?? [])
80
+ const merged = mergeAttachmentAccessRequirements(existing, required)
81
+ if (JSON.stringify(existing) === JSON.stringify(merged)) return
82
+ await syncAttachmentAccessProtection(em, { partitionCode: partition.code })
83
+ }
@@ -0,0 +1,86 @@
1
+ import { ensureAttachmentPartitionProtection } from './access-protection'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
4
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { AttachmentPartition, type Attachment } from '../data/entities'
6
+ import { attachmentAccessRequirementsSchema } from '../data/validators'
7
+ import { getAttachmentAccessRegistry } from './access-registry'
8
+ import { evaluateAttachmentAccess, type AttachmentAccessContext } from './access-runner'
9
+ import type { AttachmentAccessAction, AttachmentAccessRecord } from './access-types'
10
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
11
+ import { attachmentAccessErrorBody, throwAttachmentAccessError } from './access-errors'
12
+ import { readAttachmentMetadata } from './metadata'
13
+
14
+ export function requiresAttachmentAccessScan(partitions: readonly AttachmentPartition[]): boolean {
15
+ const registry = getAttachmentAccessRegistry()
16
+ return registry.invalid || registry.resolvers.length > 0 || registry.protectedTargets.length > 0
17
+ || partitions.some((partition) => {
18
+ const parsed = attachmentAccessRequirementsSchema.safeParse(partition.accessResolverRequirements ?? [])
19
+ return !parsed.success || parsed.data.length > 0
20
+ })
21
+ }
22
+
23
+ export async function assertAttachmentOwnerAccess(input: {
24
+ em: EntityManager
25
+ context: AttachmentAccessContext
26
+ auth: NonNullable<AuthContext>
27
+ attachment: AttachmentAccessRecord
28
+ action: AttachmentAccessAction
29
+ persistProtection?: boolean
30
+ }): Promise<void> {
31
+ const partition = await findOneWithDecryption(input.em, AttachmentPartition, { code: input.attachment.partitionCode }, undefined, {
32
+ tenantId: input.attachment.tenantId, organizationId: input.attachment.organizationId,
33
+ })
34
+ if (!partition) await throwAttachmentAccessError(404)
35
+ if (input.persistProtection) await ensureAttachmentPartitionProtection(input.em, partition!)
36
+ const result = await evaluateAttachmentAccess({ ...input, partition: partition!, requireAuthForPublic: true })
37
+ if (!result.ok) await throwAttachmentAccessError(result.status)
38
+ }
39
+
40
+ export async function scanAuthorizedAttachments(input: {
41
+ auth: NonNullable<AuthContext>
42
+ context: AttachmentAccessContext
43
+ partitions: readonly AttachmentPartition[]
44
+ loadBatch(offset: number, limit: number): Promise<Attachment[]>
45
+ offset?: number
46
+ limit?: number
47
+ collectTags?: boolean
48
+ deadline?: number
49
+ }): Promise<{ records: Attachment[]; total: number; tags: string[] }> {
50
+ const deadline = input.deadline ?? Date.now() + 15_000
51
+ const timeoutBody = await attachmentAccessErrorBody(504)
52
+ let timer: ReturnType<typeof setTimeout> | undefined
53
+ const scan = async () => {
54
+ const partitions = new Map(input.partitions.map((partition) => [partition.code, partition]))
55
+ const records: Attachment[] = []
56
+ const tags = new Set<string>()
57
+ let total = 0
58
+ for (let batchOffset = 0; ; batchOffset += 100) {
59
+ if (Date.now() >= deadline) await throwAttachmentAccessError(504)
60
+ const batch = await input.loadBatch(batchOffset, 100)
61
+ for (const attachment of batch) {
62
+ if (Date.now() >= deadline) await throwAttachmentAccessError(504)
63
+ const partition = partitions.get(attachment.partitionCode)
64
+ if (!partition) continue
65
+ const access = await evaluateAttachmentAccess({
66
+ auth: input.auth, attachment, partition, action: 'metadata', context: input.context,
67
+ requireAuthForPublic: true,
68
+ })
69
+ if (Date.now() >= deadline || (!access.ok && access.status === 504)) await throwAttachmentAccessError(504)
70
+ if (!access.ok) continue
71
+ if (input.collectTags) for (const tag of readAttachmentMetadata(attachment.storageMetadata).tags ?? []) tags.add(tag)
72
+ if (total >= (input.offset ?? 0) && (input.limit === undefined || records.length < input.limit)) records.push(attachment)
73
+ total += 1
74
+ }
75
+ if (batch.length < 100) break
76
+ }
77
+ return { records, total, tags: [...tags].sort((left, right) => left < right ? -1 : left > right ? 1 : 0) }
78
+ }
79
+ try {
80
+ return await Promise.race([scan(), new Promise<never>((_resolve, reject) => {
81
+ timer = setTimeout(() => reject(new CrudHttpError(504, timeoutBody)), Math.max(1, deadline - Date.now()))
82
+ })])
83
+ } finally {
84
+ if (timer) clearTimeout(timer)
85
+ }
86
+ }