@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.6-develop.5612.1.d382eb2f33",
3
+ "version": "0.6.6-develop.5617.1.62538c48ca",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -245,16 +245,16 @@
245
245
  "zod": "^4.4.3"
246
246
  },
247
247
  "peerDependencies": {
248
- "@open-mercato/ai-assistant": "0.6.6-develop.5612.1.d382eb2f33",
249
- "@open-mercato/shared": "0.6.6-develop.5612.1.d382eb2f33",
250
- "@open-mercato/ui": "0.6.6-develop.5612.1.d382eb2f33",
248
+ "@open-mercato/ai-assistant": "0.6.6-develop.5617.1.62538c48ca",
249
+ "@open-mercato/shared": "0.6.6-develop.5617.1.62538c48ca",
250
+ "@open-mercato/ui": "0.6.6-develop.5617.1.62538c48ca",
251
251
  "react": "^19.0.0",
252
252
  "react-dom": "^19.0.0"
253
253
  },
254
254
  "devDependencies": {
255
- "@open-mercato/ai-assistant": "0.6.6-develop.5612.1.d382eb2f33",
256
- "@open-mercato/shared": "0.6.6-develop.5612.1.d382eb2f33",
257
- "@open-mercato/ui": "0.6.6-develop.5612.1.d382eb2f33",
255
+ "@open-mercato/ai-assistant": "0.6.6-develop.5617.1.62538c48ca",
256
+ "@open-mercato/shared": "0.6.6-develop.5617.1.62538c48ca",
257
+ "@open-mercato/ui": "0.6.6-develop.5617.1.62538c48ca",
258
258
  "@testing-library/dom": "^10.4.1",
259
259
  "@testing-library/jest-dom": "^6.9.1",
260
260
  "@testing-library/react": "^16.3.1",
@@ -1,7 +1,12 @@
1
- import { Entity, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'
1
+ import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'
2
2
 
3
3
  @Entity({ tableName: 'api_keys' })
4
4
  @Unique({ properties: ['keyPrefix'] })
5
+ @Index({
6
+ name: 'api_keys_opencode_session_id_uq',
7
+ expression:
8
+ 'create unique index "api_keys_opencode_session_id_uq" on "api_keys" ("opencode_session_id") where "opencode_session_id" is not null and "deleted_at" is null',
9
+ })
5
10
  export class ApiKey {
6
11
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
7
12
  id!: string
@@ -42,6 +47,15 @@ export class ApiKey {
42
47
  @Property({ name: 'session_secret_encrypted', type: 'text', nullable: true })
43
48
  sessionSecretEncrypted?: string | null
44
49
 
50
+ /**
51
+ * OpenCode session id bound to this api_key row. Set the first time the
52
+ * chat dispatcher receives a `done` event after minting a session token,
53
+ * then asserted on every subsequent resume to prevent cross-user session
54
+ * continuation (see security fix 2026-05-23).
55
+ */
56
+ @Property({ name: 'opencode_session_id', type: 'text', nullable: true })
57
+ opencodeSessionId?: string | null
58
+
45
59
  @Property({ name: 'last_used_at', type: Date, nullable: true })
46
60
  lastUsedAt?: Date | null
47
61
 
@@ -115,6 +115,15 @@
115
115
  "nullable": true,
116
116
  "mappedType": "text"
117
117
  },
118
+ "opencode_session_id": {
119
+ "name": "opencode_session_id",
120
+ "type": "text",
121
+ "unsigned": false,
122
+ "autoincrement": false,
123
+ "primary": false,
124
+ "nullable": true,
125
+ "mappedType": "text"
126
+ },
118
127
  "last_used_at": {
119
128
  "name": "last_used_at",
120
129
  "type": "timestamptz",
@@ -188,6 +197,15 @@
188
197
  "constraint": true,
189
198
  "primary": true,
190
199
  "unique": true
200
+ },
201
+ {
202
+ "columnNames": [],
203
+ "composite": false,
204
+ "constraint": false,
205
+ "keyName": "api_keys_opencode_session_id_uq",
206
+ "primary": false,
207
+ "unique": false,
208
+ "expression": "create unique index \"api_keys_opencode_session_id_uq\" on \"api_keys\" (\"opencode_session_id\") where \"opencode_session_id\" is not null and \"deleted_at\" is null"
191
209
  }
192
210
  ],
193
211
  "checks": [],
@@ -0,0 +1,17 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260523234901 extends Migration {
4
+
5
+ override async up(): Promise<void> {
6
+ this.addSql(`alter table "api_keys" add column "opencode_session_id" text null;`);
7
+ this.addSql(
8
+ `create unique index "api_keys_opencode_session_id_uq" on "api_keys" ("opencode_session_id") where "opencode_session_id" is not null and "deleted_at" is null;`
9
+ );
10
+ }
11
+
12
+ override async down(): Promise<void> {
13
+ this.addSql(`drop index "api_keys_opencode_session_id_uq";`);
14
+ this.addSql(`alter table "api_keys" drop column "opencode_session_id";`);
15
+ }
16
+
17
+ }
@@ -7,6 +7,7 @@ import { ApiKey } from '../data/entities'
7
7
  import { createKmsService } from '@open-mercato/shared/lib/encryption/kms'
8
8
  import { encryptWithAesGcm, decryptWithAesGcm } from '@open-mercato/shared/lib/encryption/aes'
9
9
  import { getSharedApiKeyAuthCache } from '@open-mercato/shared/lib/auth/apiKeyAuthCache'
10
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
10
11
 
11
12
  const BCRYPT_COST = 10
12
13
 
@@ -230,6 +231,68 @@ export async function findApiKeyBySessionToken(
230
231
  return record
231
232
  }
232
233
 
234
+ /**
235
+ * Bind an OpenCode session id to the api_key row that owns this chat session.
236
+ *
237
+ * Called by the chat dispatcher the first time we see the `done` event for a
238
+ * freshly minted session token. From that point on,
239
+ * `findApiKeyByOpencodeSessionId(em, opencodeSessionId)` returns the same row,
240
+ * which the ai-assistant runtime uses to assert ownership on every resume.
241
+ *
242
+ * Throws when the session token has been deleted/expired, and when the api_key
243
+ * row is already bound to a DIFFERENT OpenCode session (defensive: this should
244
+ * never happen in practice because each chat mints a new session token, but we
245
+ * fail closed instead of silently overwriting).
246
+ *
247
+ * Idempotent when the row is already bound to the same OpenCode session id.
248
+ */
249
+ export async function bindOpencodeSessionToApiKey(
250
+ em: EntityManager,
251
+ sessionToken: string,
252
+ opencodeSessionId: string
253
+ ): Promise<void> {
254
+ if (!sessionToken) throw new Error('Session token not found or expired')
255
+ if (!opencodeSessionId) throw new Error('OpenCode session id is required')
256
+
257
+ const row = await findApiKeyBySessionToken(em, sessionToken)
258
+ if (!row) throw new Error('Session token not found or expired')
259
+
260
+ if (row.opencodeSessionId === opencodeSessionId) return
261
+ if (row.opencodeSessionId && row.opencodeSessionId !== opencodeSessionId) {
262
+ throw new Error('Session token already bound to a different OpenCode session')
263
+ }
264
+
265
+ row.opencodeSessionId = opencodeSessionId
266
+ await em.persist(row).flush()
267
+ }
268
+
269
+ /**
270
+ * Find an api_key row by its bound OpenCode session id.
271
+ *
272
+ * Returns null if no active row matches, or if the matched row is expired
273
+ * (same contract as `findApiKeyBySessionToken`). Uses
274
+ * `findOneWithDecryption` so encrypted-at-rest fields on the row are decrypted
275
+ * before the ai-assistant runtime inspects `sessionUserId` / `tenantId` /
276
+ * `organizationId` for the ownership check.
277
+ */
278
+ export async function findApiKeyByOpencodeSessionId(
279
+ em: EntityManager,
280
+ opencodeSessionId: string
281
+ ): Promise<ApiKey | null> {
282
+ if (!opencodeSessionId) return null
283
+
284
+ const record = await findOneWithDecryption(
285
+ em,
286
+ ApiKey,
287
+ { opencodeSessionId, deletedAt: null } as any,
288
+ )
289
+
290
+ if (!record) return null
291
+ if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null
292
+
293
+ return record
294
+ }
295
+
233
296
  /**
234
297
  * Find a session API key with its decrypted secret.
235
298
  * Returns null if not found, expired, deleted, or decryption fails.
@@ -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