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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/api_key/index.js +2 -0
  3. package/dist/generated/entities/api_key/index.js.map +2 -2
  4. package/dist/generated/entities/attachment_partition/index.js +2 -0
  5. package/dist/generated/entities/attachment_partition/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +2 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/helpers/integration/attachmentAccessFixtures.js +144 -0
  9. package/dist/helpers/integration/attachmentAccessFixtures.js.map +7 -0
  10. package/dist/modules/api_keys/data/entities.js +7 -0
  11. package/dist/modules/api_keys/data/entities.js.map +2 -2
  12. package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js +30 -0
  13. package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js.map +7 -0
  14. package/dist/modules/api_keys/services/apiKeyService.js +31 -9
  15. package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
  16. package/dist/modules/attachments/api/file/[id]/route.js +16 -10
  17. package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
  18. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +15 -9
  19. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
  20. package/dist/modules/attachments/api/library/[id]/route.js +100 -56
  21. package/dist/modules/attachments/api/library/[id]/route.js.map +3 -3
  22. package/dist/modules/attachments/api/library/route.js +56 -17
  23. package/dist/modules/attachments/api/library/route.js.map +2 -2
  24. package/dist/modules/attachments/api/partitions/route.js +2 -0
  25. package/dist/modules/attachments/api/partitions/route.js.map +2 -2
  26. package/dist/modules/attachments/api/route.js +74 -34
  27. package/dist/modules/attachments/api/route.js.map +3 -3
  28. package/dist/modules/attachments/api/transfer/route.js +63 -37
  29. package/dist/modules/attachments/api/transfer/route.js.map +2 -2
  30. package/dist/modules/attachments/data/entities.js +3 -0
  31. package/dist/modules/attachments/data/entities.js.map +2 -2
  32. package/dist/modules/attachments/data/validators.js +41 -0
  33. package/dist/modules/attachments/data/validators.js.map +7 -0
  34. package/dist/modules/attachments/di.js +5 -2
  35. package/dist/modules/attachments/di.js.map +2 -2
  36. package/dist/modules/attachments/generators.js +29 -0
  37. package/dist/modules/attachments/generators.js.map +7 -0
  38. package/dist/modules/attachments/index.js +8 -1
  39. package/dist/modules/attachments/index.js.map +2 -2
  40. package/dist/modules/attachments/lib/access-errors.js +35 -0
  41. package/dist/modules/attachments/lib/access-errors.js.map +7 -0
  42. package/dist/modules/attachments/lib/access-mutation.js +23 -0
  43. package/dist/modules/attachments/lib/access-mutation.js.map +7 -0
  44. package/dist/modules/attachments/lib/access-protection.js +73 -0
  45. package/dist/modules/attachments/lib/access-protection.js.map +7 -0
  46. package/dist/modules/attachments/lib/access-query.js +74 -0
  47. package/dist/modules/attachments/lib/access-query.js.map +7 -0
  48. package/dist/modules/attachments/lib/access-registry.js +87 -0
  49. package/dist/modules/attachments/lib/access-registry.js.map +7 -0
  50. package/dist/modules/attachments/lib/access-runner.js +161 -0
  51. package/dist/modules/attachments/lib/access-runner.js.map +7 -0
  52. package/dist/modules/attachments/lib/access-types.js +1 -0
  53. package/dist/modules/attachments/lib/access-types.js.map +7 -0
  54. package/dist/modules/attachments/lib/attachment-service.js +14 -6
  55. package/dist/modules/attachments/lib/attachment-service.js.map +2 -2
  56. package/dist/modules/attachments/lib/createFromBuffer.js +2 -0
  57. package/dist/modules/attachments/lib/createFromBuffer.js.map +2 -2
  58. package/dist/modules/attachments/lib/partitions.js +3 -2
  59. package/dist/modules/attachments/lib/partitions.js.map +2 -2
  60. package/dist/modules/attachments/lib/scoped-upload-service.js +2 -0
  61. package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
  62. package/dist/modules/attachments/lib/target-access-service.js +12 -3
  63. package/dist/modules/attachments/lib/target-access-service.js.map +2 -2
  64. package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js +38 -0
  65. package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js.map +7 -0
  66. package/dist/modules/attachments/setup.js +10 -0
  67. package/dist/modules/attachments/setup.js.map +2 -2
  68. package/dist/modules/notifications/api/[id]/action/route.js +8 -0
  69. package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
  70. package/dist/modules/notifications/api/route.js +17 -0
  71. package/dist/modules/notifications/api/route.js.map +2 -2
  72. package/dist/modules/notifications/data/validators.js +1 -1
  73. package/dist/modules/notifications/data/validators.js.map +2 -2
  74. package/dist/modules/notifications/lib/notificationService.js +45 -3
  75. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  76. package/dist/modules/notifications/lib/routeHelpers.js +27 -0
  77. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  78. package/dist/modules/payment_gateways/lib/gateway-service.js +50 -34
  79. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  80. package/dist/modules/payment_gateways/lib/status-machine.js +10 -1
  81. package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
  82. package/dist/modules/payment_gateways/lib/webhook-processor.js +1 -1
  83. package/dist/modules/payment_gateways/lib/webhook-processor.js.map +2 -2
  84. package/dist/modules/staff/notifications.js +4 -2
  85. package/dist/modules/staff/notifications.js.map +2 -2
  86. package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js +1 -1
  87. package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js.map +2 -2
  88. package/dist/modules/workflows/api/tasks/[id]/complete/route.js +7 -1
  89. package/dist/modules/workflows/api/tasks/[id]/complete/route.js.map +2 -2
  90. package/dist/modules/workflows/backend/tasks/[id]/page.js +2 -1
  91. package/dist/modules/workflows/backend/tasks/[id]/page.js.map +2 -2
  92. package/dist/modules/workflows/data/entities.js.map +2 -2
  93. package/dist/modules/workflows/lib/activity-executor.js +18 -4
  94. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  95. package/dist/modules/workflows/lib/activity-types.js +2 -2
  96. package/dist/modules/workflows/lib/activity-types.js.map +2 -2
  97. package/dist/modules/workflows/lib/condition-handler.js +7 -1
  98. package/dist/modules/workflows/lib/condition-handler.js.map +2 -2
  99. package/dist/modules/workflows/lib/duration.js +13 -0
  100. package/dist/modules/workflows/lib/duration.js.map +2 -2
  101. package/dist/modules/workflows/lib/instance-status.js +16 -0
  102. package/dist/modules/workflows/lib/instance-status.js.map +7 -0
  103. package/dist/modules/workflows/lib/step-handler.js +9 -2
  104. package/dist/modules/workflows/lib/step-handler.js.map +2 -2
  105. package/dist/modules/workflows/lib/task-handler.js +16 -8
  106. package/dist/modules/workflows/lib/task-handler.js.map +2 -2
  107. package/dist/modules/workflows/lib/task-quick-action.js +13 -10
  108. package/dist/modules/workflows/lib/task-quick-action.js.map +2 -2
  109. package/dist/modules/workflows/lib/task-sla.js +10 -0
  110. package/dist/modules/workflows/lib/task-sla.js.map +2 -2
  111. package/dist/modules/workflows/lib/transition-handler.js +2 -1
  112. package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
  113. package/dist/modules/workflows/lib/workflow-executor.js +29 -1
  114. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  115. package/dist/modules/workflows/notifications.js +4 -0
  116. package/dist/modules/workflows/notifications.js.map +2 -2
  117. package/generated/entities/api_key/index.ts +1 -0
  118. package/generated/entities/attachment_partition/index.ts +1 -0
  119. package/generated/entity-fields-registry.ts +2 -0
  120. package/package.json +7 -7
  121. package/src/helpers/integration/attachmentAccessFixtures.ts +141 -0
  122. package/src/modules/api_keys/data/entities.ts +25 -1
  123. package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +234 -68
  124. package/src/modules/api_keys/migrations/Migration20261012202110_api_keys.ts +47 -0
  125. package/src/modules/api_keys/services/apiKeyService.ts +55 -9
  126. package/src/modules/attachments/api/file/[id]/route.ts +12 -9
  127. package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +10 -8
  128. package/src/modules/attachments/api/library/[id]/route.ts +87 -58
  129. package/src/modules/attachments/api/library/route.ts +40 -11
  130. package/src/modules/attachments/api/partitions/route.ts +2 -0
  131. package/src/modules/attachments/api/route.ts +68 -35
  132. package/src/modules/attachments/api/transfer/route.ts +65 -42
  133. package/src/modules/attachments/data/entities.ts +4 -0
  134. package/src/modules/attachments/data/validators.ts +36 -0
  135. package/src/modules/attachments/di.ts +4 -1
  136. package/src/modules/attachments/generators.ts +27 -0
  137. package/src/modules/attachments/i18n/de.json +4 -0
  138. package/src/modules/attachments/i18n/en.json +4 -0
  139. package/src/modules/attachments/i18n/es.json +4 -0
  140. package/src/modules/attachments/i18n/ko.json +4 -0
  141. package/src/modules/attachments/i18n/pl.json +4 -0
  142. package/src/modules/attachments/index.ts +18 -0
  143. package/src/modules/attachments/lib/access-errors.ts +32 -0
  144. package/src/modules/attachments/lib/access-mutation.ts +26 -0
  145. package/src/modules/attachments/lib/access-protection.ts +83 -0
  146. package/src/modules/attachments/lib/access-query.ts +86 -0
  147. package/src/modules/attachments/lib/access-registry.ts +117 -0
  148. package/src/modules/attachments/lib/access-runner.ts +183 -0
  149. package/src/modules/attachments/lib/access-types.ts +78 -0
  150. package/src/modules/attachments/lib/attachment-service.ts +8 -4
  151. package/src/modules/attachments/lib/createFromBuffer.ts +3 -0
  152. package/src/modules/attachments/lib/partitions.ts +3 -2
  153. package/src/modules/attachments/lib/scoped-upload-service.ts +2 -0
  154. package/src/modules/attachments/lib/target-access-service.ts +10 -3
  155. package/src/modules/attachments/migrations/.snapshot-open-mercato.json +17 -0
  156. package/src/modules/attachments/migrations/Migration20261001120000_attachments.ts +35 -0
  157. package/src/modules/attachments/setup.ts +10 -0
  158. package/src/modules/auth/i18n/de.json +3 -0
  159. package/src/modules/auth/i18n/en.json +3 -0
  160. package/src/modules/auth/i18n/es.json +3 -0
  161. package/src/modules/auth/i18n/ko.json +3 -0
  162. package/src/modules/auth/i18n/pl.json +3 -0
  163. package/src/modules/notifications/api/[id]/action/route.ts +8 -0
  164. package/src/modules/notifications/api/route.ts +17 -0
  165. package/src/modules/notifications/data/validators.ts +1 -1
  166. package/src/modules/notifications/i18n/de.json +2 -0
  167. package/src/modules/notifications/i18n/en.json +2 -0
  168. package/src/modules/notifications/i18n/es.json +2 -0
  169. package/src/modules/notifications/i18n/ko.json +2 -0
  170. package/src/modules/notifications/i18n/pl.json +2 -0
  171. package/src/modules/notifications/lib/notificationService.ts +67 -1
  172. package/src/modules/notifications/lib/routeHelpers.ts +38 -0
  173. package/src/modules/payment_gateways/lib/gateway-service.ts +50 -33
  174. package/src/modules/payment_gateways/lib/status-machine.ts +19 -0
  175. package/src/modules/payment_gateways/lib/webhook-processor.ts +1 -1
  176. package/src/modules/staff/notifications.ts +2 -0
  177. package/src/modules/workflows/AGENTS.md +2 -2
  178. package/src/modules/workflows/api/portal/tasks/[id]/complete/route.ts +5 -1
  179. package/src/modules/workflows/api/tasks/[id]/complete/route.ts +7 -1
  180. package/src/modules/workflows/backend/tasks/[id]/page.tsx +6 -2
  181. package/src/modules/workflows/data/entities.ts +6 -0
  182. package/src/modules/workflows/i18n/de.json +2 -0
  183. package/src/modules/workflows/i18n/en.json +2 -0
  184. package/src/modules/workflows/i18n/es.json +2 -0
  185. package/src/modules/workflows/i18n/ko.json +2 -0
  186. package/src/modules/workflows/i18n/pl.json +2 -0
  187. package/src/modules/workflows/lib/activity-executor.ts +36 -4
  188. package/src/modules/workflows/lib/activity-types.ts +2 -2
  189. package/src/modules/workflows/lib/condition-handler.ts +16 -1
  190. package/src/modules/workflows/lib/duration.ts +25 -0
  191. package/src/modules/workflows/lib/instance-status.ts +29 -0
  192. package/src/modules/workflows/lib/step-handler.ts +16 -1
  193. package/src/modules/workflows/lib/task-handler.ts +18 -10
  194. package/src/modules/workflows/lib/task-quick-action.ts +19 -10
  195. package/src/modules/workflows/lib/task-sla.ts +13 -0
  196. package/src/modules/workflows/lib/transition-handler.ts +2 -1
  197. package/src/modules/workflows/lib/workflow-executor.ts +44 -1
  198. package/src/modules/workflows/notifications.ts +4 -0
@@ -0,0 +1,161 @@
1
+ import { createLogger } from "@open-mercato/shared/lib/logger";
2
+ import { getTelemetryRuntime } from "@open-mercato/shared/lib/telemetry/runtime";
3
+ import { attachmentAccessDecisionSchema, attachmentAccessRequirementsSchema, attachmentAccessSubjectFeaturesSchema } from "../data/validators.js";
4
+ import { checkAttachmentAccess } from "./access.js";
5
+ import {
6
+ collectAttachmentAccessTargets,
7
+ getAttachmentAccessRegistry,
8
+ matchesAttachmentAccessResolver,
9
+ matchesAttachmentAccessSelector,
10
+ matchesAttachmentAccessTargets
11
+ } from "./access-registry.js";
12
+ const logger = createLogger("attachments").child({ component: "access" });
13
+ const DEFAULT_TIMEOUT_MS = 1500;
14
+ function createAttachmentAccessContext(container) {
15
+ return { container, subjects: /* @__PURE__ */ new Map(), resolverCaches: /* @__PURE__ */ new Map() };
16
+ }
17
+ class AccessTimeout extends Error {
18
+ }
19
+ async function withinBudget(work, timeoutMs, controller) {
20
+ let timer;
21
+ try {
22
+ return await Promise.race([
23
+ Promise.resolve().then(work),
24
+ new Promise((_resolve, reject) => {
25
+ timer = setTimeout(() => {
26
+ controller.abort();
27
+ reject(new AccessTimeout("[internal] Attachment access evaluation timed out"));
28
+ }, timeoutMs);
29
+ })
30
+ ]);
31
+ } finally {
32
+ if (timer) clearTimeout(timer);
33
+ }
34
+ }
35
+ async function projectSubject(context, auth, attachment) {
36
+ const scope = {
37
+ tenantId: attachment.tenantId ?? auth.tenantId ?? null,
38
+ organizationId: attachment.organizationId ?? auth.orgId ?? null
39
+ };
40
+ const key = JSON.stringify([auth.sub, scope.tenantId, scope.organizationId]);
41
+ let pending = context.subjects.get(key);
42
+ if (!pending) {
43
+ pending = (async () => {
44
+ const rbac = context.container.resolve("rbacService");
45
+ const acl = await rbac.loadAcl(auth.sub, scope);
46
+ const userFeatures = attachmentAccessSubjectFeaturesSchema.parse(await rbac.getEffectiveFeatures(auth.sub, scope));
47
+ if (typeof acl.isSuperAdmin !== "boolean") {
48
+ throw new Error("[internal] Invalid attachment subject projection");
49
+ }
50
+ if (!acl.isSuperAdmin && acl.organizations && scope.organizationId && !acl.organizations.includes(scope.organizationId) && !acl.organizations.includes("__all__")) {
51
+ throw new Error("[internal] Attachment subject is outside its current organization grants");
52
+ }
53
+ return Object.freeze({
54
+ auth: Object.freeze({
55
+ sub: auth.sub,
56
+ tenantId: scope.tenantId,
57
+ orgId: scope.organizationId,
58
+ isApiKey: auth.isApiKey === true,
59
+ userId: auth.userId,
60
+ keyId: auth.keyId,
61
+ isSuperAdmin: acl.isSuperAdmin
62
+ }),
63
+ ...scope,
64
+ userFeatures: Object.freeze([...userFeatures]),
65
+ isSuperAdmin: acl.isSuperAdmin
66
+ });
67
+ })();
68
+ context.subjects.set(key, pending);
69
+ }
70
+ return pending;
71
+ }
72
+ async function evaluateAttachmentAccess(input) {
73
+ const { auth, attachment, partition, action, context } = input;
74
+ const baseline = checkAttachmentAccess(auth, attachment, partition, { requireAuthForPublic: input.requireAuthForPublic });
75
+ if (!baseline.ok) return { ok: false, status: baseline.status === 401 ? 401 : 403, reason: "scope", protected: false };
76
+ const deny = (status, reason, resolverId) => {
77
+ logger.info("Attachment owner access denied", { resolverId, action, attachmentId: attachment.id, reason });
78
+ return { ok: false, status, reason, protected: true };
79
+ };
80
+ const registry = getAttachmentAccessRegistry();
81
+ if (registry.invalid) return deny(403, "invalid_registry");
82
+ const parsedRequirements = attachmentAccessRequirementsSchema.safeParse(partition.accessResolverRequirements ?? []);
83
+ if (!parsedRequirements.success) return deny(403, "invalid_requirements");
84
+ const targets = collectAttachmentAccessTargets(attachment);
85
+ const requirements = [...parsedRequirements.data, ...registry.protectedTargets.filter((target) => matchesAttachmentAccessSelector(target.targetPartition, partition.code))].filter((requirement) => matchesAttachmentAccessTargets(requirement.targetEntity, targets));
86
+ const resolvers = registry.resolvers.filter((resolver) => matchesAttachmentAccessResolver(resolver, partition.code, targets, action));
87
+ if (!requirements.length && !resolvers.length) return { ok: true, protected: false };
88
+ if (!auth) return deny(401, "authentication_required");
89
+ for (const requirement of requirements) {
90
+ const requiredTargets = targets.filter((target) => matchesAttachmentAccessSelector(requirement.targetEntity, target.entityId));
91
+ const provider = resolvers.find((resolver) => resolver.id === requirement.resolverId);
92
+ if (!provider || (requiredTargets.length ? requiredTargets.some((target) => !matchesAttachmentAccessSelector(provider.targetEntity ?? "*", target.entityId)) : (provider.targetEntity ?? "*") !== "*")) return deny(403, "required_resolver_missing", requirement.resolverId);
93
+ if (targets.some((target) => matchesAttachmentAccessSelector(requirement.targetEntity, target.entityId) && !target.recordId)) {
94
+ return deny(403, "invalid_owner", requirement.resolverId);
95
+ }
96
+ }
97
+ if (!context) return deny(403, "policy_context_missing");
98
+ let subject;
99
+ try {
100
+ subject = await withinBudget(() => projectSubject(context, auth, attachment), DEFAULT_TIMEOUT_MS, new AbortController());
101
+ } catch (error) {
102
+ getTelemetryRuntime()?.reportError(new Error("[internal] Attachment subject projection failed"), {
103
+ module: "attachments",
104
+ code: "attachments.subject_projection_failed",
105
+ attributes: { attachmentId: attachment.id, action }
106
+ });
107
+ return deny(error instanceof AccessTimeout ? 504 : 403, "subject_projection_failed");
108
+ }
109
+ const liveBaseline = checkAttachmentAccess({ ...auth, roles: [], isSuperAdmin: subject.isSuperAdmin }, attachment, partition, { requireAuthForPublic: input.requireAuthForPublic });
110
+ if (!liveBaseline.ok) return deny(403, "current_scope");
111
+ for (const resolver of resolvers) {
112
+ const controller = new AbortController();
113
+ let cache = context.resolverCaches.get(resolver.id);
114
+ if (!cache) {
115
+ cache = /* @__PURE__ */ new Map();
116
+ context.resolverCaches.set(resolver.id, cache);
117
+ }
118
+ try {
119
+ const raw = await withinBudget(() => resolver.resolve(Object.freeze({
120
+ action,
121
+ subject,
122
+ attachment: Object.freeze({
123
+ id: attachment.id,
124
+ entityId: attachment.entityId,
125
+ recordId: attachment.recordId,
126
+ partitionCode: attachment.partitionCode,
127
+ tenantId: attachment.tenantId,
128
+ organizationId: attachment.organizationId,
129
+ fileName: attachment.fileName,
130
+ mimeType: attachment.mimeType
131
+ }),
132
+ partition: Object.freeze({
133
+ code: partition.code,
134
+ isPublic: partition.isPublic,
135
+ tenantId: partition.tenantId,
136
+ organizationId: partition.organizationId
137
+ }),
138
+ targets,
139
+ container: context.container,
140
+ cache,
141
+ signal: controller.signal
142
+ })), resolver.timeoutMs ?? DEFAULT_TIMEOUT_MS, controller);
143
+ const decision = attachmentAccessDecisionSchema.parse(raw);
144
+ if (!decision.ok) return deny(decision.status, decision.reason, resolver.id);
145
+ } catch (error) {
146
+ controller.abort();
147
+ getTelemetryRuntime()?.reportError(new Error(error instanceof AccessTimeout ? "[internal] Attachment resolver exceeded its time budget" : "[internal] Attachment resolver failed or returned an invalid decision"), {
148
+ module: "attachments",
149
+ code: "attachments.resolver_failed",
150
+ attributes: { resolverId: resolver.id, attachmentId: attachment.id, action }
151
+ });
152
+ return deny(error instanceof AccessTimeout ? 504 : 403, error instanceof AccessTimeout ? "resolver_timeout" : "resolver_failed", resolver.id);
153
+ }
154
+ }
155
+ return { ok: true, protected: true };
156
+ }
157
+ export {
158
+ createAttachmentAccessContext,
159
+ evaluateAttachmentAccess
160
+ };
161
+ //# sourceMappingURL=access-runner.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/attachments/lib/access-runner.ts"],
4
+ "sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\nimport type { Attachment, AttachmentPartition } from '../data/entities'\nimport { attachmentAccessDecisionSchema, attachmentAccessRequirementsSchema, attachmentAccessSubjectFeaturesSchema } from '../data/validators'\nimport { checkAttachmentAccess } from './access'\nimport {\n collectAttachmentAccessTargets,\n getAttachmentAccessRegistry,\n matchesAttachmentAccessResolver,\n matchesAttachmentAccessSelector,\n matchesAttachmentAccessTargets,\n} from './access-registry'\nimport type {\n AttachmentAccessAction, AttachmentAccessDecision, AttachmentAccessPartition,\n AttachmentAccessRecord, AttachmentAccessSubject,\n} from './access-types'\n\nconst logger = createLogger('attachments').child({ component: 'access' })\nconst DEFAULT_TIMEOUT_MS = 1500\n\ntype RbacProjectionService = {\n loadAcl(subject: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<{\n isSuperAdmin: boolean; organizations: string[] | null\n }>\n getEffectiveFeatures(subject: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<string[]>\n}\n\nexport type AttachmentAccessContext = {\n container: AwilixContainer\n subjects: Map<string, Promise<AttachmentAccessSubject>>\n resolverCaches: Map<string, Map<string, unknown>>\n}\n\nexport function createAttachmentAccessContext(container: AwilixContainer): AttachmentAccessContext {\n return { container, subjects: new Map(), resolverCaches: new Map() }\n}\n\nexport type AttachmentAccessResult = AttachmentAccessDecision & { protected: boolean }\n\nclass AccessTimeout extends Error {}\n\nasync function withinBudget<Result>(work: () => Promise<Result>, timeoutMs: number, controller: AbortController): Promise<Result> {\n let timer: ReturnType<typeof setTimeout> | undefined\n try {\n return await Promise.race([\n Promise.resolve().then(work),\n new Promise<never>((_resolve, reject) => {\n timer = setTimeout(() => {\n controller.abort()\n reject(new AccessTimeout('[internal] Attachment access evaluation timed out'))\n }, timeoutMs)\n }),\n ])\n } finally {\n if (timer) clearTimeout(timer)\n }\n}\n\nasync function projectSubject(\n context: AttachmentAccessContext,\n auth: NonNullable<AuthContext>,\n attachment: AttachmentAccessRecord,\n): Promise<AttachmentAccessSubject> {\n const scope = {\n tenantId: attachment.tenantId ?? auth.tenantId ?? null,\n organizationId: attachment.organizationId ?? auth.orgId ?? null,\n }\n const key = JSON.stringify([auth.sub, scope.tenantId, scope.organizationId])\n let pending = context.subjects.get(key)\n if (!pending) {\n pending = (async () => {\n const rbac = context.container.resolve<RbacProjectionService>('rbacService')\n const acl = await rbac.loadAcl(auth.sub, scope)\n const userFeatures = attachmentAccessSubjectFeaturesSchema.parse(await rbac.getEffectiveFeatures(auth.sub, scope))\n if (typeof acl.isSuperAdmin !== 'boolean') {\n throw new Error('[internal] Invalid attachment subject projection')\n }\n if (!acl.isSuperAdmin && acl.organizations && scope.organizationId\n && !acl.organizations.includes(scope.organizationId) && !acl.organizations.includes('__all__')) {\n throw new Error('[internal] Attachment subject is outside its current organization grants')\n }\n return Object.freeze({\n auth: Object.freeze({\n sub: auth.sub, tenantId: scope.tenantId, orgId: scope.organizationId,\n isApiKey: auth.isApiKey === true, userId: auth.userId, keyId: auth.keyId,\n isSuperAdmin: acl.isSuperAdmin,\n }),\n ...scope,\n userFeatures: Object.freeze([...userFeatures]),\n isSuperAdmin: acl.isSuperAdmin,\n })\n })()\n context.subjects.set(key, pending)\n }\n return pending\n}\n\nexport async function evaluateAttachmentAccess(input: {\n auth: AuthContext | undefined\n attachment: AttachmentAccessRecord\n partition: AttachmentAccessPartition\n action: AttachmentAccessAction\n context?: AttachmentAccessContext | null\n requireAuthForPublic?: boolean\n}): Promise<AttachmentAccessResult> {\n const { auth, attachment, partition, action, context } = input\n const baseline = checkAttachmentAccess(auth, attachment as Attachment, partition as AttachmentPartition, { requireAuthForPublic: input.requireAuthForPublic })\n if (!baseline.ok) return { ok: false, status: baseline.status === 401 ? 401 : 403, reason: 'scope', protected: false }\n const deny = (status: 401 | 403 | 404 | 504, reason: string, resolverId?: string): AttachmentAccessResult => {\n logger.info('Attachment owner access denied', { resolverId, action, attachmentId: attachment.id, reason })\n return { ok: false, status, reason, protected: true }\n }\n const registry = getAttachmentAccessRegistry()\n if (registry.invalid) return deny(403, 'invalid_registry')\n const parsedRequirements = attachmentAccessRequirementsSchema.safeParse(partition.accessResolverRequirements ?? [])\n if (!parsedRequirements.success) return deny(403, 'invalid_requirements')\n const targets = collectAttachmentAccessTargets(attachment)\n const requirements = [...parsedRequirements.data, ...registry.protectedTargets.filter((target) => (\n matchesAttachmentAccessSelector(target.targetPartition, partition.code)\n ))].filter((requirement) => matchesAttachmentAccessTargets(requirement.targetEntity, targets))\n const resolvers = registry.resolvers.filter((resolver) => matchesAttachmentAccessResolver(resolver, partition.code, targets, action))\n if (!requirements.length && !resolvers.length) return { ok: true, protected: false }\n if (!auth) return deny(401, 'authentication_required')\n for (const requirement of requirements) {\n const requiredTargets = targets.filter((target) => matchesAttachmentAccessSelector(requirement.targetEntity, target.entityId))\n const provider = resolvers.find((resolver) => resolver.id === requirement.resolverId)\n if (!provider || (requiredTargets.length\n ? requiredTargets.some((target) => !matchesAttachmentAccessSelector(provider.targetEntity ?? '*', target.entityId))\n : (provider.targetEntity ?? '*') !== '*')) return deny(403, 'required_resolver_missing', requirement.resolverId)\n if (targets.some((target) => matchesAttachmentAccessSelector(requirement.targetEntity, target.entityId) && !target.recordId)) {\n return deny(403, 'invalid_owner', requirement.resolverId)\n }\n }\n if (!context) return deny(403, 'policy_context_missing')\n let subject: AttachmentAccessSubject\n try {\n subject = await withinBudget(() => projectSubject(context, auth, attachment), DEFAULT_TIMEOUT_MS, new AbortController())\n } catch (error) {\n getTelemetryRuntime()?.reportError(new Error('[internal] Attachment subject projection failed'), {\n module: 'attachments', code: 'attachments.subject_projection_failed', attributes: { attachmentId: attachment.id, action },\n })\n return deny(error instanceof AccessTimeout ? 504 : 403, 'subject_projection_failed')\n }\n const liveBaseline = checkAttachmentAccess({ ...auth, roles: [], isSuperAdmin: subject.isSuperAdmin }, attachment as Attachment, partition as AttachmentPartition, { requireAuthForPublic: input.requireAuthForPublic })\n if (!liveBaseline.ok) return deny(403, 'current_scope')\n for (const resolver of resolvers) {\n const controller = new AbortController()\n let cache = context.resolverCaches.get(resolver.id)\n if (!cache) {\n cache = new Map()\n context.resolverCaches.set(resolver.id, cache)\n }\n try {\n const raw = await withinBudget(() => resolver.resolve(Object.freeze({\n action, subject,\n attachment: Object.freeze({\n id: attachment.id, entityId: attachment.entityId, recordId: attachment.recordId,\n partitionCode: attachment.partitionCode, tenantId: attachment.tenantId,\n organizationId: attachment.organizationId, fileName: attachment.fileName, mimeType: attachment.mimeType,\n }),\n partition: Object.freeze({\n code: partition.code, isPublic: partition.isPublic,\n tenantId: partition.tenantId, organizationId: partition.organizationId,\n }),\n targets, container: context.container, cache, signal: controller.signal,\n })), resolver.timeoutMs ?? DEFAULT_TIMEOUT_MS, controller)\n const decision = attachmentAccessDecisionSchema.parse(raw)\n if (!decision.ok) return deny(decision.status, decision.reason, resolver.id)\n } catch (error) {\n controller.abort()\n getTelemetryRuntime()?.reportError(new Error(error instanceof AccessTimeout\n ? '[internal] Attachment resolver exceeded its time budget'\n : '[internal] Attachment resolver failed or returned an invalid decision'), {\n module: 'attachments', code: 'attachments.resolver_failed',\n attributes: { resolverId: resolver.id, attachmentId: attachment.id, action },\n })\n return deny(error instanceof AccessTimeout ? 504 : 403, error instanceof AccessTimeout ? 'resolver_timeout' : 'resolver_failed', resolver.id)\n }\n }\n return { ok: true, protected: true }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=access-types.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": [],
4
+ "sourcesContent": [],
5
+ "mappings": "",
6
+ "names": []
7
+ }
@@ -1,9 +1,11 @@
1
+ import { evaluateAttachmentAccess } from "./access-runner.js";
2
+ import { throwAttachmentAccessError } from "./access-errors.js";
1
3
  import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
2
4
  import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
3
5
  import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
4
6
  import { createLogger } from "@open-mercato/shared/lib/logger";
5
7
  import { Attachment, AttachmentPartition } from "../data/entities.js";
6
- import { assertAttachmentScopeInvariant, checkAttachmentAccess } from "./access.js";
8
+ import { assertAttachmentScopeInvariant } from "./access.js";
7
9
  import { buildAttachmentFileUrl } from "./imageUrls.js";
8
10
  import {
9
11
  isScopedAttachmentUploadError
@@ -126,10 +128,11 @@ class DefaultAttachmentService {
126
128
  * share the one reservation ledger rather than run a parallel quota
127
129
  * mechanism — so its absence surfaces there, at the call that needs it.
128
130
  */
129
- constructor(em, storageDriverFactory, resolveScopedUploadService) {
131
+ constructor(em, storageDriverFactory, resolveScopedUploadService, resolveAccessContext) {
130
132
  this.em = em;
131
133
  this.storageDriverFactory = storageDriverFactory;
132
134
  this.resolveScopedUploadService = resolveScopedUploadService;
135
+ this.resolveAccessContext = resolveAccessContext;
133
136
  }
134
137
  validateUpload(input) {
135
138
  if (!isMultipartRequestWithinUploadLimit(input.contentLength ?? null)) {
@@ -221,10 +224,15 @@ class DefaultAttachmentService {
221
224
  { tenantId: input.auth.tenantId, organizationId: input.auth.orgId }
222
225
  );
223
226
  if (!partition) throw new CrudHttpError(500, { error: "Attachment partition is not configured" });
224
- const access = checkAttachmentAccess(input.auth, attachment, partition, { requireAuthForPublic: true });
225
- if (!access.ok) {
226
- throw new CrudHttpError(access.status, { error: access.status === 401 ? "Unauthorized" : "Forbidden" });
227
- }
227
+ const access = await evaluateAttachmentAccess({
228
+ auth: input.auth,
229
+ attachment,
230
+ partition,
231
+ action: "read",
232
+ context: this.resolveAccessContext?.(),
233
+ requireAuthForPublic: true
234
+ });
235
+ if (!access.ok) await throwAttachmentAccessError(access.status);
228
236
  if (!partitionMatchesScope(partition, input.auth.tenantId, input.auth.orgId)) {
229
237
  throw new CrudHttpError(403, { error: "Attachment partition is not accessible for this scope" });
230
238
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/attachments/lib/attachment-service.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { Attachment, AttachmentPartition } from '../data/entities'\nimport { assertAttachmentScopeInvariant, checkAttachmentAccess } from './access'\nimport type { StorageDriverFactory } from './drivers'\nimport { buildAttachmentFileUrl } from './imageUrls'\nimport {\n isScopedAttachmentUploadError,\n type ScopedAttachmentUploadErrorCode,\n type ScopedAttachmentUploadService,\n} from './scoped-upload-service'\nimport { readAttachmentMetadata, type AttachmentAssignment } from './metadata'\nimport {\n listAttachmentStorageReferences,\n lockAttachmentStorageReferences,\n removeAttachmentRecord,\n resolveAttachmentStorageRelease,\n} from './storageReferences'\nimport {\n buildAttachmentContentDisposition,\n canRenderInlineAttachment,\n hasDangerousExecutableExtension,\n} from './security'\nimport {\n isMultipartRequestWithinUploadLimit,\n resolveAttachmentMaxBytes,\n resolveAttachmentMultipartMaxBytes,\n} from './upload-limits'\n\nconst logger = createLogger('attachments').child({ component: 'attachment-service' })\n\ntype AttachmentErrorMessage = {\n key: string\n fallback: string\n}\n\n/**\n * The scoped upload service reports failures as machine codes. Each maps to the\n * translation key the public attachment route uses for the same condition, and\n * the key is resolved server-side before it reaches the response body \u2014 clients\n * render the `error` field verbatim, so handing them a raw key would surface\n * `attachments.errors.quotaExceeded` as the user-facing message.\n */\nconst SCOPED_UPLOAD_ERROR_MESSAGES: Record<ScopedAttachmentUploadErrorCode, AttachmentErrorMessage> = {\n dangerous_executable: {\n key: 'attachments.errors.dangerousExecutable',\n fallback: 'Executable file types are not allowed as attachments.',\n },\n max_upload_size: {\n key: 'attachments.errors.maxUploadSize',\n fallback: 'Attachment exceeds the maximum upload size.',\n },\n active_content: {\n key: 'attachments.errors.activeContentBlocked',\n fallback: 'Active content uploads are not allowed.',\n },\n partition_unavailable: {\n key: 'attachments.errors.partitionUnavailable',\n fallback: 'Attachment partition is not available for this scope.',\n },\n quota_exceeded: {\n key: 'attachments.errors.quotaExceeded',\n fallback: 'Attachment storage quota exceeded for this tenant.',\n },\n quota_target_exists: {\n key: 'attachments.errors.storagePathExists',\n fallback: 'The target storage path already exists.',\n },\n quota_unavailable: {\n key: 'attachments.errors.quotaUnavailable',\n fallback: 'Storage quota accounting is unavailable.',\n },\n quota_recovery_unsupported: {\n key: 'attachments.errors.quotaRecoveryUnsupported',\n fallback: 'Storage driver cannot participate in quota recovery.',\n },\n storage_failed: {\n key: 'attachments.errors.storageFailed',\n fallback: 'Failed to store the attachment file.',\n },\n persistence_failed: {\n key: 'attachments.errors.persistenceFailed',\n fallback: 'Failed to persist attachment.',\n },\n}\n\nconst UPLOAD_FAILED_MESSAGE: AttachmentErrorMessage = {\n key: 'attachments.errors.uploadFailed',\n fallback: 'Attachment upload failed.',\n}\n\nconst UPLOAD_SERVICE_UNAVAILABLE_MESSAGE: AttachmentErrorMessage = {\n key: 'attachments.errors.uploadServiceUnavailable',\n fallback: 'The attachment upload service is not available.',\n}\n\nasync function translateAttachmentError(message: AttachmentErrorMessage): Promise<string> {\n const { t } = await resolveTranslations()\n return t(message.key, message.fallback)\n}\n\nexport type AttachmentOwner = {\n entityId: string\n recordId: string\n}\n\nexport type CreateScopedAttachmentInput = AttachmentOwner & {\n tenantId: string\n organizationId: string\n partitionCode: string\n fileName: string\n declaredMimeType?: string | null\n buffer: Buffer\n assignments?: AttachmentAssignment[]\n /**\n * Persists a module-owned link inside the same transaction as the Attachment\n * row. The callback receives only the generated id, never an Attachment\n * entity or storage implementation.\n */\n persistLink?: (tx: EntityManager, attachmentId: string) => Promise<void> | void\n}\n\nexport type CreatedScopedAttachment = {\n id: string\n url: string\n fileName: string\n mimeType: string\n fileSize: number\n}\n\nexport type ReadScopedAttachmentInput = {\n attachmentId: string\n auth: NonNullable<AuthContext>\n expectedOwner: AttachmentOwner\n expectedAssignment?: AttachmentAssignment\n expectedPartitionCode?: string\n requirePrivatePartition?: boolean\n forceDownload?: boolean\n}\n\nexport type ReadScopedAttachmentResult = {\n buffer: Buffer\n contentType: string\n contentDisposition: string\n fileName: string\n mimeType: string\n}\n\nexport type ReleaseScopedAttachmentInput = {\n attachmentId: string\n tenantId: string\n organizationId: string\n expectedOwner: AttachmentOwner\n /**\n * Required: releasing an attachment deletes the row *and* the provider bytes,\n * so the caller must name the one assignment it owns. The release only\n * proceeds when that assignment is the attachment's sole reference.\n */\n expectedAssignment: AttachmentAssignment\n expectedPartitionCode?: string\n}\n\nexport type AttachmentProviderCleanup = () => Promise<void>\n\nexport interface AttachmentService {\n validateUpload(input: {\n contentLength?: string | null\n fileName?: string\n fileSize?: number\n }): void\n readUploadForm?(request: Request): Promise<FormData>\n createScoped(input: CreateScopedAttachmentInput): Promise<CreatedScopedAttachment>\n readScoped(input: ReadScopedAttachmentInput): Promise<ReadScopedAttachmentResult>\n /**\n * Permanently deletes an attachment row and its stored object once no other row references it.\n *\n * With `{ flush: false }` call it inside your own transaction and run the returned cleanup after\n * commit; `undefined` means another row still references the stored object.\n *\n * @deprecated Calling it with `{ flush: false }` outside a transaction. It still works for one\n * minor version but logs a warning, and its reference check is not locked, so two racing releases\n * of rows sharing the stored object keep it. Wrap the call in a transaction instead.\n */\n releaseScoped?(\n input: ReleaseScopedAttachmentInput,\n options?: { em?: EntityManager; flush?: boolean },\n ): Promise<AttachmentProviderCleanup | void>\n}\n\nasync function readRequestBodyWithinLimit(request: Request, maxBytes: number): Promise<Uint8Array> {\n const reader = request.body?.getReader()\n if (!reader) throw new CrudHttpError(400, { error: 'File is required' })\n const chunks: Uint8Array[] = []\n let totalBytes = 0\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n totalBytes += value.byteLength\n if (totalBytes > maxBytes) {\n await reader.cancel()\n throw new CrudHttpError(413, { error: 'Attachment exceeds the maximum upload size.' })\n }\n chunks.push(value)\n }\n } finally {\n reader.releaseLock()\n }\n const body = new Uint8Array(totalBytes)\n let offset = 0\n for (const chunk of chunks) {\n body.set(chunk, offset)\n offset += chunk.byteLength\n }\n return body\n}\n\n\nfunction assignmentMatches(candidate: AttachmentAssignment, expected: AttachmentAssignment): boolean {\n return candidate.type === expected.type && candidate.id === expected.id\n}\n\nfunction partitionMatchesScope(\n partition: AttachmentPartition,\n tenantId: string | null | undefined,\n organizationId: string | null | undefined,\n): boolean {\n const partitionTenantId = partition.tenantId ?? null\n const partitionOrganizationId = partition.organizationId ?? null\n if (partitionTenantId === null && partitionOrganizationId === null) return true\n if (partitionTenantId === null || partitionOrganizationId === null) return false\n return partitionTenantId === tenantId && partitionOrganizationId === organizationId\n}\n\nexport class DefaultAttachmentService implements AttachmentService {\n /**\n * The scoped upload service is supplied as a *resolver*, not an instance:\n * resolving it eagerly would drag its own dependencies (notably `dataEngine`)\n * into every container that merely constructs an `attachmentService`, even\n * one that never uploads. `createScoped` requires it \u2014 module uploads must\n * share the one reservation ledger rather than run a parallel quota\n * mechanism \u2014 so its absence surfaces there, at the call that needs it.\n */\n constructor(\n private readonly em: EntityManager,\n private readonly storageDriverFactory: StorageDriverFactory,\n private readonly resolveScopedUploadService?: (() => ScopedAttachmentUploadService | null) | null,\n ) {}\n\n validateUpload(input: {\n contentLength?: string | null\n fileName?: string\n fileSize?: number\n }): void {\n if (!isMultipartRequestWithinUploadLimit(input.contentLength ?? null)) {\n throw new CrudHttpError(413, { error: 'Attachment exceeds the maximum upload size.' })\n }\n if (input.fileName && hasDangerousExecutableExtension(input.fileName)) {\n throw new CrudHttpError(400, { error: 'Executable file types are not allowed as attachments.' })\n }\n if (typeof input.fileSize === 'number' && input.fileSize > resolveAttachmentMaxBytes(null)) {\n throw new CrudHttpError(413, { error: 'Attachment exceeds the maximum upload size.' })\n }\n }\n\n async readUploadForm(request: Request): Promise<FormData> {\n this.validateUpload({ contentLength: request.headers.get('content-length') })\n const contentType = request.headers.get('content-type') ?? ''\n if (!contentType.toLowerCase().includes('multipart/form-data')) {\n throw new CrudHttpError(400, { error: 'Expected multipart/form-data' })\n }\n const body = await readRequestBodyWithinLimit(request, resolveAttachmentMultipartMaxBytes())\n const responseBody = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) as ArrayBuffer\n try {\n return await new Response(responseBody, { headers: { 'content-type': contentType } }).formData()\n } catch {\n throw new CrudHttpError(400, { error: 'Invalid multipart/form-data' })\n }\n }\n\n async createScoped(input: CreateScopedAttachmentInput): Promise<CreatedScopedAttachment> {\n assertAttachmentScopeInvariant(input)\n this.validateUpload({ fileName: input.fileName, fileSize: input.buffer.length })\n\n // Delegate to the platform's scoped upload service rather than repeating its\n // work. It owns the fenced quota lease (reserve \u2192 storing \u2192 stored \u2192\n // complete) that the recovery worker reconciles, writes to the provider\n // outside the database transaction, and emits the attachment CRUD side\n // effects. The previous implementation here ran a *second*, independent\n // quota mechanism on a different advisory-lock key that counted only\n // committed rows, so it could not see an in-flight reservation from the\n // public attachment route and the two paths could jointly exceed a tenant's\n // quota \u2014 and it wrote to storage inside the transaction, so a crash before\n // commit left bytes nothing accounted for.\n let uploadService: ScopedAttachmentUploadService | null = null\n try {\n uploadService = this.resolveScopedUploadService?.() ?? null\n } catch (error) {\n logger.error('Scoped attachment upload service could not be resolved', { err: error })\n }\n if (!uploadService) {\n throw new CrudHttpError(500, {\n error: await translateAttachmentError(UPLOAD_SERVICE_UNAVAILABLE_MESSAGE),\n })\n }\n\n let attachment: Attachment\n try {\n attachment = await uploadService.upload({\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n entityId: input.entityId,\n recordId: input.recordId,\n fileName: input.fileName,\n buffer: input.buffer,\n declaredMimeType: input.declaredMimeType ?? null,\n assignments: input.assignments,\n partitionCode: input.partitionCode,\n requirePrivatePartition: true,\n persistLink: input.persistLink,\n })\n } catch (error) {\n if (isScopedAttachmentUploadError(error)) {\n const message = SCOPED_UPLOAD_ERROR_MESSAGES[error.code] ?? UPLOAD_FAILED_MESSAGE\n throw new CrudHttpError(error.status, { error: await translateAttachmentError(message) })\n }\n throw error\n }\n\n return {\n id: attachment.id,\n url: attachment.url ?? buildAttachmentFileUrl(attachment.id),\n fileName: attachment.fileName,\n mimeType: attachment.mimeType,\n fileSize: attachment.fileSize,\n }\n }\n\n async readScoped(input: ReadScopedAttachmentInput): Promise<ReadScopedAttachmentResult> {\n // Scope the lookup at the database boundary so a foreign-tenant row is\n // never materialized, then keep checkAttachmentAccess below as defense in\n // depth. This service only ever stores fully scoped rows, so global rows\n // and super-admin status deliberately do not widen the requested scope \u2014\n // a super admin reads another tenant's attachment by switching scope, not\n // by bypassing the filter.\n const attachment = await findOneWithDecryption(\n this.em,\n Attachment,\n {\n id: input.attachmentId,\n tenantId: input.auth.tenantId ?? null,\n organizationId: input.auth.orgId ?? null,\n },\n undefined,\n { tenantId: input.auth.tenantId, organizationId: input.auth.orgId },\n )\n if (!attachment) throw new CrudHttpError(404, { error: 'Attachment not found' })\n const partition = await findOneWithDecryption(\n this.em,\n AttachmentPartition,\n { code: attachment.partitionCode },\n undefined,\n { tenantId: input.auth.tenantId, organizationId: input.auth.orgId },\n )\n if (!partition) throw new CrudHttpError(500, { error: 'Attachment partition is not configured' })\n\n const access = checkAttachmentAccess(input.auth, attachment, partition, { requireAuthForPublic: true })\n if (!access.ok) {\n throw new CrudHttpError(access.status, { error: access.status === 401 ? 'Unauthorized' : 'Forbidden' })\n }\n if (!partitionMatchesScope(partition, input.auth.tenantId, input.auth.orgId)) {\n throw new CrudHttpError(403, { error: 'Attachment partition is not accessible for this scope' })\n }\n if (input.requirePrivatePartition && partition.isPublic) {\n throw new CrudHttpError(403, { error: 'Attachment partition is not accessible for this resource' })\n }\n if (input.expectedPartitionCode && attachment.partitionCode !== input.expectedPartitionCode) {\n throw new CrudHttpError(404, { error: 'Attachment not found' })\n }\n if (\n attachment.entityId !== input.expectedOwner.entityId ||\n attachment.recordId !== input.expectedOwner.recordId\n ) {\n throw new CrudHttpError(404, { error: 'Attachment not found' })\n }\n if (input.expectedAssignment) {\n const assignments = readAttachmentMetadata(attachment.storageMetadata).assignments ?? []\n if (!assignments.some((candidate) => assignmentMatches(candidate, input.expectedAssignment!))) {\n throw new CrudHttpError(404, { error: 'Attachment not found' })\n }\n }\n\n const driver = await this.storageDriverFactory.resolveForPartition(attachment.partitionCode, {\n tenantId: attachment.tenantId ?? '',\n organizationId: attachment.organizationId ?? '',\n })\n let result: Awaited<ReturnType<typeof driver.read>>\n try {\n result = await driver.read(attachment.partitionCode, attachment.storagePath)\n } catch {\n throw new CrudHttpError(404, { error: 'File not available' })\n }\n\n const mimeType = attachment.mimeType || 'application/octet-stream'\n const renderInline = !input.forceDownload && canRenderInlineAttachment(mimeType)\n return {\n buffer: result.buffer,\n contentType: renderInline ? result.contentType ?? mimeType : 'application/octet-stream',\n contentDisposition: buildAttachmentContentDisposition(\n attachment.fileName,\n renderInline ? 'inline' : 'attachment',\n ),\n fileName: attachment.fileName,\n mimeType,\n }\n }\n\n async releaseScoped(\n input: ReleaseScopedAttachmentInput,\n options: { em?: EntityManager; flush?: boolean } = {},\n ): Promise<AttachmentProviderCleanup | void> {\n const em = options.em ?? this.em\n // The type makes `expectedAssignment` mandatory, but this service is handed\n // out through untyped DI resolution and consumed through structural ports in\n // other packages, so the shared-reference guard is enforced at runtime too:\n // without it a caller could destroy an attachment other records still link.\n const expectedAssignment = input.expectedAssignment\n if (!expectedAssignment) {\n throw new CrudHttpError(500, {\n error: 'Attachment release requires the expected assignment of the releasing record',\n })\n }\n const isInTransaction = (em as { isInTransaction?: () => boolean }).isInTransaction\n if (\n options.flush !== false\n && typeof isInTransaction === 'function'\n && isInTransaction.call(em)\n ) {\n throw new CrudHttpError(500, {\n error: 'Attachment release inside an ambient transaction requires flush: false and deferred provider cleanup',\n })\n }\n const deferredOutsideTransaction = options.flush === false\n && typeof isInTransaction === 'function'\n && !isInTransaction.call(em)\n if (deferredOutsideTransaction) {\n logger.warn(\n '[deprecated] attachmentService.releaseScoped({ flush: false }) called outside a transaction; wrap it in a transaction so the storage reference lock is held until commit',\n { attachmentId: input.attachmentId },\n )\n }\n const scope = { tenantId: input.tenantId, organizationId: input.organizationId }\n const attachment = await findOneWithDecryption(\n em,\n Attachment,\n {\n id: input.attachmentId,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n },\n undefined,\n scope,\n )\n if (!attachment) throw new CrudHttpError(404, { error: 'Attachment not found' })\n if (\n attachment.entityId !== input.expectedOwner.entityId\n || attachment.recordId !== input.expectedOwner.recordId\n || (input.expectedPartitionCode && attachment.partitionCode !== input.expectedPartitionCode)\n ) {\n throw new CrudHttpError(404, { error: 'Attachment not found' })\n }\n const assignments = readAttachmentMetadata(attachment.storageMetadata).assignments ?? []\n if (!assignments.some((candidate) => assignmentMatches(candidate, expectedAssignment))) {\n throw new CrudHttpError(409, { error: 'Attachment is still referenced by another record' })\n }\n if (assignments.some((candidate) => !assignmentMatches(candidate, expectedAssignment))) {\n throw new CrudHttpError(409, { error: 'Attachment is still referenced by another record' })\n }\n\n const driver = await this.storageDriverFactory.resolveForPartition(attachment.partitionCode, scope)\n const deleteProviderBytes = () => driver.delete(attachment.partitionCode, attachment.storagePath)\n if (options.flush === false) {\n const referenceIds = deferredOutsideTransaction\n ? await listAttachmentStorageReferences(em, attachment)\n : await lockAttachmentStorageReferences(em, attachment)\n const removal = resolveAttachmentStorageRelease(referenceIds, attachment.id)\n if (!removal.removed) throw new CrudHttpError(404, { error: 'Attachment not found' })\n em.remove(attachment)\n return removal.releaseStorage ? deleteProviderBytes : undefined\n }\n const removal = await removeAttachmentRecord(em, attachment)\n if (!removal.removed) throw new CrudHttpError(404, { error: 'Attachment not found' })\n if (removal.releaseStorage) await deleteProviderBytes()\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { evaluateAttachmentAccess, type AttachmentAccessContext } from './access-runner'\nimport { throwAttachmentAccessError } from './access-errors'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { Attachment, AttachmentPartition } from '../data/entities'\nimport { assertAttachmentScopeInvariant, checkAttachmentAccess } from './access'\nimport type { StorageDriverFactory } from './drivers'\nimport { buildAttachmentFileUrl } from './imageUrls'\nimport {\n isScopedAttachmentUploadError,\n type ScopedAttachmentUploadErrorCode,\n type ScopedAttachmentUploadService,\n} from './scoped-upload-service'\nimport { readAttachmentMetadata, type AttachmentAssignment } from './metadata'\nimport {\n listAttachmentStorageReferences,\n lockAttachmentStorageReferences,\n removeAttachmentRecord,\n resolveAttachmentStorageRelease,\n} from './storageReferences'\nimport {\n buildAttachmentContentDisposition,\n canRenderInlineAttachment,\n hasDangerousExecutableExtension,\n} from './security'\nimport {\n isMultipartRequestWithinUploadLimit,\n resolveAttachmentMaxBytes,\n resolveAttachmentMultipartMaxBytes,\n} from './upload-limits'\n\nconst logger = createLogger('attachments').child({ component: 'attachment-service' })\n\ntype AttachmentErrorMessage = {\n key: string\n fallback: string\n}\n\n/**\n * The scoped upload service reports failures as machine codes. Each maps to the\n * translation key the public attachment route uses for the same condition, and\n * the key is resolved server-side before it reaches the response body \u2014 clients\n * render the `error` field verbatim, so handing them a raw key would surface\n * `attachments.errors.quotaExceeded` as the user-facing message.\n */\nconst SCOPED_UPLOAD_ERROR_MESSAGES: Record<ScopedAttachmentUploadErrorCode, AttachmentErrorMessage> = {\n dangerous_executable: {\n key: 'attachments.errors.dangerousExecutable',\n fallback: 'Executable file types are not allowed as attachments.',\n },\n max_upload_size: {\n key: 'attachments.errors.maxUploadSize',\n fallback: 'Attachment exceeds the maximum upload size.',\n },\n active_content: {\n key: 'attachments.errors.activeContentBlocked',\n fallback: 'Active content uploads are not allowed.',\n },\n partition_unavailable: {\n key: 'attachments.errors.partitionUnavailable',\n fallback: 'Attachment partition is not available for this scope.',\n },\n quota_exceeded: {\n key: 'attachments.errors.quotaExceeded',\n fallback: 'Attachment storage quota exceeded for this tenant.',\n },\n quota_target_exists: {\n key: 'attachments.errors.storagePathExists',\n fallback: 'The target storage path already exists.',\n },\n quota_unavailable: {\n key: 'attachments.errors.quotaUnavailable',\n fallback: 'Storage quota accounting is unavailable.',\n },\n quota_recovery_unsupported: {\n key: 'attachments.errors.quotaRecoveryUnsupported',\n fallback: 'Storage driver cannot participate in quota recovery.',\n },\n storage_failed: {\n key: 'attachments.errors.storageFailed',\n fallback: 'Failed to store the attachment file.',\n },\n persistence_failed: {\n key: 'attachments.errors.persistenceFailed',\n fallback: 'Failed to persist attachment.',\n },\n}\n\nconst UPLOAD_FAILED_MESSAGE: AttachmentErrorMessage = {\n key: 'attachments.errors.uploadFailed',\n fallback: 'Attachment upload failed.',\n}\n\nconst UPLOAD_SERVICE_UNAVAILABLE_MESSAGE: AttachmentErrorMessage = {\n key: 'attachments.errors.uploadServiceUnavailable',\n fallback: 'The attachment upload service is not available.',\n}\n\nasync function translateAttachmentError(message: AttachmentErrorMessage): Promise<string> {\n const { t } = await resolveTranslations()\n return t(message.key, message.fallback)\n}\n\nexport type AttachmentOwner = {\n entityId: string\n recordId: string\n}\n\nexport type CreateScopedAttachmentInput = AttachmentOwner & {\n tenantId: string\n organizationId: string\n partitionCode: string\n fileName: string\n declaredMimeType?: string | null\n buffer: Buffer\n assignments?: AttachmentAssignment[]\n /**\n * Persists a module-owned link inside the same transaction as the Attachment\n * row. The callback receives only the generated id, never an Attachment\n * entity or storage implementation.\n */\n persistLink?: (tx: EntityManager, attachmentId: string) => Promise<void> | void\n}\n\nexport type CreatedScopedAttachment = {\n id: string\n url: string\n fileName: string\n mimeType: string\n fileSize: number\n}\n\nexport type ReadScopedAttachmentInput = {\n attachmentId: string\n auth: NonNullable<AuthContext>\n expectedOwner: AttachmentOwner\n expectedAssignment?: AttachmentAssignment\n expectedPartitionCode?: string\n requirePrivatePartition?: boolean\n forceDownload?: boolean\n}\n\nexport type ReadScopedAttachmentResult = {\n buffer: Buffer\n contentType: string\n contentDisposition: string\n fileName: string\n mimeType: string\n}\n\nexport type ReleaseScopedAttachmentInput = {\n attachmentId: string\n tenantId: string\n organizationId: string\n expectedOwner: AttachmentOwner\n /**\n * Required: releasing an attachment deletes the row *and* the provider bytes,\n * so the caller must name the one assignment it owns. The release only\n * proceeds when that assignment is the attachment's sole reference.\n */\n expectedAssignment: AttachmentAssignment\n expectedPartitionCode?: string\n}\n\nexport type AttachmentProviderCleanup = () => Promise<void>\n\nexport interface AttachmentService {\n validateUpload(input: {\n contentLength?: string | null\n fileName?: string\n fileSize?: number\n }): void\n readUploadForm?(request: Request): Promise<FormData>\n createScoped(input: CreateScopedAttachmentInput): Promise<CreatedScopedAttachment>\n readScoped(input: ReadScopedAttachmentInput): Promise<ReadScopedAttachmentResult>\n /**\n * Permanently deletes an attachment row and its stored object once no other row references it.\n *\n * With `{ flush: false }` call it inside your own transaction and run the returned cleanup after\n * commit; `undefined` means another row still references the stored object.\n *\n * @deprecated Calling it with `{ flush: false }` outside a transaction. It still works for one\n * minor version but logs a warning, and its reference check is not locked, so two racing releases\n * of rows sharing the stored object keep it. Wrap the call in a transaction instead.\n */\n releaseScoped?(\n input: ReleaseScopedAttachmentInput,\n options?: { em?: EntityManager; flush?: boolean },\n ): Promise<AttachmentProviderCleanup | void>\n}\n\nasync function readRequestBodyWithinLimit(request: Request, maxBytes: number): Promise<Uint8Array> {\n const reader = request.body?.getReader()\n if (!reader) throw new CrudHttpError(400, { error: 'File is required' })\n const chunks: Uint8Array[] = []\n let totalBytes = 0\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n totalBytes += value.byteLength\n if (totalBytes > maxBytes) {\n await reader.cancel()\n throw new CrudHttpError(413, { error: 'Attachment exceeds the maximum upload size.' })\n }\n chunks.push(value)\n }\n } finally {\n reader.releaseLock()\n }\n const body = new Uint8Array(totalBytes)\n let offset = 0\n for (const chunk of chunks) {\n body.set(chunk, offset)\n offset += chunk.byteLength\n }\n return body\n}\n\n\nfunction assignmentMatches(candidate: AttachmentAssignment, expected: AttachmentAssignment): boolean {\n return candidate.type === expected.type && candidate.id === expected.id\n}\n\nfunction partitionMatchesScope(\n partition: AttachmentPartition,\n tenantId: string | null | undefined,\n organizationId: string | null | undefined,\n): boolean {\n const partitionTenantId = partition.tenantId ?? null\n const partitionOrganizationId = partition.organizationId ?? null\n if (partitionTenantId === null && partitionOrganizationId === null) return true\n if (partitionTenantId === null || partitionOrganizationId === null) return false\n return partitionTenantId === tenantId && partitionOrganizationId === organizationId\n}\n\nexport class DefaultAttachmentService implements AttachmentService {\n /**\n * The scoped upload service is supplied as a *resolver*, not an instance:\n * resolving it eagerly would drag its own dependencies (notably `dataEngine`)\n * into every container that merely constructs an `attachmentService`, even\n * one that never uploads. `createScoped` requires it \u2014 module uploads must\n * share the one reservation ledger rather than run a parallel quota\n * mechanism \u2014 so its absence surfaces there, at the call that needs it.\n */\n constructor(\n private readonly em: EntityManager,\n private readonly storageDriverFactory: StorageDriverFactory,\n private readonly resolveScopedUploadService?: (() => ScopedAttachmentUploadService | null) | null,\n private readonly resolveAccessContext?: (() => AttachmentAccessContext) | null,\n ) {}\n\n validateUpload(input: {\n contentLength?: string | null\n fileName?: string\n fileSize?: number\n }): void {\n if (!isMultipartRequestWithinUploadLimit(input.contentLength ?? null)) {\n throw new CrudHttpError(413, { error: 'Attachment exceeds the maximum upload size.' })\n }\n if (input.fileName && hasDangerousExecutableExtension(input.fileName)) {\n throw new CrudHttpError(400, { error: 'Executable file types are not allowed as attachments.' })\n }\n if (typeof input.fileSize === 'number' && input.fileSize > resolveAttachmentMaxBytes(null)) {\n throw new CrudHttpError(413, { error: 'Attachment exceeds the maximum upload size.' })\n }\n }\n\n async readUploadForm(request: Request): Promise<FormData> {\n this.validateUpload({ contentLength: request.headers.get('content-length') })\n const contentType = request.headers.get('content-type') ?? ''\n if (!contentType.toLowerCase().includes('multipart/form-data')) {\n throw new CrudHttpError(400, { error: 'Expected multipart/form-data' })\n }\n const body = await readRequestBodyWithinLimit(request, resolveAttachmentMultipartMaxBytes())\n const responseBody = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) as ArrayBuffer\n try {\n return await new Response(responseBody, { headers: { 'content-type': contentType } }).formData()\n } catch {\n throw new CrudHttpError(400, { error: 'Invalid multipart/form-data' })\n }\n }\n\n async createScoped(input: CreateScopedAttachmentInput): Promise<CreatedScopedAttachment> {\n assertAttachmentScopeInvariant(input)\n this.validateUpload({ fileName: input.fileName, fileSize: input.buffer.length })\n\n // Delegate to the platform's scoped upload service rather than repeating its\n // work. It owns the fenced quota lease (reserve \u2192 storing \u2192 stored \u2192\n // complete) that the recovery worker reconciles, writes to the provider\n // outside the database transaction, and emits the attachment CRUD side\n // effects. The previous implementation here ran a *second*, independent\n // quota mechanism on a different advisory-lock key that counted only\n // committed rows, so it could not see an in-flight reservation from the\n // public attachment route and the two paths could jointly exceed a tenant's\n // quota \u2014 and it wrote to storage inside the transaction, so a crash before\n // commit left bytes nothing accounted for.\n let uploadService: ScopedAttachmentUploadService | null = null\n try {\n uploadService = this.resolveScopedUploadService?.() ?? null\n } catch (error) {\n logger.error('Scoped attachment upload service could not be resolved', { err: error })\n }\n if (!uploadService) {\n throw new CrudHttpError(500, {\n error: await translateAttachmentError(UPLOAD_SERVICE_UNAVAILABLE_MESSAGE),\n })\n }\n\n let attachment: Attachment\n try {\n attachment = await uploadService.upload({\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n entityId: input.entityId,\n recordId: input.recordId,\n fileName: input.fileName,\n buffer: input.buffer,\n declaredMimeType: input.declaredMimeType ?? null,\n assignments: input.assignments,\n partitionCode: input.partitionCode,\n requirePrivatePartition: true,\n persistLink: input.persistLink,\n })\n } catch (error) {\n if (isScopedAttachmentUploadError(error)) {\n const message = SCOPED_UPLOAD_ERROR_MESSAGES[error.code] ?? UPLOAD_FAILED_MESSAGE\n throw new CrudHttpError(error.status, { error: await translateAttachmentError(message) })\n }\n throw error\n }\n\n return {\n id: attachment.id,\n url: attachment.url ?? buildAttachmentFileUrl(attachment.id),\n fileName: attachment.fileName,\n mimeType: attachment.mimeType,\n fileSize: attachment.fileSize,\n }\n }\n\n async readScoped(input: ReadScopedAttachmentInput): Promise<ReadScopedAttachmentResult> {\n // Scope the lookup at the database boundary so a foreign-tenant row is\n // never materialized, then keep checkAttachmentAccess below as defense in\n // depth. This service only ever stores fully scoped rows, so global rows\n // and super-admin status deliberately do not widen the requested scope \u2014\n // a super admin reads another tenant's attachment by switching scope, not\n // by bypassing the filter.\n const attachment = await findOneWithDecryption(\n this.em,\n Attachment,\n {\n id: input.attachmentId,\n tenantId: input.auth.tenantId ?? null,\n organizationId: input.auth.orgId ?? null,\n },\n undefined,\n { tenantId: input.auth.tenantId, organizationId: input.auth.orgId },\n )\n if (!attachment) throw new CrudHttpError(404, { error: 'Attachment not found' })\n const partition = await findOneWithDecryption(\n this.em,\n AttachmentPartition,\n { code: attachment.partitionCode },\n undefined,\n { tenantId: input.auth.tenantId, organizationId: input.auth.orgId },\n )\n if (!partition) throw new CrudHttpError(500, { error: 'Attachment partition is not configured' })\n\n const access = await evaluateAttachmentAccess({\n auth: input.auth, attachment, partition, action: 'read',\n context: this.resolveAccessContext?.(), requireAuthForPublic: true,\n })\n if (!access.ok) await throwAttachmentAccessError(access.status)\n if (!partitionMatchesScope(partition, input.auth.tenantId, input.auth.orgId)) {\n throw new CrudHttpError(403, { error: 'Attachment partition is not accessible for this scope' })\n }\n if (input.requirePrivatePartition && partition.isPublic) {\n throw new CrudHttpError(403, { error: 'Attachment partition is not accessible for this resource' })\n }\n if (input.expectedPartitionCode && attachment.partitionCode !== input.expectedPartitionCode) {\n throw new CrudHttpError(404, { error: 'Attachment not found' })\n }\n if (\n attachment.entityId !== input.expectedOwner.entityId ||\n attachment.recordId !== input.expectedOwner.recordId\n ) {\n throw new CrudHttpError(404, { error: 'Attachment not found' })\n }\n if (input.expectedAssignment) {\n const assignments = readAttachmentMetadata(attachment.storageMetadata).assignments ?? []\n if (!assignments.some((candidate) => assignmentMatches(candidate, input.expectedAssignment!))) {\n throw new CrudHttpError(404, { error: 'Attachment not found' })\n }\n }\n\n const driver = await this.storageDriverFactory.resolveForPartition(attachment.partitionCode, {\n tenantId: attachment.tenantId ?? '',\n organizationId: attachment.organizationId ?? '',\n })\n let result: Awaited<ReturnType<typeof driver.read>>\n try {\n result = await driver.read(attachment.partitionCode, attachment.storagePath)\n } catch {\n throw new CrudHttpError(404, { error: 'File not available' })\n }\n\n const mimeType = attachment.mimeType || 'application/octet-stream'\n const renderInline = !input.forceDownload && canRenderInlineAttachment(mimeType)\n return {\n buffer: result.buffer,\n contentType: renderInline ? result.contentType ?? mimeType : 'application/octet-stream',\n contentDisposition: buildAttachmentContentDisposition(\n attachment.fileName,\n renderInline ? 'inline' : 'attachment',\n ),\n fileName: attachment.fileName,\n mimeType,\n }\n }\n\n async releaseScoped(\n input: ReleaseScopedAttachmentInput,\n options: { em?: EntityManager; flush?: boolean } = {},\n ): Promise<AttachmentProviderCleanup | void> {\n const em = options.em ?? this.em\n // The type makes `expectedAssignment` mandatory, but this service is handed\n // out through untyped DI resolution and consumed through structural ports in\n // other packages, so the shared-reference guard is enforced at runtime too:\n // without it a caller could destroy an attachment other records still link.\n const expectedAssignment = input.expectedAssignment\n if (!expectedAssignment) {\n throw new CrudHttpError(500, {\n error: 'Attachment release requires the expected assignment of the releasing record',\n })\n }\n const isInTransaction = (em as { isInTransaction?: () => boolean }).isInTransaction\n if (\n options.flush !== false\n && typeof isInTransaction === 'function'\n && isInTransaction.call(em)\n ) {\n throw new CrudHttpError(500, {\n error: 'Attachment release inside an ambient transaction requires flush: false and deferred provider cleanup',\n })\n }\n const deferredOutsideTransaction = options.flush === false\n && typeof isInTransaction === 'function'\n && !isInTransaction.call(em)\n if (deferredOutsideTransaction) {\n logger.warn(\n '[deprecated] attachmentService.releaseScoped({ flush: false }) called outside a transaction; wrap it in a transaction so the storage reference lock is held until commit',\n { attachmentId: input.attachmentId },\n )\n }\n const scope = { tenantId: input.tenantId, organizationId: input.organizationId }\n const attachment = await findOneWithDecryption(\n em,\n Attachment,\n {\n id: input.attachmentId,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n },\n undefined,\n scope,\n )\n if (!attachment) throw new CrudHttpError(404, { error: 'Attachment not found' })\n if (\n attachment.entityId !== input.expectedOwner.entityId\n || attachment.recordId !== input.expectedOwner.recordId\n || (input.expectedPartitionCode && attachment.partitionCode !== input.expectedPartitionCode)\n ) {\n throw new CrudHttpError(404, { error: 'Attachment not found' })\n }\n const assignments = readAttachmentMetadata(attachment.storageMetadata).assignments ?? []\n if (!assignments.some((candidate) => assignmentMatches(candidate, expectedAssignment))) {\n throw new CrudHttpError(409, { error: 'Attachment is still referenced by another record' })\n }\n if (assignments.some((candidate) => !assignmentMatches(candidate, expectedAssignment))) {\n throw new CrudHttpError(409, { error: 'Attachment is still referenced by another record' })\n }\n\n const driver = await this.storageDriverFactory.resolveForPartition(attachment.partitionCode, scope)\n const deleteProviderBytes = () => driver.delete(attachment.partitionCode, attachment.storagePath)\n if (options.flush === false) {\n const referenceIds = deferredOutsideTransaction\n ? await listAttachmentStorageReferences(em, attachment)\n : await lockAttachmentStorageReferences(em, attachment)\n const removal = resolveAttachmentStorageRelease(referenceIds, attachment.id)\n if (!removal.removed) throw new CrudHttpError(404, { error: 'Attachment not found' })\n em.remove(attachment)\n return removal.releaseStorage ? deleteProviderBytes : undefined\n }\n const removal = await removeAttachmentRecord(em, attachment)\n if (!removal.removed) throw new CrudHttpError(404, { error: 'Attachment not found' })\n if (removal.releaseStorage) await deleteProviderBytes()\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["removal"]
7
7
  }
@@ -7,6 +7,7 @@ import { buildAttachmentFileUrl } from "./imageUrls.js";
7
7
  import { mergeAttachmentMetadata, upsertAssignment } from "./metadata.js";
8
8
  import { assertAttachmentScopeInvariant } from "./access.js";
9
9
  import { attachmentCrudEvents, attachmentCrudIndexer } from "./crud.js";
10
+ import { ensureAttachmentPartitionProtection } from "./access-protection.js";
10
11
  async function createAttachmentFromBuffer(input) {
11
12
  const { em } = input;
12
13
  const code = input.partitionCode && input.partitionCode.length > 0 ? input.partitionCode : resolveDefaultPartitionCode(input.entityId);
@@ -16,6 +17,7 @@ async function createAttachmentFromBuffer(input) {
16
17
  partition = await em.findOne(AttachmentPartition, { code });
17
18
  }
18
19
  if (!partition) throw new Error(`[internal] attachment partition "${code}" is not available`);
20
+ await ensureAttachmentPartitionProtection(em, partition);
19
21
  const driver = await new StorageDriverFactory(em).resolveForPartition(partition.code, {
20
22
  tenantId: input.tenantId,
21
23
  organizationId: input.organizationId
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/attachments/lib/createFromBuffer.ts"],
4
- "sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport { Attachment, AttachmentPartition } from '../data/entities'\nimport { StorageDriverFactory } from './drivers'\nimport { ensureDefaultPartitions, resolveDefaultPartitionCode } from './partitions'\nimport { buildAttachmentFileUrl } from './imageUrls'\nimport { mergeAttachmentMetadata, upsertAssignment } from './metadata'\nimport { assertAttachmentScopeInvariant } from './access'\nimport { attachmentCrudEvents, attachmentCrudIndexer } from './crud'\n\n/** DataEngine handle whose exact type is derived from `emitCrudSideEffects` (no `any`). */\ntype CrudDataEngine = Parameters<typeof emitCrudSideEffects>[0]['dataEngine']\n\nexport type CreateAttachmentFromBufferInput = {\n em: EntityManager\n /** When provided, fires CRUD side effects (events + indexing). Omit to skip. */\n dataEngine?: CrudDataEngine\n tenantId: string\n organizationId: string\n /** Domain entity this attachment is linked to (e.g. 'customers:deal'). */\n entityId: string\n recordId: string\n fileName: string\n mimeType: string\n buffer: Buffer\n /** Storage partition; defaults to the entity's default partition. */\n partitionCode?: string | null\n}\n\nexport type CreatedAttachment = {\n id: string\n fileName: string\n mimeType: string\n fileSize: number\n url: string\n}\n\n/**\n * Create a durable `Attachment` from an in-memory buffer, linked to a domain\n * record. Additive reusable seam extracted from the upload route's store block so\n * server-side producers (e.g. the agent file-plane artifact-promotion effector,\n * #12) can materialize an attachment without going through the multipart HTTP\n * route. Resolves the partition + driver, stores the bytes, persists the row\n * atomically, and (when a `dataEngine` is passed) fires CRUD side effects so the\n * attachment is indexed and its events emitted \u2014 exactly like an uploaded file.\n */\nexport async function createAttachmentFromBuffer(input: CreateAttachmentFromBufferInput): Promise<CreatedAttachment> {\n const { em } = input\n const code = (input.partitionCode && input.partitionCode.length > 0)\n ? input.partitionCode\n : resolveDefaultPartitionCode(input.entityId)\n\n let partition = await em.findOne(AttachmentPartition, { code })\n if (!partition) {\n await ensureDefaultPartitions(em)\n partition = await em.findOne(AttachmentPartition, { code })\n }\n if (!partition) throw new Error(`[internal] attachment partition \"${code}\" is not available`)\n\n const driver = await new StorageDriverFactory(em).resolveForPartition(partition.code, {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n })\n const stored = await driver.store({\n partitionCode: partition.code,\n orgId: input.organizationId,\n tenantId: input.tenantId,\n fileName: input.fileName,\n buffer: input.buffer,\n })\n\n const metadata = mergeAttachmentMetadata(null, {\n assignments: upsertAssignment([], { type: input.entityId, id: input.recordId }),\n })\n const attachmentId = randomUUID()\n assertAttachmentScopeInvariant({ tenantId: input.tenantId, organizationId: input.organizationId })\n const attachment = em.create(Attachment, {\n id: attachmentId,\n entityId: input.entityId,\n recordId: input.recordId,\n organizationId: input.organizationId,\n tenantId: input.tenantId,\n fileName: input.fileName,\n mimeType: input.mimeType,\n fileSize: input.buffer.length,\n partitionCode: partition.code,\n storageDriver: partition.storageDriver || 'local',\n storagePath: stored.storagePath,\n url: buildAttachmentFileUrl(attachmentId),\n content: null,\n storageMetadata: metadata,\n })\n await em.transactional(async (tx) => {\n await tx.persist(attachment).flush()\n })\n\n if (input.dataEngine) {\n await emitCrudSideEffects({\n dataEngine: input.dataEngine,\n action: 'created',\n entity: attachment,\n identifiers: {\n id: attachment.id,\n organizationId: attachment.organizationId ?? null,\n tenantId: attachment.tenantId ?? null,\n },\n events: attachmentCrudEvents,\n indexer: attachmentCrudIndexer,\n })\n }\n\n return {\n id: attachmentId,\n fileName: input.fileName,\n mimeType: input.mimeType,\n fileSize: input.buffer.length,\n url: attachment.url,\n }\n}\n"],
5
- "mappings": "AAAA,SAAS,kBAAkB;AAE3B,SAAS,2BAA2B;AACpC,SAAS,YAAY,2BAA2B;AAChD,SAAS,4BAA4B;AACrC,SAAS,yBAAyB,mCAAmC;AACrE,SAAS,8BAA8B;AACvC,SAAS,yBAAyB,wBAAwB;AAC1D,SAAS,sCAAsC;AAC/C,SAAS,sBAAsB,6BAA6B;AAsC5D,eAAsB,2BAA2B,OAAoE;AACnH,QAAM,EAAE,GAAG,IAAI;AACf,QAAM,OAAQ,MAAM,iBAAiB,MAAM,cAAc,SAAS,IAC9D,MAAM,gBACN,4BAA4B,MAAM,QAAQ;AAE9C,MAAI,YAAY,MAAM,GAAG,QAAQ,qBAAqB,EAAE,KAAK,CAAC;AAC9D,MAAI,CAAC,WAAW;AACd,UAAM,wBAAwB,EAAE;AAChC,gBAAY,MAAM,GAAG,QAAQ,qBAAqB,EAAE,KAAK,CAAC;AAAA,EAC5D;AACA,MAAI,CAAC,UAAW,OAAM,IAAI,MAAM,oCAAoC,IAAI,oBAAoB;AAE5F,QAAM,SAAS,MAAM,IAAI,qBAAqB,EAAE,EAAE,oBAAoB,UAAU,MAAM;AAAA,IACpF,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM;AAAA,EACxB,CAAC;AACD,QAAM,SAAS,MAAM,OAAO,MAAM;AAAA,IAChC,eAAe,UAAU;AAAA,IACzB,OAAO,MAAM;AAAA,IACb,UAAU,MAAM;AAAA,IAChB,UAAU,MAAM;AAAA,IAChB,QAAQ,MAAM;AAAA,EAChB,CAAC;AAED,QAAM,WAAW,wBAAwB,MAAM;AAAA,IAC7C,aAAa,iBAAiB,CAAC,GAAG,EAAE,MAAM,MAAM,UAAU,IAAI,MAAM,SAAS,CAAC;AAAA,EAChF,CAAC;AACD,QAAM,eAAe,WAAW;AAChC,iCAA+B,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,eAAe,CAAC;AACjG,QAAM,aAAa,GAAG,OAAO,YAAY;AAAA,IACvC,IAAI;AAAA,IACJ,UAAU,MAAM;AAAA,IAChB,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM;AAAA,IACtB,UAAU,MAAM;AAAA,IAChB,UAAU,MAAM;AAAA,IAChB,UAAU,MAAM;AAAA,IAChB,UAAU,MAAM,OAAO;AAAA,IACvB,eAAe,UAAU;AAAA,IACzB,eAAe,UAAU,iBAAiB;AAAA,IAC1C,aAAa,OAAO;AAAA,IACpB,KAAK,uBAAuB,YAAY;AAAA,IACxC,SAAS;AAAA,IACT,iBAAiB;AAAA,EACnB,CAAC;AACD,QAAM,GAAG,cAAc,OAAO,OAAO;AACnC,UAAM,GAAG,QAAQ,UAAU,EAAE,MAAM;AAAA,EACrC,CAAC;AAED,MAAI,MAAM,YAAY;AACpB,UAAM,oBAAoB;AAAA,MACxB,YAAY,MAAM;AAAA,MAClB,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,aAAa;AAAA,QACX,IAAI,WAAW;AAAA,QACf,gBAAgB,WAAW,kBAAkB;AAAA,QAC7C,UAAU,WAAW,YAAY;AAAA,MACnC;AAAA,MACA,QAAQ;AAAA,MACR,SAAS;AAAA,IACX,CAAC;AAAA,EACH;AAEA,SAAO;AAAA,IACL,IAAI;AAAA,IACJ,UAAU,MAAM;AAAA,IAChB,UAAU,MAAM;AAAA,IAChB,UAAU,MAAM,OAAO;AAAA,IACvB,KAAK,WAAW;AAAA,EAClB;AACF;",
4
+ "sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport { Attachment, AttachmentPartition } from '../data/entities'\nimport { StorageDriverFactory } from './drivers'\nimport { ensureDefaultPartitions, resolveDefaultPartitionCode } from './partitions'\nimport { buildAttachmentFileUrl } from './imageUrls'\nimport { mergeAttachmentMetadata, upsertAssignment } from './metadata'\nimport { assertAttachmentScopeInvariant } from './access'\nimport { attachmentCrudEvents, attachmentCrudIndexer } from './crud'\nimport { ensureAttachmentPartitionProtection } from './access-protection'\n\n/** DataEngine handle whose exact type is derived from `emitCrudSideEffects` (no `any`). */\ntype CrudDataEngine = Parameters<typeof emitCrudSideEffects>[0]['dataEngine']\n\nexport type CreateAttachmentFromBufferInput = {\n em: EntityManager\n /** When provided, fires CRUD side effects (events + indexing). Omit to skip. */\n dataEngine?: CrudDataEngine\n tenantId: string\n organizationId: string\n /** Domain entity this attachment is linked to (e.g. 'customers:deal'). */\n entityId: string\n recordId: string\n fileName: string\n mimeType: string\n buffer: Buffer\n /** Storage partition; defaults to the entity's default partition. */\n partitionCode?: string | null\n}\n\nexport type CreatedAttachment = {\n id: string\n fileName: string\n mimeType: string\n fileSize: number\n url: string\n}\n\n/**\n * Create a durable `Attachment` from an in-memory buffer, linked to a domain\n * record. Additive reusable seam extracted from the upload route's store block so\n * server-side producers (e.g. the agent file-plane artifact-promotion effector,\n * #12) can materialize an attachment without going through the multipart HTTP\n * route. Resolves the partition + driver, stores the bytes, persists the row\n * atomically, and (when a `dataEngine` is passed) fires CRUD side effects so the\n * attachment is indexed and its events emitted \u2014 exactly like an uploaded file.\n */\nexport async function createAttachmentFromBuffer(input: CreateAttachmentFromBufferInput): Promise<CreatedAttachment> {\n const { em } = input\n const code = (input.partitionCode && input.partitionCode.length > 0)\n ? input.partitionCode\n : resolveDefaultPartitionCode(input.entityId)\n\n let partition = await em.findOne(AttachmentPartition, { code })\n if (!partition) {\n await ensureDefaultPartitions(em)\n partition = await em.findOne(AttachmentPartition, { code })\n }\n if (!partition) throw new Error(`[internal] attachment partition \"${code}\" is not available`)\n\n await ensureAttachmentPartitionProtection(em, partition)\n\n const driver = await new StorageDriverFactory(em).resolveForPartition(partition.code, {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n })\n const stored = await driver.store({\n partitionCode: partition.code,\n orgId: input.organizationId,\n tenantId: input.tenantId,\n fileName: input.fileName,\n buffer: input.buffer,\n })\n\n const metadata = mergeAttachmentMetadata(null, {\n assignments: upsertAssignment([], { type: input.entityId, id: input.recordId }),\n })\n const attachmentId = randomUUID()\n assertAttachmentScopeInvariant({ tenantId: input.tenantId, organizationId: input.organizationId })\n const attachment = em.create(Attachment, {\n id: attachmentId,\n entityId: input.entityId,\n recordId: input.recordId,\n organizationId: input.organizationId,\n tenantId: input.tenantId,\n fileName: input.fileName,\n mimeType: input.mimeType,\n fileSize: input.buffer.length,\n partitionCode: partition.code,\n storageDriver: partition.storageDriver || 'local',\n storagePath: stored.storagePath,\n url: buildAttachmentFileUrl(attachmentId),\n content: null,\n storageMetadata: metadata,\n })\n await em.transactional(async (tx) => {\n await tx.persist(attachment).flush()\n })\n\n if (input.dataEngine) {\n await emitCrudSideEffects({\n dataEngine: input.dataEngine,\n action: 'created',\n entity: attachment,\n identifiers: {\n id: attachment.id,\n organizationId: attachment.organizationId ?? null,\n tenantId: attachment.tenantId ?? null,\n },\n events: attachmentCrudEvents,\n indexer: attachmentCrudIndexer,\n })\n }\n\n return {\n id: attachmentId,\n fileName: input.fileName,\n mimeType: input.mimeType,\n fileSize: input.buffer.length,\n url: attachment.url,\n }\n}\n"],
5
+ "mappings": "AAAA,SAAS,kBAAkB;AAE3B,SAAS,2BAA2B;AACpC,SAAS,YAAY,2BAA2B;AAChD,SAAS,4BAA4B;AACrC,SAAS,yBAAyB,mCAAmC;AACrE,SAAS,8BAA8B;AACvC,SAAS,yBAAyB,wBAAwB;AAC1D,SAAS,sCAAsC;AAC/C,SAAS,sBAAsB,6BAA6B;AAC5D,SAAS,2CAA2C;AAsCpD,eAAsB,2BAA2B,OAAoE;AACnH,QAAM,EAAE,GAAG,IAAI;AACf,QAAM,OAAQ,MAAM,iBAAiB,MAAM,cAAc,SAAS,IAC9D,MAAM,gBACN,4BAA4B,MAAM,QAAQ;AAE9C,MAAI,YAAY,MAAM,GAAG,QAAQ,qBAAqB,EAAE,KAAK,CAAC;AAC9D,MAAI,CAAC,WAAW;AACd,UAAM,wBAAwB,EAAE;AAChC,gBAAY,MAAM,GAAG,QAAQ,qBAAqB,EAAE,KAAK,CAAC;AAAA,EAC5D;AACA,MAAI,CAAC,UAAW,OAAM,IAAI,MAAM,oCAAoC,IAAI,oBAAoB;AAE5F,QAAM,oCAAoC,IAAI,SAAS;AAEvD,QAAM,SAAS,MAAM,IAAI,qBAAqB,EAAE,EAAE,oBAAoB,UAAU,MAAM;AAAA,IACpF,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM;AAAA,EACxB,CAAC;AACD,QAAM,SAAS,MAAM,OAAO,MAAM;AAAA,IAChC,eAAe,UAAU;AAAA,IACzB,OAAO,MAAM;AAAA,IACb,UAAU,MAAM;AAAA,IAChB,UAAU,MAAM;AAAA,IAChB,QAAQ,MAAM;AAAA,EAChB,CAAC;AAED,QAAM,WAAW,wBAAwB,MAAM;AAAA,IAC7C,aAAa,iBAAiB,CAAC,GAAG,EAAE,MAAM,MAAM,UAAU,IAAI,MAAM,SAAS,CAAC;AAAA,EAChF,CAAC;AACD,QAAM,eAAe,WAAW;AAChC,iCAA+B,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,eAAe,CAAC;AACjG,QAAM,aAAa,GAAG,OAAO,YAAY;AAAA,IACvC,IAAI;AAAA,IACJ,UAAU,MAAM;AAAA,IAChB,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM;AAAA,IACtB,UAAU,MAAM;AAAA,IAChB,UAAU,MAAM;AAAA,IAChB,UAAU,MAAM;AAAA,IAChB,UAAU,MAAM,OAAO;AAAA,IACvB,eAAe,UAAU;AAAA,IACzB,eAAe,UAAU,iBAAiB;AAAA,IAC1C,aAAa,OAAO;AAAA,IACpB,KAAK,uBAAuB,YAAY;AAAA,IACxC,SAAS;AAAA,IACT,iBAAiB;AAAA,EACnB,CAAC;AACD,QAAM,GAAG,cAAc,OAAO,OAAO;AACnC,UAAM,GAAG,QAAQ,UAAU,EAAE,MAAM;AAAA,EACrC,CAAC;AAED,MAAI,MAAM,YAAY;AACpB,UAAM,oBAAoB;AAAA,MACxB,YAAY,MAAM;AAAA,MAClB,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,aAAa;AAAA,QACX,IAAI,WAAW;AAAA,QACf,gBAAgB,WAAW,kBAAkB;AAAA,QAC7C,UAAU,WAAW,YAAY;AAAA,MACnC;AAAA,MACA,QAAQ;AAAA,MACR,SAAS;AAAA,IACX,CAAC;AAAA,EACH;AAEA,SAAO;AAAA,IACL,IAAI;AAAA,IACJ,UAAU,MAAM;AAAA,IAChB,UAAU,MAAM;AAAA,IAChB,UAAU,MAAM,OAAO;AAAA,IACvB,KAAK,WAAW;AAAA,EAClB;AACF;",
6
6
  "names": []
7
7
  }
@@ -2,6 +2,7 @@ import { AttachmentPartition } from "../data/entities.js";
2
2
  import { resolveDefaultAttachmentOcrEnabled } from "./ocrConfig.js";
3
3
  import { parseBooleanToken } from "@open-mercato/shared/lib/boolean";
4
4
  import { E } from "../../../generated/entities.ids.generated.js";
5
+ import { getDeclaredAttachmentAccessRequirements } from "./access-protection.js";
5
6
  const DEFAULT_ATTACHMENT_PARTITIONS = [
6
7
  {
7
8
  code: "productsMedia",
@@ -30,7 +31,6 @@ async function ensureDefaultPartitions(em) {
30
31
  const existing = await repo.findAll({ fields: ["code"] });
31
32
  const existingCodes = new Set(existing.map((entry) => entry.code));
32
33
  const pending = DEFAULT_ATTACHMENT_PARTITIONS.filter((seed) => !existingCodes.has(seed.code));
33
- if (!pending.length) return;
34
34
  for (const seed of pending) {
35
35
  const record = repo.create({
36
36
  code: seed.code,
@@ -38,11 +38,12 @@ async function ensureDefaultPartitions(em) {
38
38
  description: seed.description ?? null,
39
39
  storageDriver: "local",
40
40
  isPublic: seed.isPublic ?? false,
41
+ accessResolverRequirements: getDeclaredAttachmentAccessRequirements(seed.code),
41
42
  requiresOcr: resolveDefaultAttachmentOcrEnabled()
42
43
  });
43
44
  em.persist(record);
44
45
  }
45
- await em.flush();
46
+ if (pending.length) await em.flush();
46
47
  }
47
48
  function sanitizePartitionCode(input) {
48
49
  const trimmed = input.trim();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/attachments/lib/partitions.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { AttachmentPartition } from '../data/entities'\nimport { resolveDefaultAttachmentOcrEnabled } from './ocrConfig'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { E } from '#generated/entities.ids.generated'\n\nexport type AttachmentPartitionSeed = {\n code: string\n title: string\n description?: string | null\n isPublic?: boolean\n}\n\nexport const DEFAULT_ATTACHMENT_PARTITIONS: AttachmentPartitionSeed[] = [\n {\n code: 'productsMedia',\n title: 'Products media',\n description: 'Public media uploaded for catalog products.',\n isPublic: true,\n },\n {\n code: 'privateAttachments',\n title: 'Private attachments',\n description: 'Internal attachments scoped to tenants and organizations.',\n isPublic: false,\n },\n]\n\nconst PRODUCT_MEDIA_ENTITY_IDS = new Set<string>(\n [(E as any).catalog?.catalog_product].filter(Boolean) as string[]\n)\n\nconst FALLBACK_PARTITION = 'privateAttachments'\n\nexport function resolveDefaultPartitionCode(entityId: string | null | undefined): string {\n if (!entityId) return FALLBACK_PARTITION\n if (PRODUCT_MEDIA_ENTITY_IDS.has(entityId)) return 'productsMedia'\n return FALLBACK_PARTITION\n}\n\nexport async function ensureDefaultPartitions(em: EntityManager): Promise<void> {\n const repo = em.getRepository(AttachmentPartition)\n const existing = await repo.findAll({ fields: ['code'] })\n const existingCodes = new Set(existing.map((entry) => entry.code))\n const pending = DEFAULT_ATTACHMENT_PARTITIONS.filter((seed) => !existingCodes.has(seed.code))\n if (!pending.length) return\n for (const seed of pending) {\n const record = repo.create({\n code: seed.code,\n title: seed.title,\n description: seed.description ?? null,\n storageDriver: 'local',\n isPublic: seed.isPublic ?? false,\n requiresOcr: resolveDefaultAttachmentOcrEnabled(),\n })\n em.persist(record)\n }\n await em.flush()\n}\n\nexport function sanitizePartitionCode(input: string): string {\n const trimmed = input.trim()\n const normalized = trimmed.replace(/[^a-zA-Z0-9_-]/g, '')\n return normalized\n}\n\nexport function isPartitionSettingsLocked(): boolean {\n const demoModeParsed = parseBooleanToken(process.env.DEMO_MODE ?? '')\n const demoModeEnabled = demoModeParsed === false ? false : true\n const onboardingEnabled = parseBooleanToken(process.env.SELF_SERVICE_ONBOARDING_ENABLED ?? '') === true\n return demoModeEnabled || onboardingEnabled\n}\n"],
5
- "mappings": "AACA,SAAS,2BAA2B;AACpC,SAAS,0CAA0C;AACnD,SAAS,yBAAyB;AAClC,SAAS,SAAS;AASX,MAAM,gCAA2D;AAAA,EACtE;AAAA,IACE,MAAM;AAAA,IACN,OAAO;AAAA,IACP,aAAa;AAAA,IACb,UAAU;AAAA,EACZ;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,OAAO;AAAA,IACP,aAAa;AAAA,IACb,UAAU;AAAA,EACZ;AACF;AAEA,MAAM,2BAA2B,IAAI;AAAA,EACnC,CAAE,EAAU,SAAS,eAAe,EAAE,OAAO,OAAO;AACtD;AAEA,MAAM,qBAAqB;AAEpB,SAAS,4BAA4B,UAA6C;AACvF,MAAI,CAAC,SAAU,QAAO;AACtB,MAAI,yBAAyB,IAAI,QAAQ,EAAG,QAAO;AACnD,SAAO;AACT;AAEA,eAAsB,wBAAwB,IAAkC;AAC9E,QAAM,OAAO,GAAG,cAAc,mBAAmB;AACjD,QAAM,WAAW,MAAM,KAAK,QAAQ,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC;AACxD,QAAM,gBAAgB,IAAI,IAAI,SAAS,IAAI,CAAC,UAAU,MAAM,IAAI,CAAC;AACjE,QAAM,UAAU,8BAA8B,OAAO,CAAC,SAAS,CAAC,cAAc,IAAI,KAAK,IAAI,CAAC;AAC5F,MAAI,CAAC,QAAQ,OAAQ;AACrB,aAAW,QAAQ,SAAS;AAC1B,UAAM,SAAS,KAAK,OAAO;AAAA,MACzB,MAAM,KAAK;AAAA,MACX,OAAO,KAAK;AAAA,MACZ,aAAa,KAAK,eAAe;AAAA,MACjC,eAAe;AAAA,MACf,UAAU,KAAK,YAAY;AAAA,MAC3B,aAAa,mCAAmC;AAAA,IAClD,CAAC;AACD,OAAG,QAAQ,MAAM;AAAA,EACnB;AACA,QAAM,GAAG,MAAM;AACjB;AAEO,SAAS,sBAAsB,OAAuB;AAC3D,QAAM,UAAU,MAAM,KAAK;AAC3B,QAAM,aAAa,QAAQ,QAAQ,mBAAmB,EAAE;AACxD,SAAO;AACT;AAEO,SAAS,4BAAqC;AACnD,QAAM,iBAAiB,kBAAkB,QAAQ,IAAI,aAAa,EAAE;AACpE,QAAM,kBAAkB,mBAAmB,QAAQ,QAAQ;AAC3D,QAAM,oBAAoB,kBAAkB,QAAQ,IAAI,mCAAmC,EAAE,MAAM;AACnG,SAAO,mBAAmB;AAC5B;",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { AttachmentPartition } from '../data/entities'\nimport { resolveDefaultAttachmentOcrEnabled } from './ocrConfig'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { E } from '#generated/entities.ids.generated'\nimport { getDeclaredAttachmentAccessRequirements } from './access-protection'\n\nexport type AttachmentPartitionSeed = {\n code: string\n title: string\n description?: string | null\n isPublic?: boolean\n}\n\nexport const DEFAULT_ATTACHMENT_PARTITIONS: AttachmentPartitionSeed[] = [\n {\n code: 'productsMedia',\n title: 'Products media',\n description: 'Public media uploaded for catalog products.',\n isPublic: true,\n },\n {\n code: 'privateAttachments',\n title: 'Private attachments',\n description: 'Internal attachments scoped to tenants and organizations.',\n isPublic: false,\n },\n]\n\nconst PRODUCT_MEDIA_ENTITY_IDS = new Set<string>(\n [(E as any).catalog?.catalog_product].filter(Boolean) as string[]\n)\n\nconst FALLBACK_PARTITION = 'privateAttachments'\n\nexport function resolveDefaultPartitionCode(entityId: string | null | undefined): string {\n if (!entityId) return FALLBACK_PARTITION\n if (PRODUCT_MEDIA_ENTITY_IDS.has(entityId)) return 'productsMedia'\n return FALLBACK_PARTITION\n}\n\nexport async function ensureDefaultPartitions(em: EntityManager): Promise<void> {\n const repo = em.getRepository(AttachmentPartition)\n const existing = await repo.findAll({ fields: ['code'] })\n const existingCodes = new Set(existing.map((entry) => entry.code))\n const pending = DEFAULT_ATTACHMENT_PARTITIONS.filter((seed) => !existingCodes.has(seed.code))\n for (const seed of pending) {\n const record = repo.create({\n code: seed.code,\n title: seed.title,\n description: seed.description ?? null,\n storageDriver: 'local',\n isPublic: seed.isPublic ?? false,\n accessResolverRequirements: getDeclaredAttachmentAccessRequirements(seed.code),\n requiresOcr: resolveDefaultAttachmentOcrEnabled(),\n })\n em.persist(record)\n }\n if (pending.length) await em.flush()\n}\n\nexport function sanitizePartitionCode(input: string): string {\n const trimmed = input.trim()\n const normalized = trimmed.replace(/[^a-zA-Z0-9_-]/g, '')\n return normalized\n}\n\nexport function isPartitionSettingsLocked(): boolean {\n const demoModeParsed = parseBooleanToken(process.env.DEMO_MODE ?? '')\n const demoModeEnabled = demoModeParsed === false ? false : true\n const onboardingEnabled = parseBooleanToken(process.env.SELF_SERVICE_ONBOARDING_ENABLED ?? '') === true\n return demoModeEnabled || onboardingEnabled\n}\n"],
5
+ "mappings": "AACA,SAAS,2BAA2B;AACpC,SAAS,0CAA0C;AACnD,SAAS,yBAAyB;AAClC,SAAS,SAAS;AAClB,SAAS,+CAA+C;AASjD,MAAM,gCAA2D;AAAA,EACtE;AAAA,IACE,MAAM;AAAA,IACN,OAAO;AAAA,IACP,aAAa;AAAA,IACb,UAAU;AAAA,EACZ;AAAA,EACA;AAAA,IACE,MAAM;AAAA,IACN,OAAO;AAAA,IACP,aAAa;AAAA,IACb,UAAU;AAAA,EACZ;AACF;AAEA,MAAM,2BAA2B,IAAI;AAAA,EACnC,CAAE,EAAU,SAAS,eAAe,EAAE,OAAO,OAAO;AACtD;AAEA,MAAM,qBAAqB;AAEpB,SAAS,4BAA4B,UAA6C;AACvF,MAAI,CAAC,SAAU,QAAO;AACtB,MAAI,yBAAyB,IAAI,QAAQ,EAAG,QAAO;AACnD,SAAO;AACT;AAEA,eAAsB,wBAAwB,IAAkC;AAC9E,QAAM,OAAO,GAAG,cAAc,mBAAmB;AACjD,QAAM,WAAW,MAAM,KAAK,QAAQ,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC;AACxD,QAAM,gBAAgB,IAAI,IAAI,SAAS,IAAI,CAAC,UAAU,MAAM,IAAI,CAAC;AACjE,QAAM,UAAU,8BAA8B,OAAO,CAAC,SAAS,CAAC,cAAc,IAAI,KAAK,IAAI,CAAC;AAC5F,aAAW,QAAQ,SAAS;AAC1B,UAAM,SAAS,KAAK,OAAO;AAAA,MACzB,MAAM,KAAK;AAAA,MACX,OAAO,KAAK;AAAA,MACZ,aAAa,KAAK,eAAe;AAAA,MACjC,eAAe;AAAA,MACf,UAAU,KAAK,YAAY;AAAA,MAC3B,4BAA4B,wCAAwC,KAAK,IAAI;AAAA,MAC7E,aAAa,mCAAmC;AAAA,IAClD,CAAC;AACD,OAAG,QAAQ,MAAM;AAAA,EACnB;AACA,MAAI,QAAQ,OAAQ,OAAM,GAAG,MAAM;AACrC;AAEO,SAAS,sBAAsB,OAAuB;AAC3D,QAAM,UAAU,MAAM,KAAK;AAC3B,QAAM,aAAa,QAAQ,QAAQ,mBAAmB,EAAE;AACxD,SAAO;AACT;AAEO,SAAS,4BAAqC;AACnD,QAAM,iBAAiB,kBAAkB,QAAQ,IAAI,aAAa,EAAE;AACpE,QAAM,kBAAkB,mBAAmB,QAAQ,QAAQ;AAC3D,QAAM,oBAAoB,kBAAkB,QAAQ,IAAI,mCAAmC,EAAE,MAAM;AACnG,SAAO,mBAAmB;AAC5B;",
6
6
  "names": []
7
7
  }
@@ -6,6 +6,7 @@ import { createLogger } from "@open-mercato/shared/lib/logger";
6
6
  import { Attachment, AttachmentPartition } from "../data/entities.js";
7
7
  import { attachmentCrudEvents, attachmentCrudIndexer } from "./crud.js";
8
8
  import { buildAttachmentFileUrl } from "./imageUrls.js";
9
+ import { ensureAttachmentPartitionProtection } from "./access-protection.js";
9
10
  import { ensureDefaultPartitions, resolveDefaultPartitionCode } from "./partitions.js";
10
11
  import { extractAttachmentContent } from "./textExtraction.js";
11
12
  import { requestOcrProcessing } from "./ocrQueue.js";
@@ -66,6 +67,7 @@ class ScopedAttachmentUploadService {
66
67
  if (input.requirePrivatePartition && partition.isPublic) {
67
68
  throw new ScopedAttachmentUploadError("partition_unavailable", 403);
68
69
  }
70
+ await ensureAttachmentPartitionProtection(em, partition);
69
71
  const driver = await storageDriverFactory.resolveForPartition(partition.code, {
70
72
  tenantId: input.tenantId,
71
73
  organizationId: input.organizationId