@open-mercato/core 0.6.6-develop.5612.1.d382eb2f33 → 0.6.6-develop.5617.1.62538c48ca

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 (58) 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/generated/entities/api_key/index.ts +1 -0
  39. package/generated/entities/attachment_partition/index.ts +2 -0
  40. package/generated/entity-fields-registry.ts +3 -0
  41. package/package.json +7 -7
  42. package/src/modules/api_keys/data/entities.ts +15 -1
  43. package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +18 -0
  44. package/src/modules/api_keys/migrations/Migration20260523234901.ts +17 -0
  45. package/src/modules/api_keys/services/apiKeyService.ts +63 -0
  46. package/src/modules/attachments/api/partitions/route.ts +58 -11
  47. package/src/modules/attachments/data/entities.ts +7 -0
  48. package/src/modules/attachments/migrations/.snapshot-open-mercato.json +28 -0
  49. package/src/modules/attachments/migrations/Migration20260524000000.ts +25 -0
  50. package/src/modules/auth/api/roles/route.ts +7 -38
  51. package/src/modules/auth/cli.ts +27 -4
  52. package/src/modules/auth/commands/roles.ts +39 -11
  53. package/src/modules/auth/lib/roleTenantGuard.ts +56 -17
  54. package/src/modules/auth/lib/setup-app.ts +160 -32
  55. package/src/modules/currencies/api/fetch-configs/route.ts +4 -2
  56. package/src/modules/integrations/api/[id]/route.ts +5 -2
  57. package/src/modules/integrations/api/route.ts +5 -2
  58. package/src/modules/integrations/lib/credentials-service.ts +15 -5
@@ -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
@@ -1,4 +1,5 @@
1
1
  import { hash } from 'bcryptjs'
2
+ import { randomBytes } from 'node:crypto'
2
3
  import type { EntityManager } from '@mikro-orm/postgresql'
3
4
  import { Role, RoleAcl, User, UserRole } from '@open-mercato/core/modules/auth/data/entities'
4
5
  import { Tenant, Organization } from '@open-mercato/core/modules/directory/data/entities'
@@ -80,6 +81,29 @@ const DERIVED_EMAIL_ENV = {
80
81
  employee: 'OM_INIT_EMPLOYEE_EMAIL',
81
82
  } as const
82
83
 
84
+ export type DemoUserRole = 'superadmin' | 'admin' | 'employee'
85
+ export type DemoUserEmail = { role: DemoUserRole; email: string }
86
+
87
+ /**
88
+ * Returns the canonical list of demo user emails the setup path may have
89
+ * seeded. Honors OM_INIT_ADMIN_EMAIL / OM_INIT_EMPLOYEE_EMAIL exactly the
90
+ * same way the derived-user seeding branch does, so the deactivation loop
91
+ * never drifts from the seeding loop.
92
+ *
93
+ * Pure function — no side effects, safe to unit-test in isolation.
94
+ *
95
+ * @internal Exported for tests; not part of the public auth API.
96
+ */
97
+ export function resolveDemoUserEmails(): DemoUserEmail[] {
98
+ const adminEmail = readEnvValue(DERIVED_EMAIL_ENV.admin) ?? `admin@${DEFAULT_DERIVED_EMAIL_DOMAIN}`
99
+ const employeeEmail = readEnvValue(DERIVED_EMAIL_ENV.employee) ?? `employee@${DEFAULT_DERIVED_EMAIL_DOMAIN}`
100
+ return [
101
+ { role: 'superadmin', email: DEMO_SUPERADMIN_EMAIL },
102
+ { role: 'admin', email: adminEmail },
103
+ { role: 'employee', email: employeeEmail },
104
+ ]
105
+ }
106
+
83
107
  export type SetupInitialTenantOptions = {
84
108
  orgName: string
85
109
  primaryUser: PrimaryUserInput
@@ -97,6 +121,20 @@ export type SetupInitialTenantOptions = {
97
121
  * reusing or clobbering an existing organization.
98
122
  */
99
123
  orgSlug?: string
124
+ /**
125
+ * Opt-in flag that allows seeding the derived admin/employee accounts when
126
+ * the OM_INIT_ADMIN_PASSWORD / OM_INIT_EMPLOYEE_PASSWORD env vars are unset.
127
+ *
128
+ * - In non-production (`NODE_ENV !== 'production'`): the demo accounts are
129
+ * seeded with the well-known `'secret'` password so local dev workflows
130
+ * (e.g. `mercato init`) stay predictable for developers.
131
+ * - In production: the fallback uses a randomly generated 96-bit password
132
+ * (printed once by the CLI) so an operator who deliberately opts in still
133
+ * avoids the historical hardcoded credential. When this flag is false/unset
134
+ * in production and the env vars are missing, `DerivedUserPasswordRequiredError`
135
+ * is thrown instead of silently seeding any account.
136
+ */
137
+ allowDemoDerivedPasswords?: boolean
100
138
  }
101
139
 
102
140
  export class OrgSlugExistsError extends Error {
@@ -106,10 +144,19 @@ export class OrgSlugExistsError extends Error {
106
144
  }
107
145
  }
108
146
 
147
+ export class DerivedUserPasswordRequiredError extends Error {
148
+ constructor(public readonly missing: string[]) {
149
+ super(
150
+ `DERIVED_USER_PASSWORD_REQUIRED: missing ${missing.join(', ')} (set the env vars, pass allowDemoDerivedPasswords: true / --include-demo-users in non-production, or disable derived user seeding)`,
151
+ )
152
+ this.name = 'DerivedUserPasswordRequiredError'
153
+ }
154
+ }
155
+
109
156
  export type SetupInitialTenantResult = {
110
157
  tenantId: string
111
158
  organizationId: string
112
- users: Array<{ user: User; roles: string[]; created: boolean }>
159
+ users: Array<{ user: User; roles: string[]; created: boolean; generatedPassword?: string | null }>
113
160
  reusedExistingUser: boolean
114
161
  }
115
162
 
@@ -161,7 +208,7 @@ export async function setupInitialTenant(
161
208
  let tenantId: string | undefined
162
209
  let organizationId: string | undefined
163
210
  let reusedExistingUser = false
164
- const userSnapshots: Array<{ user: User; roles: string[]; created: boolean }> = []
211
+ const userSnapshots: Array<{ user: User; roles: string[]; created: boolean; generatedPassword?: string | null }> = []
165
212
 
166
213
  await em.transactional(async (tem) => {
167
214
  if (!existingUser) return
@@ -202,6 +249,7 @@ export async function setupInitialTenant(
202
249
  roles: string[]
203
250
  name?: string | null
204
251
  passwordHash?: string | null
252
+ generatedPassword?: string | null
205
253
  }> = [
206
254
  { email: primaryUser.email, roles: primaryRoles, name: resolvePrimaryName(primaryUser) },
207
255
  ]
@@ -210,14 +258,43 @@ export async function setupInitialTenant(
210
258
  const employeeOverride = readEnvValue(DERIVED_EMAIL_ENV.employee)
211
259
  const adminEmail = adminOverride ?? `admin@${DEFAULT_DERIVED_EMAIL_DOMAIN}`
212
260
  const employeeEmail = employeeOverride ?? `employee@${DEFAULT_DERIVED_EMAIL_DOMAIN}`
213
- const adminPassword = readEnvValue('OM_INIT_ADMIN_PASSWORD') || 'secret'
214
- const employeePassword = readEnvValue('OM_INIT_EMPLOYEE_PASSWORD') || 'secret'
215
- const adminPasswordHash = adminPassword ? await resolvePasswordHash({ email: adminEmail, password: adminPassword }) : null
216
- const employeePasswordHash = employeePassword
217
- ? await resolvePasswordHash({ email: employeeEmail, password: employeePassword })
218
- : null
219
- addUniqueBaseUser(baseUsers, { email: adminEmail, roles: ['admin'], passwordHash: adminPasswordHash })
220
- addUniqueBaseUser(baseUsers, { email: employeeEmail, roles: ['employee'], passwordHash: employeePasswordHash })
261
+ const envAdminPwd = readEnvValue('OM_INIT_ADMIN_PASSWORD')
262
+ const envEmployeePwd = readEnvValue('OM_INIT_EMPLOYEE_PASSWORD')
263
+ const isProduction = process.env.NODE_ENV === 'production'
264
+ const allowDemo = options.allowDemoDerivedPasswords === true
265
+ if (isProduction && !allowDemo) {
266
+ const missing: string[] = []
267
+ if (!envAdminPwd) missing.push('OM_INIT_ADMIN_PASSWORD')
268
+ if (!envEmployeePwd) missing.push('OM_INIT_EMPLOYEE_PASSWORD')
269
+ if (missing.length) {
270
+ throw new DerivedUserPasswordRequiredError(missing)
271
+ }
272
+ }
273
+ // In non-production, fall back to the well-known DEMO_DERIVED_PASSWORD so
274
+ // local dev workflows stay predictable (admin@/employee@ login with the
275
+ // documented demo password). In production we either have env-supplied
276
+ // values, or — for opt-in --include-demo-users flows — we generate a
277
+ // random one-time password and surface it via `generatedPassword` so the
278
+ // operator can capture it. Production callers without env vars and
279
+ // without the opt-in already threw above.
280
+ const fallbackAdminPwd = isProduction ? generateDerivedPassword() : DEMO_DERIVED_PASSWORD
281
+ const fallbackEmployeePwd = isProduction ? generateDerivedPassword() : DEMO_DERIVED_PASSWORD
282
+ const adminPasswordPlain = envAdminPwd ?? fallbackAdminPwd
283
+ const employeePasswordPlain = envEmployeePwd ?? fallbackEmployeePwd
284
+ const adminPasswordHash = await hash(adminPasswordPlain, 10)
285
+ const employeePasswordHash = await hash(employeePasswordPlain, 10)
286
+ addUniqueBaseUser(baseUsers, {
287
+ email: adminEmail,
288
+ roles: ['admin'],
289
+ passwordHash: adminPasswordHash,
290
+ generatedPassword: envAdminPwd ? null : adminPasswordPlain,
291
+ })
292
+ addUniqueBaseUser(baseUsers, {
293
+ email: employeeEmail,
294
+ roles: ['employee'],
295
+ passwordHash: employeePasswordHash,
296
+ generatedPassword: envEmployeePwd ? null : employeePasswordPlain,
297
+ })
221
298
  }
222
299
  const passwordHash = await resolvePasswordHash(primaryUser)
223
300
 
@@ -327,7 +404,7 @@ export async function setupInitialTenant(
327
404
  if (base.name) user.name = base.name
328
405
  if (confirm) user.isConfirmed = true
329
406
  tem.persist(user)
330
- userSnapshots.push({ user, roles: base.roles, created: false })
407
+ userSnapshots.push({ user, roles: base.roles, created: false, generatedPassword: base.generatedPassword ?? null })
331
408
  } else {
332
409
  user = tem.create(User, {
333
410
  email: (encryptedPayload as any).email ?? base.email,
@@ -340,7 +417,7 @@ export async function setupInitialTenant(
340
417
  createdAt: new Date(),
341
418
  })
342
419
  tem.persist(user)
343
- userSnapshots.push({ user, roles: base.roles, created: true })
420
+ userSnapshots.push({ user, roles: base.roles, created: true, generatedPassword: base.generatedPassword ?? null })
344
421
  }
345
422
  await tem.flush()
346
423
  for (const roleName of base.roles) {
@@ -362,7 +439,7 @@ export async function setupInitialTenant(
362
439
  }
363
440
 
364
441
  await ensureDefaultRoleAcls(em, tenantId, resolvedModules, { includeSuperadminRole })
365
- await deactivateDemoSuperAdminIfSelfOnboardingEnabled(em)
442
+ await deactivateDemoUsersIfSelfOnboardingEnabled(em)
366
443
 
367
444
  // Call module onTenantCreated hooks
368
445
  for (const mod of resolvedModules) {
@@ -394,8 +471,8 @@ function readEnvValue(key: string): string | undefined {
394
471
  }
395
472
 
396
473
  function addUniqueBaseUser(
397
- baseUsers: Array<{ email: string; roles: string[]; name?: string | null; passwordHash?: string | null }>,
398
- entry: { email: string; roles: string[]; name?: string | null; passwordHash?: string | null },
474
+ baseUsers: Array<{ email: string; roles: string[]; name?: string | null; passwordHash?: string | null; generatedPassword?: string | null }>,
475
+ entry: { email: string; roles: string[]; name?: string | null; passwordHash?: string | null; generatedPassword?: string | null },
399
476
  ) {
400
477
  if (!entry.email) return
401
478
  const normalized = entry.email.toLowerCase()
@@ -403,6 +480,26 @@ function addUniqueBaseUser(
403
480
  baseUsers.push(entry)
404
481
  }
405
482
 
483
+ /**
484
+ * Well-known demo password seeded for derived admin@/employee@ accounts in
485
+ * non-production (`NODE_ENV !== 'production'`) when no env override is set.
486
+ * Keeps the documented `yarn dev` / `mercato init` DX predictable. Never used
487
+ * in production: the production branch either consumes env-supplied values or
488
+ * falls back to `generateDerivedPassword()` so credentials remain non-guessable.
489
+ */
490
+ const DEMO_DERIVED_PASSWORD = 'secret'
491
+
492
+ /**
493
+ * Generate a 16-character base64url password (96 bits of entropy) for a derived
494
+ * demo user in production when no env override is provided AND the caller
495
+ * explicitly opted in via `allowDemoDerivedPasswords`. Surfaced via the
496
+ * `users[].generatedPassword` snapshot so CLI callers can print it to the
497
+ * operator — there is no other recovery path for these credentials.
498
+ */
499
+ function generateDerivedPassword(): string {
500
+ return randomBytes(12).toString('base64url')
501
+ }
502
+
406
503
  function isDemoModeEnabled(): boolean {
407
504
  const parsed = parseBooleanToken(process.env.DEMO_MODE ?? '')
408
505
  return parsed === false ? false : true
@@ -562,29 +659,60 @@ async function ensureRoleAclFor(
562
659
  }
563
660
  }
564
661
 
565
- async function deactivateDemoSuperAdminIfSelfOnboardingEnabled(em: EntityManager) {
662
+ /**
663
+ * Neutralizes every demo account the setup path may have seeded
664
+ * (superadmin, admin, employee — honoring OM_INIT_*_EMAIL overrides)
665
+ * when SELF_SERVICE_ONBOARDING_ENABLED is on and the operator did not
666
+ * opt in to keeping demo credentials via shouldKeepDemoSuperadminDuringInit.
667
+ *
668
+ * Each user is processed in its own try/catch so a single failure (e.g.
669
+ * decryption error on a legacy row) does not skip the remaining accounts.
670
+ *
671
+ * @internal Exported for tests; not part of the public auth API.
672
+ */
673
+ export async function deactivateDemoUsersIfSelfOnboardingEnabled(em: EntityManager) {
566
674
  if (process.env.SELF_SERVICE_ONBOARDING_ENABLED !== 'true') return
567
675
  if (shouldKeepDemoSuperadminDuringInit()) return
568
- try {
569
- const user = await findOneWithDecryption(em, User, { email: DEMO_SUPERADMIN_EMAIL }, {}, { tenantId: null, organizationId: null })
570
- if (!user) return
571
- let dirty = false
572
- if (user.passwordHash) {
573
- user.passwordHash = null
574
- dirty = true
575
- }
576
- if (user.isConfirmed !== false) {
577
- user.isConfirmed = false
578
- dirty = true
579
- }
580
- if (dirty) {
581
- await em.persist(user).flush()
676
+ for (const { role, email } of resolveDemoUserEmails()) {
677
+ try {
678
+ const user = await findOneWithDecryption(
679
+ em,
680
+ User,
681
+ { email },
682
+ {},
683
+ { tenantId: null, organizationId: null },
684
+ )
685
+ if (!user) continue
686
+ let dirty = false
687
+ if (user.passwordHash) {
688
+ user.passwordHash = null
689
+ dirty = true
690
+ }
691
+ if (user.isConfirmed !== false) {
692
+ user.isConfirmed = false
693
+ dirty = true
694
+ }
695
+ if (dirty) {
696
+ await em.persist(user).flush()
697
+ }
698
+ } catch (error) {
699
+ console.error(
700
+ `[auth.setup] failed to deactivate demo ${role} user (${email})`,
701
+ error,
702
+ )
582
703
  }
583
- } catch (error) {
584
- console.error('[auth.setup] failed to deactivate demo superadmin user', error)
585
704
  }
586
705
  }
587
706
 
707
+ /**
708
+ * @deprecated Renamed to {@link deactivateDemoUsersIfSelfOnboardingEnabled}
709
+ * because the helper now neutralizes admin/employee demo accounts in addition
710
+ * to superadmin (security tracker finding #5). Kept as an internal alias to
711
+ * avoid breaking any out-of-tree caller that imported the old name; will be
712
+ * removed in a future major.
713
+ */
714
+ const deactivateDemoSuperAdminIfSelfOnboardingEnabled = deactivateDemoUsersIfSelfOnboardingEnabled
715
+
588
716
  /** Try to get modules from runtime registry; returns empty array if not yet registered. */
589
717
  function tryGetModules(): Module[] {
590
718
  try {
@@ -13,8 +13,10 @@ import {
13
13
  import { currencyFetchConfigCreateSchema, currencyFetchConfigUpdateSchema } from '../../data/validators'
14
14
 
15
15
  export const metadata = {
16
- requireAuth: true,
17
- requireFeatures: ['currencies.fetch.view'],
16
+ GET: { requireAuth: true, requireFeatures: ['currencies.fetch.view'] },
17
+ POST: { requireAuth: true, requireFeatures: ['currencies.fetch.manage'] },
18
+ PUT: { requireAuth: true, requireFeatures: ['currencies.fetch.manage'] },
19
+ DELETE: { requireAuth: true, requireFeatures: ['currencies.fetch.manage'] },
18
20
  }
19
21
 
20
22
  export async function GET(req: NextRequest) {
@@ -3,7 +3,7 @@ import { z } from 'zod'
3
3
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { getBundle, getBundleIntegrations, getIntegration } from '@open-mercato/shared/modules/integrations/types'
6
- import type { CredentialsService } from '../../lib/credentials-service'
6
+ import { CredentialsEncryptionUnavailableError, type CredentialsService } from '../../lib/credentials-service'
7
7
  import type { IntegrationStateService } from '../../lib/state-service'
8
8
  import type { IntegrationLogService } from '../../lib/log-service'
9
9
  import { deriveIntegrationHealthStatus, getEffectiveHealthCheckConfig } from '../../lib/health-service'
@@ -60,7 +60,10 @@ export async function GET(req: Request, ctx: { params?: Promise<{ id?: string }>
60
60
  const scope = { organizationId: auth.orgId as string, tenantId: auth.tenantId }
61
61
 
62
62
  const [credentials, state, analyticsMap] = await Promise.all([
63
- credentialsService.resolve(integration.id, scope),
63
+ credentialsService.resolve(integration.id, scope).catch((err) => {
64
+ if (err instanceof CredentialsEncryptionUnavailableError) return null
65
+ throw err
66
+ }),
64
67
  stateService.resolveState(integration.id, scope),
65
68
  logService.aggregateAnalytics([integration.id], scope, 30),
66
69
  ])
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
3
3
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
- import type { CredentialsService } from '../lib/credentials-service'
4
+ import { CredentialsEncryptionUnavailableError, type CredentialsService } from '../lib/credentials-service'
5
5
  import type { IntegrationStateService } from '../lib/state-service'
6
6
  import type { IntegrationLogService } from '../lib/log-service'
7
7
  import { deriveIntegrationHealthStatus, getEffectiveHealthCheckConfig } from '../lib/health-service'
@@ -101,7 +101,10 @@ export async function GET(req: Request) {
101
101
  const baseRows: ListRow[] = await Promise.all(
102
102
  getAllIntegrations().map(async (integration) => {
103
103
  const [resolvedCredentials, state] = await Promise.all([
104
- credentialsService.resolve(integration.id, scope),
104
+ credentialsService.resolve(integration.id, scope).catch((err) => {
105
+ if (err instanceof CredentialsEncryptionUnavailableError) return null
106
+ throw err
107
+ }),
105
108
  stateService.resolveState(integration.id, scope),
106
109
  ])
107
110
 
@@ -13,12 +13,22 @@ import { EncryptionMap } from '../../entities/data/entities'
13
13
  import { IntegrationCredentials } from '../data/entities'
14
14
 
15
15
  const ENCRYPTED_CREDENTIALS_BLOB_KEY = '__om_encrypted_credentials_blob_v1'
16
- const CREDENTIALS_ENCRYPTION_UNAVAILABLE_MESSAGE =
17
- 'Integration credentials encryption key is unavailable. Configure Vault KMS or TENANT_DATA_ENCRYPTION_FALLBACK_KEY.'
18
16
 
17
+ /**
18
+ * Raised when integration credentials cannot be encrypted or decrypted because
19
+ * no tenant DEK is available — typically a production deployment with neither
20
+ * Vault nor `TENANT_DATA_ENCRYPTION_FALLBACK_KEY` (or equivalent env vars)
21
+ * configured. The credentials path deliberately fails closed instead of using
22
+ * a hardcoded fallback secret; see security tracker finding #7.
23
+ */
19
24
  export class CredentialsEncryptionUnavailableError extends Error {
20
- constructor() {
21
- super(CREDENTIALS_ENCRYPTION_UNAVAILABLE_MESSAGE)
25
+ readonly code = 'CREDENTIALS_ENCRYPTION_UNAVAILABLE'
26
+ constructor(tenantId: string) {
27
+ super(
28
+ `Cannot encrypt or decrypt integration credentials for tenant ${tenantId}: ` +
29
+ `no tenant DEK is available. Configure Vault (VAULT_ADDR/VAULT_TOKEN) or ` +
30
+ `set TENANT_DATA_ENCRYPTION_FALLBACK_KEY in the environment.`,
31
+ )
22
32
  this.name = 'CredentialsEncryptionUnavailableError'
23
33
  }
24
34
  }
@@ -110,7 +120,7 @@ export function createCredentialsService(em: EntityManager) {
110
120
  const created = await kms.createTenantDek(scope.tenantId)
111
121
  if (created?.key) return created.key
112
122
 
113
- throw new CredentialsEncryptionUnavailableError()
123
+ throw new CredentialsEncryptionUnavailableError(scope.tenantId)
114
124
  }
115
125
 
116
126
  async function encryptCredentialsBlob(