@open-mercato/core 0.6.6-develop.5612.1.d382eb2f33 → 0.6.6-develop.5619.1.29f01e2c42

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 (86) 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 +4 -0
  5. package/dist/generated/entities/attachment_partition/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +3 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/api_keys/data/entities.js +9 -2
  9. package/dist/modules/api_keys/data/entities.js.map +2 -2
  10. package/dist/modules/api_keys/migrations/Migration20260523234901.js +17 -0
  11. package/dist/modules/api_keys/migrations/Migration20260523234901.js.map +7 -0
  12. package/dist/modules/api_keys/services/apiKeyService.js +26 -0
  13. package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
  14. package/dist/modules/attachments/api/partitions/route.js +54 -12
  15. package/dist/modules/attachments/api/partitions/route.js.map +2 -2
  16. package/dist/modules/attachments/data/entities.js +7 -0
  17. package/dist/modules/attachments/data/entities.js.map +2 -2
  18. package/dist/modules/attachments/migrations/Migration20260524000000.js +27 -0
  19. package/dist/modules/attachments/migrations/Migration20260524000000.js.map +7 -0
  20. package/dist/modules/auth/api/roles/route.js +6 -35
  21. package/dist/modules/auth/api/roles/route.js.map +2 -2
  22. package/dist/modules/auth/cli.js +26 -4
  23. package/dist/modules/auth/cli.js.map +2 -2
  24. package/dist/modules/auth/commands/roles.js +34 -11
  25. package/dist/modules/auth/commands/roles.js.map +2 -2
  26. package/dist/modules/auth/lib/roleTenantGuard.js +41 -13
  27. package/dist/modules/auth/lib/roleTenantGuard.js.map +3 -3
  28. package/dist/modules/auth/lib/setup-app.js +88 -26
  29. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  30. package/dist/modules/currencies/api/fetch-configs/route.js +4 -2
  31. package/dist/modules/currencies/api/fetch-configs/route.js.map +2 -2
  32. package/dist/modules/integrations/api/[id]/route.js +5 -1
  33. package/dist/modules/integrations/api/[id]/route.js.map +2 -2
  34. package/dist/modules/integrations/api/route.js +5 -1
  35. package/dist/modules/integrations/api/route.js.map +2 -2
  36. package/dist/modules/integrations/lib/credentials-service.js +6 -4
  37. package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
  38. package/dist/modules/sales/acl.js +6 -0
  39. package/dist/modules/sales/acl.js.map +2 -2
  40. package/dist/modules/sales/api/returns/route.js +43 -3
  41. package/dist/modules/sales/api/returns/route.js.map +2 -2
  42. package/dist/modules/sales/commands/returns.js +473 -213
  43. package/dist/modules/sales/commands/returns.js.map +2 -2
  44. package/dist/modules/sales/commands/shared.js +2 -0
  45. package/dist/modules/sales/commands/shared.js.map +2 -2
  46. package/dist/modules/sales/components/documents/ReturnEditDialog.js +125 -0
  47. package/dist/modules/sales/components/documents/ReturnEditDialog.js.map +7 -0
  48. package/dist/modules/sales/components/documents/ReturnsSection.js +102 -6
  49. package/dist/modules/sales/components/documents/ReturnsSection.js.map +2 -2
  50. package/dist/modules/sales/data/validators.js +13 -0
  51. package/dist/modules/sales/data/validators.js.map +2 -2
  52. package/dist/modules/sales/setup.js +1 -0
  53. package/dist/modules/sales/setup.js.map +2 -2
  54. package/generated/entities/api_key/index.ts +1 -0
  55. package/generated/entities/attachment_partition/index.ts +2 -0
  56. package/generated/entity-fields-registry.ts +3 -0
  57. package/package.json +7 -7
  58. package/src/modules/api_keys/data/entities.ts +15 -1
  59. package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +18 -0
  60. package/src/modules/api_keys/migrations/Migration20260523234901.ts +17 -0
  61. package/src/modules/api_keys/services/apiKeyService.ts +63 -0
  62. package/src/modules/attachments/api/partitions/route.ts +58 -11
  63. package/src/modules/attachments/data/entities.ts +7 -0
  64. package/src/modules/attachments/migrations/.snapshot-open-mercato.json +28 -0
  65. package/src/modules/attachments/migrations/Migration20260524000000.ts +25 -0
  66. package/src/modules/auth/api/roles/route.ts +7 -38
  67. package/src/modules/auth/cli.ts +27 -4
  68. package/src/modules/auth/commands/roles.ts +39 -11
  69. package/src/modules/auth/lib/roleTenantGuard.ts +56 -17
  70. package/src/modules/auth/lib/setup-app.ts +160 -32
  71. package/src/modules/currencies/api/fetch-configs/route.ts +4 -2
  72. package/src/modules/integrations/api/[id]/route.ts +5 -2
  73. package/src/modules/integrations/api/route.ts +5 -2
  74. package/src/modules/integrations/lib/credentials-service.ts +15 -5
  75. package/src/modules/sales/acl.ts +6 -0
  76. package/src/modules/sales/api/returns/route.ts +41 -3
  77. package/src/modules/sales/commands/returns.ts +561 -229
  78. package/src/modules/sales/commands/shared.ts +1 -0
  79. package/src/modules/sales/components/documents/ReturnEditDialog.tsx +157 -0
  80. package/src/modules/sales/components/documents/ReturnsSection.tsx +105 -3
  81. package/src/modules/sales/data/validators.ts +15 -0
  82. package/src/modules/sales/i18n/de.json +11 -0
  83. package/src/modules/sales/i18n/en.json +11 -0
  84. package/src/modules/sales/i18n/es.json +11 -0
  85. package/src/modules/sales/i18n/pl.json +11 -0
  86. package/src/modules/sales/setup.ts +1 -0
@@ -3,6 +3,7 @@ import { z } from 'zod'
3
3
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
4
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
6
7
  import { Attachment, AttachmentPartition } from '../../data/entities'
7
8
  import { ensureDefaultPartitions, DEFAULT_ATTACHMENT_PARTITIONS, sanitizePartitionCode, isPartitionSettingsLocked } from '../../lib/partitions'
8
9
  import { resolvePartitionEnvKey } from '../../lib/partitionEnv'
@@ -23,6 +24,24 @@ const deleteSchema = z.object({
23
24
 
24
25
  const DEFAULT_CODES = new Set(DEFAULT_ATTACHMENT_PARTITIONS.map((entry) => entry.code))
25
26
 
27
+ type AuthLike = { sub?: string; tenantId?: string | null; orgId?: string | null; isSuperAdmin?: boolean } | null | undefined
28
+
29
+ function isSuperAdmin(auth: AuthLike): boolean {
30
+ return auth?.isSuperAdmin === true
31
+ }
32
+
33
+ function canManagePartition(auth: AuthLike, entry: AttachmentPartition): boolean {
34
+ if (!auth?.tenantId) return false
35
+ if (entry.tenantId && entry.tenantId === auth.tenantId) return true
36
+ if (entry.tenantId == null && isSuperAdmin(auth)) return true
37
+ return false
38
+ }
39
+
40
+ function partitionVisibilityFilter(auth: AuthLike) {
41
+ if (isSuperAdmin(auth)) return {}
42
+ return { $or: [{ tenantId: null }, { tenantId: auth?.tenantId ?? null }] }
43
+ }
44
+
26
45
  function serializePartition(entry: AttachmentPartition) {
27
46
  return {
28
47
  id: entry.id,
@@ -54,12 +73,18 @@ async function resolveEm() {
54
73
 
55
74
  export async function GET(req: Request) {
56
75
  const auth = await getAuthFromRequest(req)
57
- if (!auth?.sub) {
76
+ if (!auth?.sub || !auth.tenantId) {
58
77
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
59
78
  }
60
79
  const em = await resolveEm()
61
80
  await ensureDefaultPartitions(em)
62
- const rows = await em.find(AttachmentPartition, {}, { orderBy: { createdAt: 'asc' } })
81
+ const rows = await findWithDecryption(
82
+ em,
83
+ AttachmentPartition,
84
+ partitionVisibilityFilter(auth),
85
+ { orderBy: { createdAt: 'asc' } },
86
+ { tenantId: auth.tenantId, organizationId: auth.orgId ?? null },
87
+ )
63
88
  return NextResponse.json({ items: rows.map((entry) => serializePartition(entry)) })
64
89
  }
65
90
 
@@ -71,7 +96,7 @@ export async function POST(req: Request) {
71
96
  )
72
97
  }
73
98
  const auth = await getAuthFromRequest(req)
74
- if (!auth?.sub) {
99
+ if (!auth?.sub || !auth.tenantId) {
75
100
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
76
101
  }
77
102
  let json: unknown = null
@@ -90,7 +115,13 @@ export async function POST(req: Request) {
90
115
  }
91
116
  const em = await resolveEm()
92
117
  await ensureDefaultPartitions(em)
93
- const exists = await em.findOne(AttachmentPartition, { code })
118
+ const exists = await findOneWithDecryption(
119
+ em,
120
+ AttachmentPartition,
121
+ { code },
122
+ undefined,
123
+ { tenantId: auth.tenantId, organizationId: auth.orgId ?? null },
124
+ )
94
125
  if (exists) {
95
126
  return NextResponse.json({ error: 'Partition code already exists.' }, { status: 409 })
96
127
  }
@@ -106,6 +137,8 @@ export async function POST(req: Request) {
106
137
  ? parsed.data.requiresOcr
107
138
  : resolveDefaultAttachmentOcrEnabled(),
108
139
  ocrModel: parsed.data.ocrModel?.trim() || null,
140
+ tenantId: auth.tenantId,
141
+ organizationId: auth.orgId ?? null,
109
142
  })
110
143
  await em.persist(entry).flush()
111
144
  return NextResponse.json({ item: serializePartition(entry) }, { status: 201 })
@@ -119,7 +152,7 @@ export async function PUT(req: Request) {
119
152
  )
120
153
  }
121
154
  const auth = await getAuthFromRequest(req)
122
- if (!auth?.sub) {
155
+ if (!auth?.sub || !auth.tenantId) {
123
156
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
124
157
  }
125
158
  let json: unknown = null
@@ -133,8 +166,14 @@ export async function PUT(req: Request) {
133
166
  return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
134
167
  }
135
168
  const em = await resolveEm()
136
- const entry = await em.findOne(AttachmentPartition, { id: parsed.data.id })
137
- if (!entry) {
169
+ const entry = await findOneWithDecryption(
170
+ em,
171
+ AttachmentPartition,
172
+ { id: parsed.data.id },
173
+ undefined,
174
+ { tenantId: auth.tenantId, organizationId: auth.orgId ?? null },
175
+ )
176
+ if (!entry || !canManagePartition(auth, entry)) {
138
177
  return NextResponse.json({ error: 'Partition not found' }, { status: 404 })
139
178
  }
140
179
  if (sanitizePartitionCode(parsed.data.code) !== entry.code) {
@@ -167,7 +206,7 @@ export async function DELETE(req: Request) {
167
206
  )
168
207
  }
169
208
  const auth = await getAuthFromRequest(req)
170
- if (!auth?.sub) {
209
+ if (!auth?.sub || !auth.tenantId) {
171
210
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
172
211
  }
173
212
  const url = new URL(req.url)
@@ -177,14 +216,22 @@ export async function DELETE(req: Request) {
177
216
  return NextResponse.json({ error: 'Partition id is required' }, { status: 400 })
178
217
  }
179
218
  const em = await resolveEm()
180
- const entry = await em.findOne(AttachmentPartition, { id: parsed.data.id })
181
- if (!entry) {
219
+ const entry = await findOneWithDecryption(
220
+ em,
221
+ AttachmentPartition,
222
+ { id: parsed.data.id },
223
+ undefined,
224
+ { tenantId: auth.tenantId, organizationId: auth.orgId ?? null },
225
+ )
226
+ if (!entry || !canManagePartition(auth, entry)) {
182
227
  return NextResponse.json({ error: 'Partition not found' }, { status: 404 })
183
228
  }
184
229
  if (DEFAULT_CODES.has(entry.code)) {
185
230
  return NextResponse.json({ error: 'Default partitions cannot be removed.' }, { status: 400 })
186
231
  }
187
- const usage = await em.count(Attachment, { partitionCode: entry.code })
232
+ const usageFilter: Record<string, unknown> = { partitionCode: entry.code }
233
+ if (entry.tenantId) usageFilter.tenantId = entry.tenantId
234
+ const usage = await em.count(Attachment, usageFilter)
188
235
  if (usage > 0) {
189
236
  return NextResponse.json({ error: 'Partition is in use and cannot be removed.' }, { status: 409 })
190
237
  }
@@ -34,6 +34,13 @@ export class AttachmentPartition {
34
34
  @Property({ name: 'ocr_model', type: 'text', nullable: true })
35
35
  ocrModel?: string | null
36
36
 
37
+ @Property({ name: 'organization_id', type: 'uuid', nullable: true })
38
+ organizationId?: string | null
39
+
40
+ @Property({ name: 'tenant_id', type: 'uuid', nullable: true })
41
+ @Index({ name: 'attachment_partitions_tenant_idx' })
42
+ tenantId?: string | null
43
+
37
44
  @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
38
45
  createdAt: Date = new Date()
39
46
 
@@ -270,6 +270,24 @@
270
270
  "nullable": true,
271
271
  "mappedType": "text"
272
272
  },
273
+ "organization_id": {
274
+ "name": "organization_id",
275
+ "type": "uuid",
276
+ "unsigned": false,
277
+ "autoincrement": false,
278
+ "primary": false,
279
+ "nullable": true,
280
+ "mappedType": "uuid"
281
+ },
282
+ "tenant_id": {
283
+ "name": "tenant_id",
284
+ "type": "uuid",
285
+ "unsigned": false,
286
+ "autoincrement": false,
287
+ "primary": false,
288
+ "nullable": true,
289
+ "mappedType": "uuid"
290
+ },
273
291
  "created_at": {
274
292
  "name": "created_at",
275
293
  "type": "timestamptz",
@@ -294,6 +312,16 @@
294
312
  "name": "attachment_partitions",
295
313
  "schema": "public",
296
314
  "indexes": [
315
+ {
316
+ "keyName": "attachment_partitions_tenant_idx",
317
+ "columnNames": [
318
+ "tenant_id"
319
+ ],
320
+ "composite": false,
321
+ "constraint": false,
322
+ "primary": false,
323
+ "unique": false
324
+ },
297
325
  {
298
326
  "keyName": "attachment_partitions_code_unique",
299
327
  "columnNames": [
@@ -0,0 +1,25 @@
1
+ import { Migration } from '@mikro-orm/migrations'
2
+
3
+ export class Migration20260524000000 extends Migration {
4
+ override async up(): Promise<void> {
5
+ this.addSql(
6
+ `alter table "attachment_partitions" add column "organization_id" uuid null;`,
7
+ )
8
+ this.addSql(
9
+ `alter table "attachment_partitions" add column "tenant_id" uuid null;`,
10
+ )
11
+ this.addSql(
12
+ `create index "attachment_partitions_tenant_idx" on "attachment_partitions" ("tenant_id");`,
13
+ )
14
+ }
15
+
16
+ override async down(): Promise<void> {
17
+ this.addSql(`drop index if exists "attachment_partitions_tenant_idx";`)
18
+ this.addSql(
19
+ `alter table "attachment_partitions" drop column "tenant_id";`,
20
+ )
21
+ this.addSql(
22
+ `alter table "attachment_partitions" drop column "organization_id";`,
23
+ )
24
+ }
25
+ }
@@ -14,11 +14,8 @@ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
14
14
  import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
15
15
  import { roleCrudEvents, roleCrudIndexer } from '@open-mercato/core/modules/auth/commands/roles'
16
16
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
17
- import {
18
- assertActorCanAccessRoleTarget,
19
- assertActorCanModifySuperAdminRoleTarget,
20
- } from '@open-mercato/core/modules/auth/lib/grantChecks'
21
- import { enforceTenantSelection } from '@open-mercato/core/modules/auth/lib/tenantAccess'
17
+ import { assertActorCanModifySuperAdminRoleTarget } from '@open-mercato/core/modules/auth/lib/grantChecks'
18
+ import { enforceRoleTenantAccess } from '@open-mercato/core/modules/auth/lib/roleTenantGuard'
22
19
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
23
20
 
24
21
  const querySchema = z.object({
@@ -88,18 +85,8 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
88
85
  create: {
89
86
  commandId: 'auth.roles.create',
90
87
  schema: rawBodySchema,
91
- mapInput: async ({ parsed, ctx }) => {
92
- // Preserve an explicit null so the create command rejects it (400 — global
93
- // roles are unsupported); only resolve string/omitted tenantId against the actor.
94
- if (parsed.tenantId === null) return parsed
95
- const requestedTenantId = typeof parsed.tenantId === 'string' ? parsed.tenantId : undefined
96
- const resolvedTenantId = await enforceTenantSelection(
97
- { auth: ctx.auth, container: ctx.container },
98
- requestedTenantId,
99
- )
100
- if (resolvedTenantId) parsed.tenantId = resolvedTenantId
101
- return parsed
102
- },
88
+ mapInput: async ({ parsed, ctx }) =>
89
+ enforceRoleTenantAccess('create', parsed, { auth: ctx.auth, container: ctx.container }),
103
90
  response: ({ result }) => ({ id: String(result.id) }),
104
91
  status: 201,
105
92
  },
@@ -109,9 +96,8 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
109
96
  mapInput: async ({ parsed, ctx }) => {
110
97
  if (ctx.request && typeof parsed.id === 'string' && parsed.id.length) {
111
98
  await assertCanModifySuperAdminRole(ctx.request, parsed.id)
112
- await assertCanAccessRoleTarget(ctx.request, parsed.id)
113
99
  }
114
- return parsed
100
+ return enforceRoleTenantAccess('update', parsed, { auth: ctx.auth, container: ctx.container })
115
101
  },
116
102
  response: () => ({ ok: true }),
117
103
  },
@@ -121,9 +107,8 @@ const crud = makeCrudRoute<CrudInput, CrudInput, Record<string, unknown>>({
121
107
  const targetId = resolveDeleteTargetId(parsed, raw)
122
108
  if (ctx.request && targetId) {
123
109
  await assertCanModifySuperAdminRole(ctx.request, targetId)
124
- await assertCanAccessRoleTarget(ctx.request, targetId)
125
110
  }
126
- return parsed
111
+ return enforceRoleTenantAccess('delete', parsed, { auth: ctx.auth, container: ctx.container })
127
112
  },
128
113
  response: () => ({ ok: true }),
129
114
  },
@@ -275,7 +260,6 @@ export const DELETE = async (req: Request) => {
275
260
  if (targetId) {
276
261
  try {
277
262
  await assertCanModifySuperAdminRole(req, targetId)
278
- await assertCanAccessRoleTarget(req, targetId)
279
263
  } catch (err) {
280
264
  if (err instanceof CrudHttpError) {
281
265
  return NextResponse.json(err.body, { status: err.status })
@@ -301,21 +285,6 @@ async function assertCanModifySuperAdminRole(req: Request, targetRoleId: string)
301
285
  })
302
286
  }
303
287
 
304
- async function assertCanAccessRoleTarget(req: Request, targetRoleId: string) {
305
- const auth = await getAuthFromRequest(req)
306
- if (!auth?.sub) throw new CrudHttpError(401, { error: 'Unauthorized' })
307
- const container = await createRequestContainer()
308
- const em = container.resolve('em') as EntityManager
309
- await assertActorCanAccessRoleTarget({
310
- em,
311
- rbacService: container.resolve('rbacService') as RbacService,
312
- actorUserId: auth.sub,
313
- tenantId: auth.tenantId ?? null,
314
- organizationId: auth.orgId ?? null,
315
- targetRoleId,
316
- })
317
- }
318
-
319
288
  function resolveDeleteTargetId(parsed: unknown, raw: unknown): string | null {
320
289
  const fromParsed = readId(parsed as Record<string, unknown> | null | undefined)
321
290
  if (fromParsed) return fromParsed
@@ -343,7 +312,7 @@ export const openApi: OpenApiRouteDoc = {
343
312
  },
344
313
  POST: {
345
314
  summary: 'Create role',
346
- description: 'Creates a new role for the current tenant or globally when `tenantId` is omitted.',
315
+ description: 'Creates a new role anchored to the caller\'s tenant. Non-superadmins cannot target another tenant; supplying a foreign `tenantId` is rejected.',
347
316
  requestBody: {
348
317
  contentType: 'application/json',
349
318
  schema: roleCreateSchema,
@@ -5,7 +5,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
5
5
  import { User, Role, UserRole } from '@open-mercato/core/modules/auth/data/entities'
6
6
  import { Tenant, Organization } from '@open-mercato/core/modules/directory/data/entities'
7
7
  import { rebuildHierarchyForTenant } from '@open-mercato/core/modules/directory/lib/hierarchy'
8
- import { ensureRoles, setupInitialTenant, ensureDefaultRoleAcls, ensureCustomRoleAcls, OrgSlugExistsError } from './lib/setup-app'
8
+ import { ensureRoles, setupInitialTenant, ensureDefaultRoleAcls, ensureCustomRoleAcls, OrgSlugExistsError, DerivedUserPasswordRequiredError } from './lib/setup-app'
9
9
  import { normalizeTenantId } from './lib/tenantAccess'
10
10
  import { computeEmailHash, emailHashLookupValues } from './lib/emailHash'
11
11
  import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
@@ -401,7 +401,7 @@ const addOrganization: ModuleCli = {
401
401
  },
402
402
  }
403
403
 
404
- const SETUP_USAGE = 'Usage: mercato auth setup --orgName <name> --email <email> --password <password> [--orgSlug <slug>] [--roles superadmin,admin,employee] [--skip-password-policy] [--with-examples] [--json]'
404
+ const SETUP_USAGE = 'Usage: mercato auth setup --orgName <name> --email <email> --password <password> [--orgSlug <slug>] [--roles superadmin,admin,employee] [--skip-password-policy] [--include-demo-users] [--with-examples] [--json]'
405
405
 
406
406
  const setupApp: ModuleCli = {
407
407
  command: 'setup',
@@ -440,6 +440,10 @@ const setupApp: ModuleCli = {
440
440
  const withExamples = typeof withExamplesRaw === 'boolean'
441
441
  ? withExamplesRaw
442
442
  : parseBooleanToken(typeof withExamplesRaw === 'string' ? withExamplesRaw : null) ?? false
443
+ const includeDemoUsersRaw = args['include-demo-users'] ?? args.includeDemoUsers
444
+ const includeDemoUsers = typeof includeDemoUsersRaw === 'boolean'
445
+ ? includeDemoUsersRaw
446
+ : parseBooleanToken(typeof includeDemoUsersRaw === 'string' ? includeDemoUsersRaw : null) ?? false
443
447
  const jsonModeRaw = args.json
444
448
  const jsonMode = typeof jsonModeRaw === 'boolean'
445
449
  ? jsonModeRaw
@@ -489,7 +493,14 @@ const setupApp: ModuleCli = {
489
493
  orgSlug,
490
494
  roleNames,
491
495
  primaryUser: { email, password, confirm: true },
492
- includeDerivedUsers: true,
496
+ // Derived admin/employee demo accounts only land when the operator
497
+ // explicitly opts in via --include-demo-users. Default-deny prevents
498
+ // production deployments from silently seeding well-known emails.
499
+ includeDerivedUsers: includeDemoUsers,
500
+ // When demo users are explicitly requested, the operator has consented
501
+ // to having admin@/employee@ accounts created with autogenerated
502
+ // passwords (surfaced in stdout) when no env override is provided.
503
+ allowDemoDerivedPasswords: includeDemoUsers,
493
504
  // When the caller passes an explicit slug, treat it as a "fresh tenant"
494
505
  // signal — silent reuse of an existing user's tenant defeats the point
495
506
  // of slugging the new tenant for downstream tooling.
@@ -537,7 +548,12 @@ const setupApp: ModuleCli = {
537
548
  if (env.NODE_ENV !== 'test') {
538
549
  for (const snapshot of result.users) {
539
550
  if (snapshot.created) {
540
- console.log('🎉 Created user', snapshot.user.email)
551
+ if (snapshot.generatedPassword) {
552
+ console.log('⚠️ GENERATED password — copy now; it is not stored in plain text')
553
+ console.log('🎉 Created user', snapshot.user.email, 'password:', snapshot.generatedPassword)
554
+ } else {
555
+ console.log('🎉 Created user', snapshot.user.email)
556
+ }
541
557
  } else {
542
558
  console.log(`Updated user ${snapshot.user.email}`)
543
559
  }
@@ -552,6 +568,13 @@ const setupApp: ModuleCli = {
552
568
  process.exitCode = 1
553
569
  return
554
570
  }
571
+ if (err instanceof DerivedUserPasswordRequiredError) {
572
+ process.stderr.write(
573
+ `Setup aborted: ${err.message}. In production, set OM_INIT_ADMIN_PASSWORD and OM_INIT_EMPLOYEE_PASSWORD before passing --include-demo-users, or omit --include-demo-users to skip derived demo accounts entirely.\n`,
574
+ )
575
+ process.exitCode = 2
576
+ return
577
+ }
555
578
  if (err instanceof Error && err.message === 'USER_EXISTS') {
556
579
  process.stderr.write('Setup aborted: user already exists with the provided email.\n')
557
580
  process.exitCode = 1
@@ -24,7 +24,7 @@ import {
24
24
  } from '@open-mercato/shared/lib/commands/customFieldSnapshots'
25
25
  import { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'
26
26
  import { resolveRedoSnapshot } from '@open-mercato/shared/lib/commands/redo'
27
- import { normalizeTenantId } from '@open-mercato/core/modules/auth/lib/tenantAccess'
27
+ import { resolveIsSuperAdmin, normalizeTenantId } from '@open-mercato/core/modules/auth/lib/tenantAccess'
28
28
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
29
29
 
30
30
  type SerializedRole = {
@@ -84,6 +84,22 @@ function assertRoleNameAllowed(name: string | undefined | null) {
84
84
  }
85
85
  }
86
86
 
87
+ type ResolvedActorScope = { isSuperAdmin: boolean; actorTenantId: string | null }
88
+
89
+ async function resolveActorScope(ctx: { auth: { tenantId?: string | null; sub?: string | null; orgId?: string | null; isSuperAdmin?: boolean } | null; container: { resolve<T = unknown>(name: string): T } }): Promise<ResolvedActorScope> {
90
+ const isSuperAdmin = await resolveIsSuperAdmin(ctx)
91
+ const actorTenantId = normalizeTenantId(ctx.auth?.tenantId ?? null) ?? null
92
+ return { isSuperAdmin, actorTenantId }
93
+ }
94
+
95
+ function buildScopedRoleFilter(roleId: string, scope: ResolvedActorScope): FilterQuery<Role> {
96
+ const filter: FilterQuery<Role> = { id: roleId, deletedAt: null }
97
+ if (!scope.isSuperAdmin) {
98
+ ;(filter as Record<string, unknown>).tenantId = scope.actorTenantId
99
+ }
100
+ return filter
101
+ }
102
+
87
103
  const createSchema = z.object({
88
104
  name: z.string().min(2).max(100),
89
105
  tenantId: z.string().uuid().optional(),
@@ -128,7 +144,14 @@ const createRoleCommand: CommandHandler<Record<string, unknown>, Role> = {
128
144
  }
129
145
  const { parsed, custom } = parseWithCustomFields(createSchema, rawInput)
130
146
  assertRoleNameAllowed(parsed.name)
131
- const resolvedTenantId = parsed.tenantId ?? ctx.auth?.tenantId ?? null
147
+ const scope = await resolveActorScope(ctx)
148
+ const requestedTenantId = parsed.tenantId ?? null
149
+ if (!scope.isSuperAdmin && requestedTenantId && requestedTenantId !== scope.actorTenantId) {
150
+ throw new CrudHttpError(403, { error: 'Not authorized to target this tenant.' })
151
+ }
152
+ const resolvedTenantId = scope.isSuperAdmin
153
+ ? (requestedTenantId ?? scope.actorTenantId)
154
+ : scope.actorTenantId
132
155
  if (!resolvedTenantId) {
133
156
  throw new CrudHttpError(400, { error: 'tenantId is required — global roles are not supported' })
134
157
  }
@@ -279,7 +302,8 @@ const updateRoleCommand: CommandHandler<Record<string, unknown>, Role> = {
279
302
  async prepare(rawInput, ctx) {
280
303
  const { parsed } = parseWithCustomFields(updateSchema, rawInput)
281
304
  const em = (ctx.container.resolve('em') as EntityManager)
282
- const existing = await findOneWithDecryption(em, Role, { id: parsed.id, deletedAt: null }, {}, { tenantId: null, organizationId: null })
305
+ const scope = await resolveActorScope(ctx)
306
+ const existing = await findOneWithDecryption(em, Role, buildScopedRoleFilter(parsed.id, scope), {}, { tenantId: scope.actorTenantId, organizationId: null })
283
307
  if (!existing) throw new CrudHttpError(404, { error: 'Role not found' })
284
308
  assertRoleTenantInScope(resolveActorTenantScope(ctx), existing.tenantId)
285
309
  const acls = await loadRoleAclSnapshots(em, parsed.id)
@@ -293,7 +317,8 @@ const updateRoleCommand: CommandHandler<Record<string, unknown>, Role> = {
293
317
  async execute(rawInput, ctx) {
294
318
  const { parsed, custom } = parseWithCustomFields(updateSchema, rawInput)
295
319
  const em = (ctx.container.resolve('em') as EntityManager)
296
- const current = await findOneWithDecryption(em, Role, { id: parsed.id, deletedAt: null }, {}, { tenantId: null, organizationId: null })
320
+ const scope = await resolveActorScope(ctx)
321
+ const current = await findOneWithDecryption(em, Role, buildScopedRoleFilter(parsed.id, scope), {}, { tenantId: scope.actorTenantId, organizationId: null })
297
322
  if (!current) throw new CrudHttpError(404, { error: 'Role not found' })
298
323
  const actorTenantScope = resolveActorTenantScope(ctx)
299
324
  assertRoleTenantInScope(actorTenantScope, current.tenantId)
@@ -309,21 +334,22 @@ const updateRoleCommand: CommandHandler<Record<string, unknown>, Role> = {
309
334
  }
310
335
  const wantsTenantChange = parsed.tenantId !== undefined && parsed.tenantId !== current.tenantId
311
336
  if (wantsTenantChange) {
337
+ if (!scope.isSuperAdmin) {
338
+ throw new CrudHttpError(403, { error: 'Not authorized to target this tenant.' })
339
+ }
312
340
  const assignments = await em.count(UserRole, { role: current, deletedAt: null })
313
341
  if (assignments > 0) {
314
342
  throw new CrudHttpError(400, { error: 'Role cannot be moved to another tenant while users are assigned' })
315
343
  }
316
344
  await em.nativeDelete(RoleAcl, { role: parsed.id as unknown as Role })
317
345
  }
318
- const updateWhere: Record<string, unknown> = { id: parsed.id, deletedAt: null }
319
- if (actorTenantScope) updateWhere.tenantId = actorTenantScope
320
346
  const de = (ctx.container.resolve('dataEngine') as DataEngine)
321
347
  const role = await de.updateOrmEntity({
322
348
  entity: Role,
323
- where: updateWhere as FilterQuery<Role>,
349
+ where: buildScopedRoleFilter(parsed.id, scope),
324
350
  apply: (entity) => {
325
351
  if (parsed.name !== undefined) entity.name = parsed.name
326
- if (parsed.tenantId !== undefined) entity.tenantId = parsed.tenantId
352
+ if (parsed.tenantId !== undefined && scope.isSuperAdmin) entity.tenantId = parsed.tenantId
327
353
  },
328
354
  })
329
355
  if (!role) throw new CrudHttpError(404, { error: 'Role not found' })
@@ -446,7 +472,8 @@ const deleteRoleCommand: CommandHandler<{ body?: Record<string, unknown>; query?
446
472
  async prepare(input, ctx) {
447
473
  const id = requireId(input, 'Role id required')
448
474
  const em = (ctx.container.resolve('em') as EntityManager)
449
- const existing = await findOneWithDecryption(em, Role, { id, deletedAt: null }, {}, { tenantId: null, organizationId: null })
475
+ const scope = await resolveActorScope(ctx)
476
+ const existing = await findOneWithDecryption(em, Role, buildScopedRoleFilter(id, scope), {}, { tenantId: scope.actorTenantId, organizationId: null })
450
477
  if (!existing) return {}
451
478
  const actorTenantScope = resolveActorTenantScope(ctx)
452
479
  if (actorTenantScope) {
@@ -464,7 +491,8 @@ const deleteRoleCommand: CommandHandler<{ body?: Record<string, unknown>; query?
464
491
  async execute(input, ctx) {
465
492
  const id = requireId(input, 'Role id required')
466
493
  const em = (ctx.container.resolve('em') as EntityManager)
467
- const role = await findOneWithDecryption(em, Role, { id, deletedAt: null }, {}, { tenantId: null, organizationId: null })
494
+ const scope = await resolveActorScope(ctx)
495
+ const role = await findOneWithDecryption(em, Role, buildScopedRoleFilter(id, scope), {}, { tenantId: scope.actorTenantId, organizationId: null })
468
496
  if (!role) throw new CrudHttpError(404, { error: 'Role not found' })
469
497
  assertRoleTenantInScope(resolveActorTenantScope(ctx), role.tenantId)
470
498
  const activeAssignments = await em.count(UserRole, { role, deletedAt: null })
@@ -475,7 +503,7 @@ const deleteRoleCommand: CommandHandler<{ body?: Record<string, unknown>; query?
475
503
  const de = (ctx.container.resolve('dataEngine') as DataEngine)
476
504
  const deleted = await de.deleteOrmEntity({
477
505
  entity: Role,
478
- where: { id, deletedAt: null } as FilterQuery<Role>,
506
+ where: buildScopedRoleFilter(id, scope),
479
507
  soft: false,
480
508
  })
481
509
  if (!deleted) throw new CrudHttpError(404, { error: 'Role not found' })
@@ -1,8 +1,13 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
- import { forbidden } from '@open-mercato/shared/lib/crud/errors'
2
+ import { CrudHttpError, forbidden } from '@open-mercato/shared/lib/crud/errors'
3
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
4
  import { Role } from '@open-mercato/core/modules/auth/data/entities'
4
5
  import { enforceTenantSelection, normalizeTenantId, resolveIsSuperAdmin } from './tenantAccess'
5
6
 
7
+ function roleNotFound(): never {
8
+ throw new CrudHttpError(404, { error: 'Role not found' })
9
+ }
10
+
6
11
  type RoleTenantAccessCtx = {
7
12
  auth: {
8
13
  tenantId?: string | null
@@ -17,40 +22,74 @@ type RoleTenantAccessCtx = {
17
22
  type RoleTenantPayload = {
18
23
  id?: unknown
19
24
  tenantId?: unknown
25
+ body?: { id?: unknown } | null
26
+ query?: { id?: unknown } | null
27
+ }
28
+
29
+ function extractRoleId(input: Record<string, unknown>): string | null {
30
+ const payload = input as RoleTenantPayload
31
+ const candidates: unknown[] = [payload.id, payload.body?.id, payload.query?.id]
32
+ for (const candidate of candidates) {
33
+ if (typeof candidate === 'string' && candidate.length) return candidate
34
+ }
35
+ return null
36
+ }
37
+
38
+ async function assertActorOwnsRole(
39
+ roleId: string,
40
+ ctx: RoleTenantAccessCtx,
41
+ ): Promise<{ matched: boolean }> {
42
+ const auth = ctx.auth
43
+ if (!auth) throw forbidden('Not authorized')
44
+ const em = (ctx.container.resolve('em') as EntityManager)
45
+ const existing = await findOneWithDecryption(
46
+ em,
47
+ Role,
48
+ { id: roleId, deletedAt: null },
49
+ {},
50
+ { tenantId: null, organizationId: null },
51
+ )
52
+ if (!existing) return { matched: false }
53
+
54
+ const isSuperAdmin = await resolveIsSuperAdmin(ctx)
55
+ if (isSuperAdmin) return { matched: true }
56
+
57
+ const actorTenant = normalizeTenantId(auth.tenantId ?? null) ?? null
58
+ const existingTenantId = normalizeTenantId(existing.tenantId ?? null) ?? null
59
+ // Unified 404 for "out of scope" — matches grantChecks.assertActorCanAccessRoleTarget
60
+ // and prevents existence enumeration of foreign-tenant or global roles.
61
+ if (!actorTenant) roleNotFound()
62
+ if (existingTenantId !== actorTenant) roleNotFound()
63
+ return { matched: true }
20
64
  }
21
65
 
22
66
  export async function enforceRoleTenantAccess(
23
- mode: 'create' | 'update',
67
+ mode: 'create' | 'update' | 'delete',
24
68
  input: Record<string, unknown>,
25
69
  ctx: RoleTenantAccessCtx,
26
70
  ): Promise<Record<string, unknown>> {
27
71
  const auth = ctx.auth
28
72
  if (!auth) throw forbidden('Not authorized')
29
- const isSuperAdmin = await resolveIsSuperAdmin(ctx)
30
73
 
31
74
  if (mode === 'create') {
32
75
  const tenantId = await enforceTenantSelection(ctx, (input as RoleTenantPayload).tenantId)
33
76
  return { ...input, tenantId }
34
77
  }
35
78
 
79
+ if (mode === 'delete') {
80
+ const roleId = extractRoleId(input)
81
+ if (!roleId) return input
82
+ await assertActorOwnsRole(roleId, ctx)
83
+ return input
84
+ }
85
+
86
+ // mode === 'update'
36
87
  const roleIdCandidate = (input as RoleTenantPayload).id
37
88
  const roleId = typeof roleIdCandidate === 'string' ? roleIdCandidate : null
38
89
  if (!roleId) return input
39
90
 
40
- const em = (ctx.container.resolve('em') as EntityManager)
41
- const existing = await em.findOne(Role, { id: roleId, deletedAt: null })
42
- if (!existing) return input
43
-
44
- const actorTenant = normalizeTenantId(auth.tenantId ?? null) ?? null
45
- const existingTenantId = normalizeTenantId(existing.tenantId ?? null) ?? null
46
-
47
- if (!isSuperAdmin && !actorTenant) {
48
- throw forbidden('Not authorized')
49
- }
50
-
51
- if (!isSuperAdmin && existingTenantId !== actorTenant) {
52
- throw forbidden('Not authorized')
53
- }
91
+ const ownership = await assertActorOwnsRole(roleId, ctx)
92
+ if (!ownership.matched) return input
54
93
 
55
94
  if ((input as RoleTenantPayload).tenantId === undefined) {
56
95
  return input