@open-mercato/core 0.6.7-develop.6630.1.c8a615e536 → 0.6.7-develop.6645.1.52f0b37813

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 (28) hide show
  1. package/dist/modules/attachments/components/AttachmentLibrary.js +13 -2
  2. package/dist/modules/attachments/components/AttachmentLibrary.js.map +2 -2
  3. package/dist/modules/catalog/di.js +1 -1
  4. package/dist/modules/catalog/di.js.map +2 -2
  5. package/dist/modules/dashboards/lib/widgets.js +5 -3
  6. package/dist/modules/dashboards/lib/widgets.js.map +2 -2
  7. package/dist/modules/directory/api/get/organizations/lookup.js +51 -3
  8. package/dist/modules/directory/api/get/organizations/lookup.js.map +2 -2
  9. package/dist/modules/directory/api/get/tenants/lookup.js +31 -1
  10. package/dist/modules/directory/api/get/tenants/lookup.js.map +2 -2
  11. package/dist/modules/notifications/di.js +1 -1
  12. package/dist/modules/notifications/di.js.map +2 -2
  13. package/dist/modules/planner/commands/availability-rule-sets.js +37 -15
  14. package/dist/modules/planner/commands/availability-rule-sets.js.map +2 -2
  15. package/dist/modules/planner/commands/availability.js +37 -11
  16. package/dist/modules/planner/commands/availability.js.map +2 -2
  17. package/dist/modules/planner/commands/shared.js +34 -1
  18. package/dist/modules/planner/commands/shared.js.map +2 -2
  19. package/package.json +7 -7
  20. package/src/modules/attachments/components/AttachmentLibrary.tsx +9 -3
  21. package/src/modules/catalog/di.ts +3 -1
  22. package/src/modules/dashboards/lib/widgets.ts +5 -3
  23. package/src/modules/directory/api/get/organizations/lookup.ts +87 -2
  24. package/src/modules/directory/api/get/tenants/lookup.ts +46 -0
  25. package/src/modules/notifications/di.ts +1 -1
  26. package/src/modules/planner/commands/availability-rule-sets.ts +38 -14
  27. package/src/modules/planner/commands/availability.ts +44 -11
  28. package/src/modules/planner/commands/shared.ts +51 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.7-develop.6630.1.c8a615e536",
3
+ "version": "0.6.7-develop.6645.1.52f0b37813",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -254,16 +254,16 @@
254
254
  "zod": "^4.4.3"
255
255
  },
256
256
  "peerDependencies": {
257
- "@open-mercato/ai-assistant": "0.6.7-develop.6630.1.c8a615e536",
258
- "@open-mercato/shared": "0.6.7-develop.6630.1.c8a615e536",
259
- "@open-mercato/ui": "0.6.7-develop.6630.1.c8a615e536",
257
+ "@open-mercato/ai-assistant": "0.6.7-develop.6645.1.52f0b37813",
258
+ "@open-mercato/shared": "0.6.7-develop.6645.1.52f0b37813",
259
+ "@open-mercato/ui": "0.6.7-develop.6645.1.52f0b37813",
260
260
  "react": "^19.0.0",
261
261
  "react-dom": "^19.0.0"
262
262
  },
263
263
  "devDependencies": {
264
- "@open-mercato/ai-assistant": "0.6.7-develop.6630.1.c8a615e536",
265
- "@open-mercato/shared": "0.6.7-develop.6630.1.c8a615e536",
266
- "@open-mercato/ui": "0.6.7-develop.6630.1.c8a615e536",
264
+ "@open-mercato/ai-assistant": "0.6.7-develop.6645.1.52f0b37813",
265
+ "@open-mercato/shared": "0.6.7-develop.6645.1.52f0b37813",
266
+ "@open-mercato/ui": "0.6.7-develop.6645.1.52f0b37813",
267
267
  "@testing-library/dom": "^10.4.1",
268
268
  "@testing-library/jest-dom": "^6.9.1",
269
269
  "@testing-library/react": "^16.3.1",
@@ -199,7 +199,7 @@ type AssignmentsEditorProps = {
199
199
  disabled?: boolean
200
200
  }
201
201
 
202
- function AttachmentAssignmentsEditor({ value, onChange, labels, disabled }: AssignmentsEditorProps) {
202
+ export function AttachmentAssignmentsEditor({ value, onChange, labels, disabled }: AssignmentsEditorProps) {
203
203
  const handleChange = React.useCallback(
204
204
  (index: number, patch: Partial<AssignmentDraft>) => {
205
205
  onChange(value.map((entry, idx) => (idx === index ? { ...entry, ...patch } : entry)))
@@ -229,7 +229,7 @@ function AttachmentAssignmentsEditor({ value, onChange, labels, disabled }: Assi
229
229
  <div className="text-xs text-muted-foreground">No assignments yet.</div>
230
230
  ) : (
231
231
  value.map((entry, index) => (
232
- <div key={`${index}-${entry.type}-${entry.id}`} className="rounded border p-3 space-y-2">
232
+ <div key={index} className="rounded border p-3 space-y-2">
233
233
  <div className="grid gap-2 sm:grid-cols-2">
234
234
  <div className="space-y-1">
235
235
  <label className="text-xs font-medium">{labels.type}</label>
@@ -916,6 +916,7 @@ export function AttachmentLibrary() {
916
916
  className="text-sm text-blue-600 underline"
917
917
  target="_blank"
918
918
  rel="noreferrer"
919
+ onClick={(event) => event.stopPropagation()}
919
920
  >
920
921
  {content}
921
922
  </a>
@@ -961,7 +962,12 @@ export function AttachmentLibrary() {
961
962
  const absolute = resolveAbsoluteUrl(downloadPath)
962
963
  return (
963
964
  <Button variant="ghost" size="icon" asChild>
964
- <a href={absolute} download aria-label={t('attachments.library.table.download', 'Download')}>
965
+ <a
966
+ href={absolute}
967
+ download
968
+ aria-label={t('attachments.library.table.download', 'Download')}
969
+ onClick={(event) => event.stopPropagation()}
970
+ >
965
971
  <Download className="h-4 w-4" />
966
972
  </a>
967
973
  </Button>
@@ -12,7 +12,9 @@ export function register(container: AppContainer) {
12
12
  container.register({
13
13
  catalogPricingService: asFunction(({ eventBus }: AppCradle) => {
14
14
  return new DefaultCatalogPricingService(eventBus ?? null)
15
- }).singleton(),
15
+ })
16
+ .singleton()
17
+ .proxy(),
16
18
  CatalogProduct: asValue(CatalogProduct),
17
19
  CatalogProductPrice: asValue(CatalogProductPrice),
18
20
  })
@@ -1,5 +1,6 @@
1
1
  import type { Module, ModuleDashboardWidgetEntry } from '@open-mercato/shared/modules/registry'
2
2
  import type { DashboardWidgetMetadata, DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
3
+ import { applyDashboardWidgetOverridesToEntries } from '@open-mercato/shared/modules/overrides'
3
4
  import { getModules } from '@open-mercato/shared/lib/i18n/server'
4
5
 
5
6
  type LoadedWidgetModule = DashboardWidgetModule<any> & { metadata: DashboardWidgetMetadata }
@@ -20,13 +21,14 @@ async function loadWidgetEntries(): Promise<WidgetEntry[]> {
20
21
  if (!widgetEntriesPromise) {
21
22
  widgetEntriesPromise = Promise.resolve().then(() => {
22
23
  const list = getModules() as Module[]
23
- return list.flatMap((mod) => {
24
- const entries = mod.dashboardWidgets ?? []
25
- return entries.map((entry) => ({
24
+ const entries = list.flatMap((mod) => {
25
+ const moduleEntries = mod.dashboardWidgets ?? []
26
+ return moduleEntries.map((entry) => ({
26
27
  ...entry,
27
28
  moduleId: mod.id,
28
29
  }))
29
30
  })
31
+ return applyDashboardWidgetOverridesToEntries(entries) as WidgetEntry[]
30
32
  })
31
33
  }
32
34
  return widgetEntriesPromise
@@ -2,8 +2,13 @@ import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
5
6
  import { Organization } from '@open-mercato/core/modules/directory/data/entities'
6
7
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
8
+ import { getCachedRateLimiterService } from '@open-mercato/core/bootstrap'
9
+ import { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'
10
+ import { checkRateLimit, getClientIp, rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'
11
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
7
12
 
8
13
  export const metadata = {
9
14
  path: '/directory/organizations/lookup',
@@ -16,7 +21,76 @@ const orgLookupQuerySchema = z.object({
16
21
  slug: z.string().min(1).max(150),
17
22
  })
18
23
 
24
+ // Unauthenticated pre-auth portal resolver: an anonymous caller can probe any
25
+ // slug, so cap probes per IP to keep slug enumeration bounded and noisy.
26
+ //
27
+ // The cap matches the sibling tenant lookup (60/min/IP) because the legitimate
28
+ // call pattern is the same: useTenantContext resolves the org on every portal
29
+ // page mount without caching, and a whole workforce behind one NAT egress IP —
30
+ // or one reverse proxy — shares this budget. A tripped lookup renders as a
31
+ // missing organization, so the cap must clear real portal traffic by a wide
32
+ // margin. Tune per deployment with RATE_LIMIT_DIRECTORY_ORG_LOOKUP_POINTS /
33
+ // _DURATION / _BLOCK_DURATION.
34
+ const orgLookupIpRateLimitConfig = readEndpointRateLimitConfig('DIRECTORY_ORG_LOOKUP', {
35
+ points: 60,
36
+ duration: 60,
37
+ blockDuration: 60,
38
+ keyPrefix: 'directory-org-lookup',
39
+ })
40
+
41
+ // Structural contract for the OPTIONAL `domainMappingService` owned by
42
+ // `customer_accounts`. Declared locally (never imported) because
43
+ // `customer_accounts` already depends on `directory` — importing it back would
44
+ // invert the dependency and break this module's isomorphism.
45
+ type DomainTenantResolver = {
46
+ resolveByHostname(hostname: string): Promise<{ tenantId: string; status: string } | null>
47
+ }
48
+
49
+ // A branded (custom-domain) portal host already implies a tenant, so bound the
50
+ // slug lookup to it. Returns null on the platform domain, on an unmapped or
51
+ // inactive host, and when the peer module is absent — those keep the global
52
+ // resolution the platform-domain portal bootstrap depends on. The Host header is
53
+ // caller-controlled, so this is containment for legitimate branded traffic, not
54
+ // a trust boundary; the rate limit above is what bounds enumeration.
55
+ async function resolveTenantFromHost(container: AppContainer, req: Request): Promise<string | null> {
56
+ const host = req.headers.get('host')
57
+ if (!host) return null
58
+ try {
59
+ const resolver = container.resolve('domainMappingService') as DomainTenantResolver
60
+ const mapping = await resolver.resolveByHostname(host)
61
+ if (!mapping || mapping.status !== 'active') return null
62
+ return mapping.tenantId
63
+ } catch {
64
+ return null
65
+ }
66
+ }
67
+
68
+ // Fail-open, like auth's checkAuthRateLimit: this resolver sits on the unauthenticated
69
+ // portal bootstrap path, so a limiter outage must degrade to unlimited lookups rather
70
+ // than 500 the portal.
71
+ async function enforceOrgLookupRateLimit(req: Request): Promise<NextResponse | null> {
72
+ try {
73
+ const rateLimiterService = getCachedRateLimiterService()
74
+ if (!rateLimiterService) return null
75
+ const clientIp = getClientIp(req, rateLimiterService.trustProxyDepth)
76
+ if (!clientIp) return null
77
+ const { translate } = await resolveTranslations()
78
+ return await checkRateLimit(
79
+ rateLimiterService,
80
+ orgLookupIpRateLimitConfig,
81
+ clientIp,
82
+ translate('api.errors.rateLimit', 'Too many requests. Please try again later.'),
83
+ )
84
+ } catch {
85
+ return null
86
+ }
87
+ }
88
+
19
89
  export async function GET(req: Request) {
90
+ // Consume the limit before validation or DB work, so junk input cannot bypass it.
91
+ const rateLimitResponse = await enforceOrgLookupRateLimit(req)
92
+ if (rateLimitResponse) return rateLimitResponse
93
+
20
94
  const url = new URL(req.url)
21
95
  const slug = url.searchParams.get('slug') || ''
22
96
  const parsed = orgLookupQuerySchema.safeParse({ slug })
@@ -26,9 +100,18 @@ export async function GET(req: Request) {
26
100
 
27
101
  const container = await createRequestContainer()
28
102
  const em = container.resolve('em') as EntityManager
103
+ const tenantId = await resolveTenantFromHost(container, req)
104
+ // `slug` is unique per (tenant, slug), not globally, so an unscoped match can
105
+ // hit several tenants' rows. Order deterministically so an unscoped collision
106
+ // always resolves to the same organization instead of an arbitrary one.
29
107
  const organization = await em.findOne(
30
108
  Organization,
31
- { slug: parsed.data.slug, deletedAt: null },
109
+ {
110
+ slug: parsed.data.slug,
111
+ deletedAt: null,
112
+ ...(tenantId ? { tenant: tenantId } : {}),
113
+ },
114
+ { orderBy: { createdAt: 'ASC' } },
32
115
  )
33
116
  if (!organization) {
34
117
  return NextResponse.json({ ok: false, error: 'Organization not found.' }, { status: 404 })
@@ -61,7 +144,8 @@ const orgLookupErrorSchema = z.object({
61
144
 
62
145
  const orgLookupDoc: OpenApiMethodDoc = {
63
146
  summary: 'Public organization lookup by slug',
64
- description: 'Resolves organization metadata for portal flows. No authentication required.',
147
+ description:
148
+ 'Resolves organization metadata for portal flows. No authentication required, so the endpoint is rate limited per IP. On a custom-domain host the lookup is scoped to that host\'s tenant.',
65
149
  tags: [lookupTag],
66
150
  query: orgLookupQuerySchema,
67
151
  responses: [
@@ -70,6 +154,7 @@ const orgLookupDoc: OpenApiMethodDoc = {
70
154
  errors: [
71
155
  { status: 400, description: 'Invalid slug', schema: orgLookupErrorSchema },
72
156
  { status: 404, description: 'Organization not found', schema: orgLookupErrorSchema },
157
+ { status: 429, description: 'Too many requests', schema: rateLimitErrorSchema },
73
158
  ],
74
159
  }
75
160
 
@@ -4,6 +4,10 @@ import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { Tenant } from '@open-mercato/core/modules/directory/data/entities'
6
6
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
7
+ import { getCachedRateLimiterService } from '@open-mercato/core/bootstrap'
8
+ import { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'
9
+ import { checkRateLimit, getClientIp, rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'
10
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
7
11
 
8
12
  export const metadata = {
9
13
  path: '/directory/tenants/lookup',
@@ -16,7 +20,48 @@ const tenantLookupQuerySchema = z.object({
16
20
  tenantId: z.string().uuid(),
17
21
  })
18
22
 
23
+ // Unauthenticated login/onboarding resolver: it returns the tenant name to anyone
24
+ // holding the id, so cap probes per IP to keep name-harvesting from leaked ids —
25
+ // and the unauthenticated DB query behind it — bounded and noisy.
26
+ //
27
+ // The cap is deliberately generous: the login screen resolves the tenant on every
28
+ // render and a workforce behind one NAT egress IP shares this budget, while a
29
+ // tripped lookup renders as an invalid tenant. 60/min/IP stays clear of that.
30
+ // Tune per deployment with RATE_LIMIT_DIRECTORY_TENANT_LOOKUP_POINTS / _DURATION /
31
+ // _BLOCK_DURATION.
32
+ const tenantLookupIpRateLimitConfig = readEndpointRateLimitConfig('DIRECTORY_TENANT_LOOKUP', {
33
+ points: 60,
34
+ duration: 60,
35
+ blockDuration: 60,
36
+ keyPrefix: 'directory-tenant-lookup',
37
+ })
38
+
39
+ // Fail-open, like auth's checkAuthRateLimit: this resolver sits on the unauthenticated
40
+ // login bootstrap path, so a limiter outage must degrade to unlimited lookups rather
41
+ // than 500 the login screen.
42
+ async function enforceTenantLookupRateLimit(req: Request): Promise<NextResponse | null> {
43
+ try {
44
+ const rateLimiterService = getCachedRateLimiterService()
45
+ if (!rateLimiterService) return null
46
+ const clientIp = getClientIp(req, rateLimiterService.trustProxyDepth)
47
+ if (!clientIp) return null
48
+ const { translate } = await resolveTranslations()
49
+ return await checkRateLimit(
50
+ rateLimiterService,
51
+ tenantLookupIpRateLimitConfig,
52
+ clientIp,
53
+ translate('api.errors.rateLimit', 'Too many requests. Please try again later.'),
54
+ )
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
19
60
  export async function GET(req: Request) {
61
+ // Consume the limit before validation or DB work, so junk input cannot bypass it.
62
+ const rateLimitResponse = await enforceTenantLookupRateLimit(req)
63
+ if (rateLimitResponse) return rateLimitResponse
64
+
20
65
  const url = new URL(req.url)
21
66
  const tenantId = url.searchParams.get('tenantId') || url.searchParams.get('tenant') || ''
22
67
  const parsed = tenantLookupQuerySchema.safeParse({ tenantId })
@@ -62,6 +107,7 @@ const tenantLookupDoc: OpenApiMethodDoc = {
62
107
  errors: [
63
108
  { status: 400, description: 'Invalid tenant id', schema: tenantLookupErrorSchema },
64
109
  { status: 404, description: 'Tenant not found', schema: tenantLookupErrorSchema },
110
+ { status: 429, description: 'Too many tenant lookups', schema: rateLimitErrorSchema },
65
111
  ],
66
112
  }
67
113
 
@@ -6,6 +6,6 @@ export function register(container: AppContainer): void {
6
6
  container.register({
7
7
  notificationService: asFunction(({ em, eventBus, commandBus }) =>
8
8
  createNotificationService({ em, eventBus, commandBus })
9
- ).scoped(),
9
+ ).scoped().proxy(),
10
10
  })
11
11
  }
@@ -17,7 +17,16 @@ import {
17
17
  } from '../data/validators'
18
18
  import { plannerAvailabilityRuleSetCrudEvents } from '../lib/crud'
19
19
  import { makeCreateRedo } from '@open-mercato/shared/lib/commands/redo'
20
- import { ensureOrganizationScope, ensureTenantScope, extractUndoPayload } from './shared'
20
+ import {
21
+ applyScopeToWhere,
22
+ commandActorScope,
23
+ ensureOrganizationScope,
24
+ ensureTenantScope,
25
+ explicitPlannerCommandScope,
26
+ extractUndoPayload,
27
+ scopeForDecryption,
28
+ type PlannerCommandScope,
29
+ } from './shared'
21
30
  import { E } from '#generated/entities.ids.generated'
22
31
 
23
32
  const availabilityRuleSetCrudIndexer: CrudIndexerConfig<PlannerAvailabilityRuleSet> = {
@@ -43,13 +52,14 @@ type AvailabilityRuleSetUndoPayload = {
43
52
  async function loadAvailabilityRuleSetSnapshot(
44
53
  em: EntityManager,
45
54
  id: string,
55
+ scope: PlannerCommandScope,
46
56
  ): Promise<AvailabilityRuleSetSnapshot | null> {
47
57
  const ruleSet = await findOneWithDecryption(
48
58
  em,
49
59
  PlannerAvailabilityRuleSet,
50
- { id },
60
+ applyScopeToWhere<PlannerAvailabilityRuleSet>({ id }, scope),
51
61
  undefined,
52
- { tenantId: null, organizationId: null },
62
+ scopeForDecryption(scope),
53
63
  )
54
64
  if (!ruleSet) return null
55
65
  const custom = await loadCustomFieldSnapshot(em, {
@@ -114,15 +124,23 @@ const createAvailabilityRuleSetCommand: CommandHandler<PlannerAvailabilityRuleSe
114
124
  })
115
125
  return { ruleSetId: record.id }
116
126
  },
117
- captureAfter: async (_input, result, ctx) => {
127
+ captureAfter: async (input, result, ctx) => {
118
128
  const em = (ctx.container.resolve('em') as EntityManager).fork()
119
- const snapshot = await loadAvailabilityRuleSetSnapshot(em, result.ruleSetId)
129
+ const snapshot = await loadAvailabilityRuleSetSnapshot(
130
+ em,
131
+ result.ruleSetId,
132
+ explicitPlannerCommandScope(input.tenantId, input.organizationId),
133
+ )
120
134
  if (!snapshot) return null
121
135
  return snapshot
122
136
  },
123
- buildLog: async ({ result, ctx }) => {
137
+ buildLog: async ({ input, result, ctx }) => {
124
138
  const em = (ctx.container.resolve('em') as EntityManager).fork()
125
- const snapshot = await loadAvailabilityRuleSetSnapshot(em, result.ruleSetId)
139
+ const snapshot = await loadAvailabilityRuleSetSnapshot(
140
+ em,
141
+ result.ruleSetId,
142
+ explicitPlannerCommandScope(input.tenantId, input.organizationId),
143
+ )
126
144
  if (!snapshot) return null
127
145
  const { translate } = await resolveTranslations()
128
146
  return {
@@ -190,19 +208,20 @@ const updateAvailabilityRuleSetCommand: CommandHandler<PlannerAvailabilityRuleSe
190
208
  async prepare(input, ctx) {
191
209
  const { parsed } = parseWithCustomFields(plannerAvailabilityRuleSetUpdateSchema, input)
192
210
  const em = (ctx.container.resolve('em') as EntityManager)
193
- const snapshot = await loadAvailabilityRuleSetSnapshot(em, parsed.id)
211
+ const snapshot = await loadAvailabilityRuleSetSnapshot(em, parsed.id, commandActorScope(ctx))
194
212
  if (!snapshot) return {}
195
213
  return { before: snapshot }
196
214
  },
197
215
  async execute(input, ctx) {
198
216
  const { parsed, custom } = parseWithCustomFields(plannerAvailabilityRuleSetUpdateSchema, input)
199
217
  const em = (ctx.container.resolve('em') as EntityManager).fork()
218
+ const scope = commandActorScope(ctx)
200
219
  const record = await findOneWithDecryption(
201
220
  em,
202
221
  PlannerAvailabilityRuleSet,
203
- { id: parsed.id, deletedAt: null },
222
+ applyScopeToWhere<PlannerAvailabilityRuleSet>({ id: parsed.id, deletedAt: null }, scope),
204
223
  undefined,
205
- { tenantId: ctx.auth?.tenantId ?? null, organizationId: ctx.auth?.orgId ?? null },
224
+ scopeForDecryption(scope),
206
225
  )
207
226
  if (!record) throw new CrudHttpError(404, { error: 'Planner availability rule set not found.' })
208
227
  ensureTenantScope(ctx, record.tenantId)
@@ -240,7 +259,11 @@ const updateAvailabilityRuleSetCommand: CommandHandler<PlannerAvailabilityRuleSe
240
259
  const before = snapshots.before as AvailabilityRuleSetSnapshot | undefined
241
260
  if (!before) return null
242
261
  const em = (ctx.container.resolve('em') as EntityManager).fork()
243
- const after = await loadAvailabilityRuleSetSnapshot(em, before.id)
262
+ const after = await loadAvailabilityRuleSetSnapshot(
263
+ em,
264
+ before.id,
265
+ explicitPlannerCommandScope(before.tenantId, before.organizationId),
266
+ )
244
267
  if (!after) return null
245
268
  const changes = buildChanges(before as unknown as Record<string, unknown>, after as unknown as Record<string, unknown>, [
246
269
  'name',
@@ -319,7 +342,7 @@ const deleteAvailabilityRuleSetCommand: CommandHandler<{ id?: string }, { ruleSe
319
342
  const id = input?.id
320
343
  if (!id) throw new CrudHttpError(400, { error: 'Availability rule set id is required.' })
321
344
  const em = (ctx.container.resolve('em') as EntityManager)
322
- const snapshot = await loadAvailabilityRuleSetSnapshot(em, id)
345
+ const snapshot = await loadAvailabilityRuleSetSnapshot(em, id, commandActorScope(ctx))
323
346
  if (!snapshot) return {}
324
347
  return { before: snapshot }
325
348
  },
@@ -327,12 +350,13 @@ const deleteAvailabilityRuleSetCommand: CommandHandler<{ id?: string }, { ruleSe
327
350
  const id = input?.id
328
351
  if (!id) throw new CrudHttpError(400, { error: 'Availability rule set id is required.' })
329
352
  const em = (ctx.container.resolve('em') as EntityManager).fork()
353
+ const scope = commandActorScope(ctx)
330
354
  const record = await findOneWithDecryption(
331
355
  em,
332
356
  PlannerAvailabilityRuleSet,
333
- { id, deletedAt: null },
357
+ applyScopeToWhere<PlannerAvailabilityRuleSet>({ id, deletedAt: null }, scope),
334
358
  undefined,
335
- { tenantId: ctx.auth?.tenantId ?? null, organizationId: ctx.auth?.orgId ?? null },
359
+ scopeForDecryption(scope),
336
360
  )
337
361
  if (!record) throw new CrudHttpError(404, { error: 'Planner availability rule set not found.' })
338
362
  ensureTenantScope(ctx, record.tenantId)
@@ -10,9 +10,16 @@ import {
10
10
  type PlannerAvailabilityRuleCreateInput,
11
11
  type PlannerAvailabilityRuleUpdateInput,
12
12
  } from '../data/validators'
13
- import { ensureOrganizationScope, ensureTenantScope } from './shared'
13
+ import {
14
+ applyScopeToWhere,
15
+ commandActorScope,
16
+ ensureOrganizationScope,
17
+ ensureTenantScope,
18
+ explicitPlannerCommandScope,
19
+ extractUndoPayload,
20
+ type PlannerCommandScope,
21
+ } from './shared'
14
22
  import type { PlannerAvailabilityKind, PlannerAvailabilitySubjectType } from '../data/entities'
15
- import { extractUndoPayload } from './shared'
16
23
  import { resolveRedoSnapshot } from '@open-mercato/shared/lib/commands/redo'
17
24
 
18
25
  const AVAILABILITY_RULE_RESOURCE_KIND = 'planner.availability.rule'
@@ -38,8 +45,15 @@ type AvailabilityRuleUndoPayload = {
38
45
  after?: AvailabilityRuleSnapshot | null
39
46
  }
40
47
 
41
- async function loadAvailabilityRuleSnapshot(em: EntityManager, id: string): Promise<AvailabilityRuleSnapshot | null> {
42
- const record = await em.findOne(PlannerAvailabilityRule, { id })
48
+ async function loadAvailabilityRuleSnapshot(
49
+ em: EntityManager,
50
+ id: string,
51
+ scope: PlannerCommandScope,
52
+ ): Promise<AvailabilityRuleSnapshot | null> {
53
+ const record = await em.findOne(
54
+ PlannerAvailabilityRule,
55
+ applyScopeToWhere<PlannerAvailabilityRule>({ id }, scope),
56
+ )
43
57
  if (!record) return null
44
58
  return {
45
59
  id: record.id,
@@ -125,9 +139,13 @@ const createAvailabilityRuleCommand: CommandHandler<PlannerAvailabilityRuleCreat
125
139
  await em.flush()
126
140
  return { ruleId: record.id }
127
141
  },
128
- captureAfter: async (_input, result, ctx) => {
142
+ captureAfter: async (input, result, ctx) => {
129
143
  const em = (ctx.container.resolve('em') as EntityManager).fork()
130
- return loadAvailabilityRuleSnapshot(em, result.ruleId)
144
+ return loadAvailabilityRuleSnapshot(
145
+ em,
146
+ result.ruleId,
147
+ explicitPlannerCommandScope(input.tenantId, input.organizationId),
148
+ )
131
149
  },
132
150
  buildLog: async ({ input, result, ctx, snapshots }) => {
133
151
  const snapshot = snapshots.after as AvailabilityRuleSnapshot | undefined
@@ -171,13 +189,19 @@ const updateAvailabilityRuleCommand: CommandHandler<PlannerAvailabilityRuleUpdat
171
189
  async prepare(input, ctx) {
172
190
  const parsed = plannerAvailabilityRuleUpdateSchema.parse(input)
173
191
  const em = (ctx.container.resolve('em') as EntityManager)
174
- const snapshot = await loadAvailabilityRuleSnapshot(em, parsed.id)
192
+ const snapshot = await loadAvailabilityRuleSnapshot(em, parsed.id, commandActorScope(ctx))
175
193
  return snapshot ? { before: snapshot } : {}
176
194
  },
177
195
  async execute(input, ctx) {
178
196
  const parsed = plannerAvailabilityRuleUpdateSchema.parse(input)
179
197
  const em = (ctx.container.resolve('em') as EntityManager).fork()
180
- const record = await em.findOne(PlannerAvailabilityRule, { id: parsed.id, deletedAt: null })
198
+ const record = await em.findOne(
199
+ PlannerAvailabilityRule,
200
+ applyScopeToWhere<PlannerAvailabilityRule>(
201
+ { id: parsed.id, deletedAt: null },
202
+ commandActorScope(ctx),
203
+ ),
204
+ )
181
205
  if (!record) throw new CrudHttpError(404, { error: 'Planner availability rule not found.' })
182
206
  ensureTenantScope(ctx, record.tenantId)
183
207
  ensureOrganizationScope(ctx, record.organizationId)
@@ -208,7 +232,13 @@ const updateAvailabilityRuleCommand: CommandHandler<PlannerAvailabilityRuleUpdat
208
232
  buildLog: async ({ snapshots, input, result, ctx }) => {
209
233
  const before = snapshots.before as AvailabilityRuleSnapshot | undefined
210
234
  const em = (ctx.container.resolve('em') as EntityManager).fork()
211
- const afterSnapshot = before ? await loadAvailabilityRuleSnapshot(em, before.id) : null
235
+ const afterSnapshot = before
236
+ ? await loadAvailabilityRuleSnapshot(
237
+ em,
238
+ before.id,
239
+ explicitPlannerCommandScope(before.tenantId, before.organizationId),
240
+ )
241
+ : null
212
242
  const { translate } = await resolveTranslations()
213
243
  return {
214
244
  actionLabel: translate('planner.audit.availability.update', 'Update availability rule'),
@@ -242,14 +272,17 @@ const deleteAvailabilityRuleCommand: CommandHandler<{ id?: string }, { ruleId: s
242
272
  const id = input?.id
243
273
  if (!id) return {}
244
274
  const em = (ctx.container.resolve('em') as EntityManager)
245
- const snapshot = await loadAvailabilityRuleSnapshot(em, id)
275
+ const snapshot = await loadAvailabilityRuleSnapshot(em, id, commandActorScope(ctx))
246
276
  return snapshot ? { before: snapshot } : {}
247
277
  },
248
278
  async execute(input, ctx) {
249
279
  const id = input?.id
250
280
  if (!id) throw new CrudHttpError(400, { error: 'Availability rule id is required.' })
251
281
  const em = (ctx.container.resolve('em') as EntityManager).fork()
252
- const record = await em.findOne(PlannerAvailabilityRule, { id, deletedAt: null })
282
+ const record = await em.findOne(
283
+ PlannerAvailabilityRule,
284
+ applyScopeToWhere<PlannerAvailabilityRule>({ id, deletedAt: null }, commandActorScope(ctx)),
285
+ )
253
286
  if (!record) return { ruleId: id }
254
287
  ensureTenantScope(ctx, record.tenantId)
255
288
  ensureOrganizationScope(ctx, record.organizationId)
@@ -1,4 +1,55 @@
1
+ import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
1
2
  import { ensureOrganizationScope, ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'
2
3
  import { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'
4
+ import type { FilterQuery } from '@mikro-orm/postgresql'
3
5
 
4
6
  export { ensureOrganizationScope, ensureTenantScope, extractUndoPayload }
7
+
8
+ export type PlannerCommandScope = {
9
+ tenantId: string | null
10
+ organizationId: string | null
11
+ requireTenant: boolean
12
+ requireOrganization: boolean
13
+ }
14
+
15
+ export function commandActorScope(ctx: CommandRuntimeContext): PlannerCommandScope {
16
+ const isPrivilegedActor = ctx.auth?.isSuperAdmin === true || ctx.systemActor === true
17
+ const tenantId = isPrivilegedActor ? null : (ctx.auth?.tenantId ?? ctx.organizationScope?.tenantId ?? null)
18
+ const organizationId = isPrivilegedActor ? null : (ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null)
19
+ const organizationUnrestricted =
20
+ isPrivilegedActor || (organizationId === null && tenantId !== null && ctx.organizationScope?.allowedIds === null)
21
+ return {
22
+ tenantId,
23
+ organizationId: organizationUnrestricted ? null : organizationId,
24
+ requireTenant: !isPrivilegedActor,
25
+ requireOrganization: !organizationUnrestricted,
26
+ }
27
+ }
28
+
29
+ export function explicitPlannerCommandScope(
30
+ tenantId: string | null,
31
+ organizationId: string | null,
32
+ ): PlannerCommandScope {
33
+ return {
34
+ tenantId,
35
+ organizationId,
36
+ requireTenant: true,
37
+ requireOrganization: true,
38
+ }
39
+ }
40
+
41
+ export function applyScopeToWhere<TEntity extends object>(
42
+ where: FilterQuery<TEntity>,
43
+ scope: PlannerCommandScope,
44
+ ): FilterQuery<TEntity> {
45
+ const scoped = { ...(where as Record<string, unknown>) }
46
+ if (scope.requireTenant || scope.tenantId !== null) scoped.tenantId = scope.tenantId
47
+ if (scope.requireOrganization || scope.organizationId !== null) scoped.organizationId = scope.organizationId
48
+ return scoped as FilterQuery<TEntity>
49
+ }
50
+
51
+ export function scopeForDecryption(
52
+ scope: PlannerCommandScope,
53
+ ): { tenantId: string | null; organizationId: string | null } {
54
+ return { tenantId: scope.tenantId, organizationId: scope.organizationId }
55
+ }