@open-mercato/ai-assistant 0.6.6-develop.6540.1.ae856df1ec → 0.6.6-develop.6541.1.c6f8fa5d2b

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.
@@ -5,9 +5,8 @@ import type { EntityManager } from '@mikro-orm/postgresql'
5
5
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
6
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
7
  import { createApiKey } from '@open-mercato/core/modules/api_keys/services/apiKeyService'
8
- import { UserRole } from '@open-mercato/core/modules/auth/data/entities'
9
- import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
10
8
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
9
+ import { getUserRoleIds } from '../../lib/user-role-ids'
11
10
 
12
11
  const logger = createLogger('ai_assistant')
13
12
 
@@ -15,29 +14,6 @@ export const metadata = {
15
14
  POST: { requireAuth: true, requireFeatures: ['api_keys.create'] },
16
15
  }
17
16
 
18
- /**
19
- * Resolve the calling user's role ids for the active tenant. Mirrors the
20
- * `session-key` route so the generated key carries exactly the caller's roles.
21
- */
22
- async function getUserRoleIds(
23
- em: EntityManager,
24
- userId: string,
25
- tenantId: string | null,
26
- ): Promise<string[]> {
27
- if (!tenantId) return []
28
- const links = await findWithDecryption(
29
- em,
30
- UserRole,
31
- { user: userId as any, role: { tenantId } } as any,
32
- { populate: ['role'] },
33
- { tenantId, organizationId: null },
34
- )
35
- const linkList = Array.isArray(links) ? links : []
36
- return linkList
37
- .map((link) => (link.role as any)?.id)
38
- .filter((id): id is string => typeof id === 'string' && id.length > 0)
39
- }
40
-
41
17
  const bodySchema = z.object({
42
18
  name: z.string().min(1).max(120).optional(),
43
19
  description: z.string().max(1000).optional().nullable(),
@@ -8,9 +8,8 @@ import {
8
8
  generateSessionToken,
9
9
  createSessionApiKey,
10
10
  } from '@open-mercato/core/modules/api_keys/services/apiKeyService'
11
- import { UserRole } from '@open-mercato/core/modules/auth/data/entities'
12
- import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
13
11
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
12
+ import { getUserRoleIds } from '../../lib/user-role-ids'
14
13
 
15
14
  const logger = createLogger('ai_assistant')
16
15
 
@@ -20,29 +19,6 @@ export const metadata = {
20
19
 
21
20
  const SESSION_TTL_MINUTES = 120 // 2 hours
22
21
 
23
- /**
24
- * Get user's role IDs from the database.
25
- */
26
- async function getUserRoleIds(
27
- em: EntityManager,
28
- userId: string,
29
- tenantId: string | null
30
- ): Promise<string[]> {
31
- if (!tenantId) return []
32
-
33
- const links = await findWithDecryption(
34
- em,
35
- UserRole,
36
- { user: userId as any, role: { tenantId } } as any,
37
- { populate: ['role'] },
38
- { tenantId, organizationId: null },
39
- )
40
- const linkList = Array.isArray(links) ? links : []
41
- return linkList
42
- .map((l) => (l.role as any)?.id)
43
- .filter((id): id is string => typeof id === 'string' && id.length > 0)
44
- }
45
-
46
22
  /**
47
23
  * POST /api/ai_assistant/session-key
48
24
  *
@@ -1,3 +1,4 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
1
2
  import { createLogger } from '@open-mercato/shared/lib/logger'
2
3
  import { NextResponse, type NextRequest } from 'next/server'
3
4
  import { z } from 'zod'
@@ -6,6 +7,7 @@ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
7
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
8
  import type { EntityManager } from '@mikro-orm/postgresql'
8
9
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
10
+ import { findApiKeyBySecret } from '@open-mercato/core/modules/api_keys/services/apiKeyService'
9
11
  import { llmProviderRegistry } from '@open-mercato/shared/lib/ai/llm-provider-registry'
10
12
  import { joinProviderModel } from '@open-mercato/shared/lib/ai/model-id'
11
13
  import {
@@ -170,8 +172,26 @@ export async function GET(req: NextRequest) {
170
172
  ? getOpenCodeProviderConfiguredEnvKey(fallbackOpenCodeProviderId)
171
173
  : null
172
174
 
173
- // Check if MCP_SERVER_API_KEY is configured (required for MCP authentication)
174
- const mcpKeyConfigured = !!process.env.MCP_SERVER_API_KEY?.trim()
175
+ // Check if the MCP API key is configured (required for MCP authentication):
176
+ // either directly via MCP_SERVER_API_KEY or via the shared key file that
177
+ // the containerized mcp service provisions (MCP_SERVER_API_KEY_FILE).
178
+ // The file variant is validated against the api_keys table — the file
179
+ // outlives DB resets and key deletion, so mere existence would report a
180
+ // green badge while OpenCode -> MCP auth is actually broken.
181
+ const mcpKeyFilePath = process.env.MCP_SERVER_API_KEY_FILE?.trim()
182
+ let mcpKeyConfigured = !!process.env.MCP_SERVER_API_KEY?.trim()
183
+ if (!mcpKeyConfigured && mcpKeyFilePath && existsSync(mcpKeyFilePath)) {
184
+ try {
185
+ const fileSecret = readFileSync(mcpKeyFilePath, 'utf8').trim()
186
+ if (fileSecret.startsWith('omk_')) {
187
+ const keyContainer = await createRequestContainer()
188
+ const keyEm = keyContainer.resolve<EntityManager>('em')
189
+ mcpKeyConfigured = !!(await findApiKeyBySecret(keyEm, fileSecret))
190
+ }
191
+ } catch {
192
+ mcpKeyConfigured = false
193
+ }
194
+ }
175
195
 
176
196
  // Phase 4a: resolve tenant override row and per-agent resolution matrix
177
197
  let tenantOverride: {
@@ -161,6 +161,47 @@ const mcpDev: ModuleCli = {
161
161
  },
162
162
  }
163
163
 
164
+ const mcpEnsureApiKey: ModuleCli = {
165
+ command: 'mcp:ensure-api-key',
166
+ async run(rest) {
167
+ const args = parseArgs(rest)
168
+ // A bare `--file` (no value) parses to boolean true — treat it as missing
169
+ // rather than provisioning into a file literally named "true".
170
+ const fileArg = typeof args.file === 'string' ? args.file.trim() : ''
171
+ const filePath =
172
+ fileArg || process.env.MCP_SERVER_API_KEY_FILE?.trim() || process.env.MCP_API_KEY_FILE?.trim() || ''
173
+ if (!filePath) {
174
+ console.error('Usage: mercato ai_assistant mcp:ensure-api-key --file <path> [options]')
175
+ console.error('')
176
+ console.error('Ensures a valid MCP server API key exists in the database and that its')
177
+ console.error('plaintext secret is stored in <path> (the idempotency anchor). The secret')
178
+ console.error('is never printed; consumers such as the OpenCode container read the file.')
179
+ console.error('')
180
+ console.error('Options:')
181
+ console.error(' --file <path> Secret file path (or MCP_SERVER_API_KEY_FILE env)')
182
+ console.error(' --name <name> API key name (default __mcp_server__)')
183
+ console.error(' --email <email> Owner user email (default OM_INIT_SUPERADMIN_EMAIL, then superadmin@acme.com)')
184
+ console.error(' --rotate Force rotation even when the file secret is still valid')
185
+ process.exitCode = 1
186
+ return
187
+ }
188
+
189
+ const keyName = typeof args.name === 'string' && args.name.length > 0 ? args.name : undefined
190
+ const ownerEmail = typeof args.email === 'string' && args.email.length > 0 ? args.email : undefined
191
+ const rotate = args.rotate === true || args.rotate === 'true'
192
+
193
+ await ensureBootstrap()
194
+ const container = await createRequestContainer()
195
+ const em = container.resolve<import('@mikro-orm/postgresql').EntityManager>('em')
196
+ const rbac = container.resolve('rbacService') as import('@open-mercato/core/modules/auth/services/rbacService').RbacService
197
+
198
+ const { ensureMcpApiKey } = await import('./lib/mcp-ensure-api-key')
199
+ const result = await ensureMcpApiKey({ em, filePath, keyName, ownerEmail, rotate, rbac })
200
+ const message = result.status === 'valid' ? 'Existing key is valid' : 'New key created'
201
+ console.log(`[mcp:ensure-api-key] ${message} (id=${result.keyId}, prefix=${result.keyPrefix})`)
202
+ },
203
+ }
204
+
164
205
  const listTools: ModuleCli = {
165
206
  command: 'mcp:list-tools',
166
207
  async run(rest) {
@@ -377,6 +418,7 @@ export default [
377
418
  mcpServe,
378
419
  mcpServeHttp,
379
420
  mcpDev,
421
+ mcpEnsureApiKey,
380
422
  listTools,
381
423
  entityGraph,
382
424
  runPendingActionCleanup,
@@ -0,0 +1,182 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ jest.mock('@open-mercato/core/modules/api_keys/services/apiKeyService', () => ({
6
+ createApiKey: jest.fn(),
7
+ deleteApiKey: jest.fn(),
8
+ findApiKeyBySecret: jest.fn(),
9
+ }))
10
+
11
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
12
+ findOneWithDecryption: jest.fn(),
13
+ findWithDecryption: jest.fn(),
14
+ }))
15
+
16
+ import {
17
+ createApiKey,
18
+ deleteApiKey,
19
+ findApiKeyBySecret,
20
+ } from '@open-mercato/core/modules/api_keys/services/apiKeyService'
21
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
22
+ import { ensureMcpApiKey, DEFAULT_MCP_KEY_NAME } from '../mcp-ensure-api-key'
23
+
24
+ const mockedCreateApiKey = createApiKey as jest.Mock
25
+ const mockedDeleteApiKey = deleteApiKey as jest.Mock
26
+ const mockedFindApiKeyBySecret = findApiKeyBySecret as jest.Mock
27
+ const mockedFindOneWithDecryption = findOneWithDecryption as jest.Mock
28
+ const mockedFindWithDecryption = findWithDecryption as jest.Mock
29
+
30
+ const owner = {
31
+ id: 'user-1',
32
+ tenantId: 'tenant-1',
33
+ email: 'superadmin@acme.com',
34
+ }
35
+
36
+ function createEm() {
37
+ return { find: jest.fn().mockResolvedValue([]) } as any
38
+ }
39
+
40
+ describe('ensureMcpApiKey', () => {
41
+ let workDir: string
42
+ let filePath: string
43
+
44
+ beforeEach(async () => {
45
+ jest.clearAllMocks()
46
+ workDir = await fs.mkdtemp(join(tmpdir(), 'mcp-key-test-'))
47
+ filePath = join(workDir, 'shared', 'mcp-api-key')
48
+ mockedFindOneWithDecryption.mockResolvedValue(owner)
49
+ mockedFindWithDecryption.mockResolvedValue([{ role: { id: 'role-1' } }])
50
+ mockedCreateApiKey.mockResolvedValue({
51
+ record: { id: 'key-new', keyPrefix: 'omk_new.' },
52
+ secret: 'omk_new1.aaaabbbbccccddddeeeeffff',
53
+ })
54
+ })
55
+
56
+ afterEach(async () => {
57
+ await fs.rm(workDir, { recursive: true, force: true })
58
+ })
59
+
60
+ it('returns valid without touching the DB when the file secret resolves to a live key of the same name', async () => {
61
+ await fs.mkdir(join(workDir, 'shared'), { recursive: true })
62
+ await fs.writeFile(filePath, 'omk_old1.secret\n')
63
+ mockedFindApiKeyBySecret.mockResolvedValue({
64
+ id: 'key-old',
65
+ name: DEFAULT_MCP_KEY_NAME,
66
+ keyPrefix: 'omk_old1.sec',
67
+ })
68
+
69
+ const em = createEm()
70
+ const result = await ensureMcpApiKey({ em, filePath })
71
+
72
+ expect(result).toEqual({ status: 'valid', keyId: 'key-old', keyPrefix: 'omk_old1.sec' })
73
+ expect(mockedCreateApiKey).not.toHaveBeenCalled()
74
+ expect(mockedDeleteApiKey).not.toHaveBeenCalled()
75
+ expect((await fs.readFile(filePath, 'utf8')).trim()).toBe('omk_old1.secret')
76
+ })
77
+
78
+ it('creates a key owned by the superadmin and writes the secret file when no file exists', async () => {
79
+ const em = createEm()
80
+ const result = await ensureMcpApiKey({ em, filePath })
81
+
82
+ expect(result).toEqual({ status: 'created', keyId: 'key-new', keyPrefix: 'omk_new.' })
83
+ expect(mockedCreateApiKey).toHaveBeenCalledWith(
84
+ em,
85
+ expect.objectContaining({
86
+ name: DEFAULT_MCP_KEY_NAME,
87
+ tenantId: 'tenant-1',
88
+ organizationId: null,
89
+ roles: ['role-1'],
90
+ createdBy: 'user-1',
91
+ }),
92
+ expect.anything(),
93
+ )
94
+ expect((await fs.readFile(filePath, 'utf8')).trim()).toBe('omk_new1.aaaabbbbccccddddeeeeffff')
95
+ })
96
+
97
+ it('resolves the owner with an encrypted-email-aware $or filter', async () => {
98
+ const em = createEm()
99
+ await ensureMcpApiKey({ em, filePath })
100
+
101
+ expect(mockedFindOneWithDecryption).toHaveBeenCalledWith(
102
+ em,
103
+ expect.anything(),
104
+ expect.objectContaining({
105
+ deletedAt: null,
106
+ $or: expect.arrayContaining([
107
+ { email: 'superadmin@acme.com' },
108
+ expect.objectContaining({ emailHash: expect.anything() }),
109
+ ]),
110
+ }),
111
+ expect.anything(),
112
+ expect.anything(),
113
+ )
114
+ })
115
+
116
+ it('scopes stale-key cleanup to the owner tenant', async () => {
117
+ const em = createEm()
118
+ await ensureMcpApiKey({ em, filePath })
119
+
120
+ expect(em.find).toHaveBeenCalledWith(
121
+ expect.anything(),
122
+ expect.objectContaining({ name: DEFAULT_MCP_KEY_NAME, tenantId: 'tenant-1', deletedAt: null }),
123
+ )
124
+ })
125
+
126
+ it('soft-deletes stale keys with the same name before creating a replacement', async () => {
127
+ await fs.mkdir(join(workDir, 'shared'), { recursive: true })
128
+ await fs.writeFile(filePath, 'omk_stale.secret\n')
129
+ mockedFindApiKeyBySecret.mockResolvedValue(null)
130
+
131
+ const em = createEm()
132
+ em.find.mockResolvedValue([{ id: 'stale-1' }, { id: 'stale-2' }])
133
+
134
+ const result = await ensureMcpApiKey({ em, filePath })
135
+
136
+ expect(result.status).toBe('created')
137
+ expect(mockedDeleteApiKey).toHaveBeenCalledTimes(2)
138
+ expect(mockedDeleteApiKey).toHaveBeenCalledWith(em, 'stale-1', expect.anything())
139
+ expect(mockedDeleteApiKey).toHaveBeenCalledWith(em, 'stale-2', expect.anything())
140
+ })
141
+
142
+ it('rotates even when the file secret is valid if rotate is set', async () => {
143
+ await fs.mkdir(join(workDir, 'shared'), { recursive: true })
144
+ await fs.writeFile(filePath, 'omk_old1.secret\n')
145
+ mockedFindApiKeyBySecret.mockResolvedValue({
146
+ id: 'key-old',
147
+ name: DEFAULT_MCP_KEY_NAME,
148
+ keyPrefix: 'omk_old1.sec',
149
+ })
150
+
151
+ const em = createEm()
152
+ const result = await ensureMcpApiKey({ em, filePath, rotate: true })
153
+
154
+ expect(result.status).toBe('created')
155
+ expect(mockedFindApiKeyBySecret).not.toHaveBeenCalled()
156
+ expect((await fs.readFile(filePath, 'utf8')).trim()).toBe('omk_new1.aaaabbbbccccddddeeeeffff')
157
+ })
158
+
159
+ it('rotates when the file secret resolves to a key with a different name', async () => {
160
+ await fs.mkdir(join(workDir, 'shared'), { recursive: true })
161
+ await fs.writeFile(filePath, 'omk_old1.secret\n')
162
+ mockedFindApiKeyBySecret.mockResolvedValue({
163
+ id: 'key-other',
164
+ name: '__session_something__',
165
+ keyPrefix: 'omk_old1.sec',
166
+ })
167
+
168
+ const em = createEm()
169
+ const result = await ensureMcpApiKey({ em, filePath })
170
+
171
+ expect(result.status).toBe('created')
172
+ expect(mockedCreateApiKey).toHaveBeenCalled()
173
+ })
174
+
175
+ it('throws a clear error when the owner user does not exist', async () => {
176
+ mockedFindOneWithDecryption.mockResolvedValue(null)
177
+
178
+ const em = createEm()
179
+ await expect(ensureMcpApiKey({ em, filePath })).rejects.toThrow(/owner not found/)
180
+ expect(mockedCreateApiKey).not.toHaveBeenCalled()
181
+ })
182
+ })
@@ -0,0 +1,132 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
5
+ import { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'
6
+ import {
7
+ createApiKey,
8
+ deleteApiKey,
9
+ findApiKeyBySecret,
10
+ } from '@open-mercato/core/modules/api_keys/services/apiKeyService'
11
+ import { User } from '@open-mercato/core/modules/auth/data/entities'
12
+ import { emailHashLookupValues } from '@open-mercato/core/modules/auth/lib/emailHash'
13
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
14
+ import { getUserRoleIds } from './user-role-ids'
15
+
16
+ export const DEFAULT_MCP_KEY_NAME = '__mcp_server__'
17
+ export const DEFAULT_MCP_KEY_OWNER_EMAIL = 'superadmin@acme.com'
18
+
19
+ export type EnsureMcpApiKeyOptions = {
20
+ em: EntityManager
21
+ filePath: string
22
+ keyName?: string
23
+ ownerEmail?: string
24
+ rotate?: boolean
25
+ rbac?: RbacService
26
+ }
27
+
28
+ export type EnsureMcpApiKeyResult = {
29
+ status: 'valid' | 'created'
30
+ keyId: string
31
+ keyPrefix: string
32
+ }
33
+
34
+ async function readSecretFile(filePath: string): Promise<string | null> {
35
+ try {
36
+ const raw = await fs.readFile(filePath, 'utf8')
37
+ const trimmed = raw.trim()
38
+ return trimmed.length > 0 ? trimmed : null
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ async function writeSecretFile(filePath: string, secret: string): Promise<void> {
45
+ await fs.mkdir(dirname(filePath), { recursive: true })
46
+ const tmpPath = `${filePath}.tmp`
47
+ // World-readable on purpose: the file lives in a dedicated named volume
48
+ // mounted only into this stack's containers, and the consumer (the OpenCode
49
+ // container) runs as a non-root user that cannot read a root-owned 0600
50
+ // file. The volume boundary, not the file mode, is the security boundary.
51
+ await fs.writeFile(tmpPath, `${secret}\n`, { mode: 0o644 })
52
+ await fs.rename(tmpPath, filePath)
53
+ await fs.chmod(filePath, 0o644)
54
+ }
55
+
56
+ /**
57
+ * Ensure a valid MCP server API key exists and its plaintext secret is stored
58
+ * in `filePath`. The DB only holds a bcrypt hash, so the secret file is the
59
+ * idempotency anchor: when it still resolves to a live key of the expected
60
+ * name, nothing changes. Otherwise stale keys with that name are soft-deleted
61
+ * and a fresh key is created, owned by the superadmin user so header-only MCP
62
+ * calls resolve an ACL context via `createdBy`.
63
+ */
64
+ export async function ensureMcpApiKey(options: EnsureMcpApiKeyOptions): Promise<EnsureMcpApiKeyResult> {
65
+ const keyName = options.keyName?.trim() || DEFAULT_MCP_KEY_NAME
66
+ const existingSecret = await readSecretFile(options.filePath)
67
+
68
+ if (existingSecret && existingSecret.startsWith('omk_') && !options.rotate) {
69
+ const existingKey = await findApiKeyBySecret(options.em, existingSecret)
70
+ if (existingKey && existingKey.name === keyName) {
71
+ return { status: 'valid', keyId: existingKey.id, keyPrefix: existingKey.keyPrefix }
72
+ }
73
+ }
74
+
75
+ const ownerEmail =
76
+ options.ownerEmail?.trim() ||
77
+ process.env.OM_INIT_SUPERADMIN_EMAIL?.trim() ||
78
+ DEFAULT_MCP_KEY_OWNER_EMAIL
79
+ // User.email is encrypted at rest with an email_hash lookup column, and
80
+ // findOneWithDecryption does not rewrite filters — a plaintext { email }
81
+ // match would find nothing once encryption is on. Mirror the $or pattern
82
+ // from AuthService.findUserByEmail so both modes resolve the owner.
83
+ const owner = await findOneWithDecryption(
84
+ options.em,
85
+ User,
86
+ {
87
+ deletedAt: null,
88
+ $or: [
89
+ { email: ownerEmail },
90
+ { emailHash: { $in: emailHashLookupValues(ownerEmail) } },
91
+ ],
92
+ },
93
+ {},
94
+ { tenantId: null, organizationId: null },
95
+ )
96
+ if (!owner) {
97
+ throw new Error(
98
+ `[internal] MCP API key owner not found: no active user with email "${ownerEmail}". ` +
99
+ 'Run "mercato init" first or pass --email pointing at an existing admin user.',
100
+ )
101
+ }
102
+
103
+ const roleIds = await getUserRoleIds(options.em, owner.id, owner.tenantId ?? null)
104
+
105
+ // Scope the cleanup to the owner's tenant: keys with the same name in other
106
+ // tenants belong to other stacks/tenants and must never be revoked here.
107
+ const staleKeys = await options.em.find(ApiKey, {
108
+ name: keyName,
109
+ tenantId: owner.tenantId ?? null,
110
+ deletedAt: null,
111
+ })
112
+ for (const staleKey of staleKeys) {
113
+ await deleteApiKey(options.em, staleKey.id, { rbac: options.rbac })
114
+ }
115
+
116
+ const { record, secret } = await createApiKey(
117
+ options.em,
118
+ {
119
+ name: keyName,
120
+ description: 'MCP server key provisioned by mcp:ensure-api-key',
121
+ tenantId: owner.tenantId ?? null,
122
+ organizationId: null,
123
+ roles: roleIds,
124
+ createdBy: owner.id,
125
+ },
126
+ { rbac: options.rbac },
127
+ )
128
+
129
+ await writeSecretFile(options.filePath, secret)
130
+
131
+ return { status: 'created', keyId: record.id, keyPrefix: record.keyPrefix }
132
+ }
@@ -0,0 +1,30 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { UserRole } from '@open-mercato/core/modules/auth/data/entities'
3
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+
5
+ /**
6
+ * Resolve a user's role ids for the given tenant.
7
+ *
8
+ * Single source for every AI-assistant surface that mints a role-carrying
9
+ * API key (the mcp-key and session-key routes, the chat route's ephemeral
10
+ * session keys, and the mcp:ensure-api-key CLI), so all of those keys
11
+ * inherit exactly the same ACL for the same user.
12
+ */
13
+ export async function getUserRoleIds(
14
+ em: EntityManager,
15
+ userId: string,
16
+ tenantId: string | null,
17
+ ): Promise<string[]> {
18
+ if (!tenantId) return []
19
+ const links = await findWithDecryption(
20
+ em,
21
+ UserRole,
22
+ { user: userId, role: { tenantId } },
23
+ { populate: ['role'] },
24
+ { tenantId, organizationId: null },
25
+ )
26
+ const linkList = Array.isArray(links) ? links : []
27
+ return linkList
28
+ .map((link) => link.role?.id)
29
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
30
+ }