@open-mercato/ai-assistant 0.6.6-develop.6543.1.49fadb4c31 → 0.6.6

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 (33) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +7 -20
  3. package/dist/modules/ai_assistant/api/chat/route.js +14 -1
  4. package/dist/modules/ai_assistant/api/chat/route.js.map +2 -2
  5. package/dist/modules/ai_assistant/api/mcp-key/route.js +14 -1
  6. package/dist/modules/ai_assistant/api/mcp-key/route.js.map +2 -2
  7. package/dist/modules/ai_assistant/api/session-key/route.js +14 -1
  8. package/dist/modules/ai_assistant/api/session-key/route.js.map +2 -2
  9. package/dist/modules/ai_assistant/api/settings/route.js +1 -16
  10. package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
  11. package/dist/modules/ai_assistant/cli.js +0 -35
  12. package/dist/modules/ai_assistant/cli.js.map +2 -2
  13. package/dist/modules/ai_assistant/lib/codemode-tools.js +8 -3
  14. package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
  15. package/package.json +7 -8
  16. package/src/modules/ai_assistant/api/chat/route.ts +25 -1
  17. package/src/modules/ai_assistant/api/mcp-key/route.ts +25 -1
  18. package/src/modules/ai_assistant/api/session-key/route.ts +25 -1
  19. package/src/modules/ai_assistant/api/settings/route.ts +2 -22
  20. package/src/modules/ai_assistant/cli.ts +0 -42
  21. package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +0 -146
  22. package/src/modules/ai_assistant/lib/codemode-tools.ts +11 -6
  23. package/dist/modules/ai_assistant/lib/mcp-ensure-api-key.js +0 -89
  24. package/dist/modules/ai_assistant/lib/mcp-ensure-api-key.js.map +0 -7
  25. package/dist/modules/ai_assistant/lib/scope-injection.js +0 -23
  26. package/dist/modules/ai_assistant/lib/scope-injection.js.map +0 -7
  27. package/dist/modules/ai_assistant/lib/user-role-ids.js +0 -18
  28. package/dist/modules/ai_assistant/lib/user-role-ids.js.map +0 -7
  29. package/src/modules/ai_assistant/lib/__tests__/mcp-ensure-api-key.test.ts +0 -182
  30. package/src/modules/ai_assistant/lib/__tests__/scope-injection.test.ts +0 -66
  31. package/src/modules/ai_assistant/lib/mcp-ensure-api-key.ts +0 -132
  32. package/src/modules/ai_assistant/lib/scope-injection.ts +0 -47
  33. package/src/modules/ai_assistant/lib/user-role-ids.ts +0 -30
@@ -477,149 +477,3 @@ describe('mutation call cap (issue #2724)', () => {
477
477
  expect(counts()).toEqual({ apiCallCount: CODE_MODE_MAX_MUTATION_CALLS + 5, mutationCallCount: 0 })
478
478
  })
479
479
  })
480
-
481
- describe('scope enforcement (issue #2658)', () => {
482
- const listCompaniesEndpoint = {
483
- id: 'list_companies',
484
- operationId: 'list_companies',
485
- method: 'GET',
486
- path: '/api/customers/companies',
487
- summary: '',
488
- description: '',
489
- tags: [],
490
- requiredFeatures: [],
491
- parameters: [],
492
- requestBodySchema: null,
493
- deprecated: false,
494
- }
495
- const createCompanyEndpoint = {
496
- id: 'create_company',
497
- operationId: 'create_company',
498
- method: 'POST',
499
- path: '/api/customers/companies',
500
- summary: '',
501
- description: '',
502
- tags: [],
503
- requiredFeatures: ['customers.companies.create'],
504
- parameters: [],
505
- requestBodySchema: null,
506
- deprecated: false,
507
- }
508
-
509
- beforeEach(() => {
510
- mockedGetApiEndpoints.mockReset()
511
- mockedFetchWithTimeout.mockReset()
512
- mockedFetchWithTimeout.mockResolvedValue(okResponse())
513
- })
514
-
515
- function fetchedUrl(): URL {
516
- const [url] = mockedFetchWithTimeout.mock.calls[0]
517
- return new URL(url as string)
518
- }
519
- function fetchedBody(): Record<string, unknown> {
520
- const [, init] = mockedFetchWithTimeout.mock.calls[0]
521
- return JSON.parse((init as { body: string }).body)
522
- }
523
- function creatorContext(scope: Partial<McpToolContext> = {}): McpToolContext {
524
- return createContext({
525
- userFeatures: ['ai_assistant.view', 'customers.companies.create'],
526
- ...scope,
527
- })
528
- }
529
-
530
- it('drops AI-supplied query scope when ctx scope is null (fail closed)', async () => {
531
- mockedGetApiEndpoints.mockResolvedValue([listCompaniesEndpoint])
532
- const apiRequest = createApiRequestFn(
533
- createContext({ tenantId: null, organizationId: null }),
534
- () => {}
535
- )
536
-
537
- await apiRequest({
538
- method: 'GET',
539
- path: '/api/customers/companies',
540
- query: { tenantId: 'evil', organizationId: 'evil-org', city: 'NYC' },
541
- })
542
-
543
- const params = fetchedUrl().searchParams
544
- expect(params.get('tenantId')).toBeNull()
545
- expect(params.get('organizationId')).toBeNull()
546
- expect(params.get('city')).toBe('NYC')
547
- })
548
-
549
- it('overrides AI-supplied query scope with ctx scope', async () => {
550
- mockedGetApiEndpoints.mockResolvedValue([listCompaniesEndpoint])
551
- const apiRequest = createApiRequestFn(createContext(), () => {})
552
-
553
- await apiRequest({
554
- method: 'GET',
555
- path: '/api/customers/companies',
556
- query: { tenantId: 'evil' },
557
- })
558
-
559
- expect(fetchedUrl().searchParams.get('tenantId')).toBe('tenant-1')
560
- })
561
-
562
- it('strips AI-supplied body scope when ctx scope is null', async () => {
563
- mockedGetApiEndpoints.mockResolvedValue([createCompanyEndpoint])
564
- const apiRequest = createApiRequestFn(
565
- creatorContext({ tenantId: null, organizationId: null }),
566
- () => {}
567
- )
568
-
569
- await apiRequest({
570
- method: 'POST',
571
- path: '/api/customers/companies',
572
- body: { name: 'Acme', tenantId: 'evil', organizationId: 'evil-org' },
573
- })
574
-
575
- const body = fetchedBody()
576
- expect(body.name).toBe('Acme')
577
- expect('tenantId' in body).toBe(false)
578
- expect('organizationId' in body).toBe(false)
579
- })
580
-
581
- it('overrides AI-supplied body scope with ctx scope', async () => {
582
- mockedGetApiEndpoints.mockResolvedValue([createCompanyEndpoint])
583
- const apiRequest = createApiRequestFn(creatorContext(), () => {})
584
-
585
- await apiRequest({
586
- method: 'POST',
587
- path: '/api/customers/companies',
588
- body: { name: 'Acme', tenantId: 'evil' },
589
- })
590
-
591
- const body = fetchedBody()
592
- expect(body.tenantId).toBe('tenant-1')
593
- expect(body.organizationId).toBe('org-1')
594
- })
595
-
596
- it('enforces query scope on non-GET methods too (not only GET)', async () => {
597
- mockedGetApiEndpoints.mockResolvedValue([createCompanyEndpoint])
598
- const apiRequest = createApiRequestFn(creatorContext(), () => {})
599
-
600
- await apiRequest({
601
- method: 'POST',
602
- path: '/api/customers/companies',
603
- query: { tenantId: 'evil' },
604
- body: {},
605
- })
606
-
607
- expect(fetchedUrl().searchParams.get('tenantId')).toBe('tenant-1')
608
- })
609
-
610
- it('drops dangerous prototype-pollution keys from the body', async () => {
611
- mockedGetApiEndpoints.mockResolvedValue([createCompanyEndpoint])
612
- const apiRequest = createApiRequestFn(creatorContext(), () => {})
613
-
614
- await apiRequest({
615
- method: 'POST',
616
- path: '/api/customers/companies',
617
- body: { name: 'Acme', ['__proto__']: { polluted: true } } as Record<string, unknown>,
618
- })
619
-
620
- const body = fetchedBody()
621
- expect(body.name).toBe('Acme')
622
- expect(Object.prototype.hasOwnProperty.call(body, '__proto__')).toBe(false)
623
- expect(({} as Record<string, unknown>).polluted).toBeUndefined()
624
- })
625
- })
@@ -16,7 +16,6 @@ import { registerMcpTool } from './tool-registry'
16
16
  import type { McpToolContext } from './types'
17
17
  import { createSandbox } from './sandbox'
18
18
  import { truncateResult } from './truncate'
19
- import { applyContextScopeToQuery, applyContextScopeToBody } from './scope-injection'
20
19
  import { hasRequiredFeatures } from './auth'
21
20
  import { getApiEndpoints, getRawOpenApiSpec, type ApiEndpoint } from './api-endpoint-index'
22
21
  import {
@@ -793,19 +792,25 @@ export function createApiRequestFn(
793
792
 
794
793
  let url = `${baseUrl}${apiPath}`
795
794
 
796
- // Build query parameters — scope is enforced from ctx for every method, not only
797
- // GET, so AI-supplied tenantId/organizationId can never survive (see scope-injection).
798
- const queryParams = applyContextScopeToQuery(query, ctx)
795
+ // Build query parameters
796
+ const queryParams: Record<string, string> = { ...query }
797
+
798
+ if (normalizedMethod === 'GET') {
799
+ if (ctx.tenantId) queryParams.tenantId = ctx.tenantId
800
+ if (ctx.organizationId) queryParams.organizationId = ctx.organizationId
801
+ }
799
802
 
800
803
  if (Object.keys(queryParams).length > 0) {
801
804
  const separator = url.includes('?') ? '&' : '?'
802
805
  url += separator + new URLSearchParams(queryParams).toString()
803
806
  }
804
807
 
805
- // Build request body with context-enforced scope
808
+ // Build request body with context injection
806
809
  let requestBody: Record<string, unknown> | undefined
807
810
  if (['POST', 'PUT', 'PATCH'].includes(normalizedMethod)) {
808
- requestBody = applyContextScopeToBody(body, ctx)
811
+ requestBody = { ...body }
812
+ if (ctx.tenantId) requestBody.tenantId = ctx.tenantId
813
+ if (ctx.organizationId) requestBody.organizationId = ctx.organizationId
809
814
  }
810
815
 
811
816
  // Build headers
@@ -1,89 +0,0 @@
1
- import { promises as fs } from "node:fs";
2
- import { dirname } from "node:path";
3
- import { ApiKey } from "@open-mercato/core/modules/api_keys/data/entities";
4
- import {
5
- createApiKey,
6
- deleteApiKey,
7
- findApiKeyBySecret
8
- } from "@open-mercato/core/modules/api_keys/services/apiKeyService";
9
- import { User } from "@open-mercato/core/modules/auth/data/entities";
10
- import { emailHashLookupValues } from "@open-mercato/core/modules/auth/lib/emailHash";
11
- import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
12
- import { getUserRoleIds } from "./user-role-ids.js";
13
- const DEFAULT_MCP_KEY_NAME = "__mcp_server__";
14
- const DEFAULT_MCP_KEY_OWNER_EMAIL = "superadmin@acme.com";
15
- async function readSecretFile(filePath) {
16
- try {
17
- const raw = await fs.readFile(filePath, "utf8");
18
- const trimmed = raw.trim();
19
- return trimmed.length > 0 ? trimmed : null;
20
- } catch {
21
- return null;
22
- }
23
- }
24
- async function writeSecretFile(filePath, secret) {
25
- await fs.mkdir(dirname(filePath), { recursive: true });
26
- const tmpPath = `${filePath}.tmp`;
27
- await fs.writeFile(tmpPath, `${secret}
28
- `, { mode: 420 });
29
- await fs.rename(tmpPath, filePath);
30
- await fs.chmod(filePath, 420);
31
- }
32
- async function ensureMcpApiKey(options) {
33
- const keyName = options.keyName?.trim() || DEFAULT_MCP_KEY_NAME;
34
- const existingSecret = await readSecretFile(options.filePath);
35
- if (existingSecret && existingSecret.startsWith("omk_") && !options.rotate) {
36
- const existingKey = await findApiKeyBySecret(options.em, existingSecret);
37
- if (existingKey && existingKey.name === keyName) {
38
- return { status: "valid", keyId: existingKey.id, keyPrefix: existingKey.keyPrefix };
39
- }
40
- }
41
- const ownerEmail = options.ownerEmail?.trim() || process.env.OM_INIT_SUPERADMIN_EMAIL?.trim() || DEFAULT_MCP_KEY_OWNER_EMAIL;
42
- const owner = await findOneWithDecryption(
43
- options.em,
44
- User,
45
- {
46
- deletedAt: null,
47
- $or: [
48
- { email: ownerEmail },
49
- { emailHash: { $in: emailHashLookupValues(ownerEmail) } }
50
- ]
51
- },
52
- {},
53
- { tenantId: null, organizationId: null }
54
- );
55
- if (!owner) {
56
- throw new Error(
57
- `[internal] MCP API key owner not found: no active user with email "${ownerEmail}". Run "mercato init" first or pass --email pointing at an existing admin user.`
58
- );
59
- }
60
- const roleIds = await getUserRoleIds(options.em, owner.id, owner.tenantId ?? null);
61
- const staleKeys = await options.em.find(ApiKey, {
62
- name: keyName,
63
- tenantId: owner.tenantId ?? null,
64
- deletedAt: null
65
- });
66
- for (const staleKey of staleKeys) {
67
- await deleteApiKey(options.em, staleKey.id, { rbac: options.rbac });
68
- }
69
- const { record, secret } = await createApiKey(
70
- options.em,
71
- {
72
- name: keyName,
73
- description: "MCP server key provisioned by mcp:ensure-api-key",
74
- tenantId: owner.tenantId ?? null,
75
- organizationId: null,
76
- roles: roleIds,
77
- createdBy: owner.id
78
- },
79
- { rbac: options.rbac }
80
- );
81
- await writeSecretFile(options.filePath, secret);
82
- return { status: "created", keyId: record.id, keyPrefix: record.keyPrefix };
83
- }
84
- export {
85
- DEFAULT_MCP_KEY_NAME,
86
- DEFAULT_MCP_KEY_OWNER_EMAIL,
87
- ensureMcpApiKey
88
- };
89
- //# sourceMappingURL=mcp-ensure-api-key.js.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../../src/modules/ai_assistant/lib/mcp-ensure-api-key.ts"],
4
- "sourcesContent": ["import { promises as fs } from 'node:fs'\nimport { dirname } from 'node:path'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { ApiKey } from '@open-mercato/core/modules/api_keys/data/entities'\nimport {\n createApiKey,\n deleteApiKey,\n findApiKeyBySecret,\n} from '@open-mercato/core/modules/api_keys/services/apiKeyService'\nimport { User } from '@open-mercato/core/modules/auth/data/entities'\nimport { emailHashLookupValues } from '@open-mercato/core/modules/auth/lib/emailHash'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { getUserRoleIds } from './user-role-ids'\n\nexport const DEFAULT_MCP_KEY_NAME = '__mcp_server__'\nexport const DEFAULT_MCP_KEY_OWNER_EMAIL = 'superadmin@acme.com'\n\nexport type EnsureMcpApiKeyOptions = {\n em: EntityManager\n filePath: string\n keyName?: string\n ownerEmail?: string\n rotate?: boolean\n rbac?: RbacService\n}\n\nexport type EnsureMcpApiKeyResult = {\n status: 'valid' | 'created'\n keyId: string\n keyPrefix: string\n}\n\nasync function readSecretFile(filePath: string): Promise<string | null> {\n try {\n const raw = await fs.readFile(filePath, 'utf8')\n const trimmed = raw.trim()\n return trimmed.length > 0 ? trimmed : null\n } catch {\n return null\n }\n}\n\nasync function writeSecretFile(filePath: string, secret: string): Promise<void> {\n await fs.mkdir(dirname(filePath), { recursive: true })\n const tmpPath = `${filePath}.tmp`\n // World-readable on purpose: the file lives in a dedicated named volume\n // mounted only into this stack's containers, and the consumer (the OpenCode\n // container) runs as a non-root user that cannot read a root-owned 0600\n // file. The volume boundary, not the file mode, is the security boundary.\n await fs.writeFile(tmpPath, `${secret}\\n`, { mode: 0o644 })\n await fs.rename(tmpPath, filePath)\n await fs.chmod(filePath, 0o644)\n}\n\n/**\n * Ensure a valid MCP server API key exists and its plaintext secret is stored\n * in `filePath`. The DB only holds a bcrypt hash, so the secret file is the\n * idempotency anchor: when it still resolves to a live key of the expected\n * name, nothing changes. Otherwise stale keys with that name are soft-deleted\n * and a fresh key is created, owned by the superadmin user so header-only MCP\n * calls resolve an ACL context via `createdBy`.\n */\nexport async function ensureMcpApiKey(options: EnsureMcpApiKeyOptions): Promise<EnsureMcpApiKeyResult> {\n const keyName = options.keyName?.trim() || DEFAULT_MCP_KEY_NAME\n const existingSecret = await readSecretFile(options.filePath)\n\n if (existingSecret && existingSecret.startsWith('omk_') && !options.rotate) {\n const existingKey = await findApiKeyBySecret(options.em, existingSecret)\n if (existingKey && existingKey.name === keyName) {\n return { status: 'valid', keyId: existingKey.id, keyPrefix: existingKey.keyPrefix }\n }\n }\n\n const ownerEmail =\n options.ownerEmail?.trim() ||\n process.env.OM_INIT_SUPERADMIN_EMAIL?.trim() ||\n DEFAULT_MCP_KEY_OWNER_EMAIL\n // User.email is encrypted at rest with an email_hash lookup column, and\n // findOneWithDecryption does not rewrite filters \u2014 a plaintext { email }\n // match would find nothing once encryption is on. Mirror the $or pattern\n // from AuthService.findUserByEmail so both modes resolve the owner.\n const owner = await findOneWithDecryption(\n options.em,\n User,\n {\n deletedAt: null,\n $or: [\n { email: ownerEmail },\n { emailHash: { $in: emailHashLookupValues(ownerEmail) } },\n ],\n },\n {},\n { tenantId: null, organizationId: null },\n )\n if (!owner) {\n throw new Error(\n `[internal] MCP API key owner not found: no active user with email \"${ownerEmail}\". ` +\n 'Run \"mercato init\" first or pass --email pointing at an existing admin user.',\n )\n }\n\n const roleIds = await getUserRoleIds(options.em, owner.id, owner.tenantId ?? null)\n\n // Scope the cleanup to the owner's tenant: keys with the same name in other\n // tenants belong to other stacks/tenants and must never be revoked here.\n const staleKeys = await options.em.find(ApiKey, {\n name: keyName,\n tenantId: owner.tenantId ?? null,\n deletedAt: null,\n })\n for (const staleKey of staleKeys) {\n await deleteApiKey(options.em, staleKey.id, { rbac: options.rbac })\n }\n\n const { record, secret } = await createApiKey(\n options.em,\n {\n name: keyName,\n description: 'MCP server key provisioned by mcp:ensure-api-key',\n tenantId: owner.tenantId ?? null,\n organizationId: null,\n roles: roleIds,\n createdBy: owner.id,\n },\n { rbac: options.rbac },\n )\n\n await writeSecretFile(options.filePath, secret)\n\n return { status: 'created', keyId: record.id, keyPrefix: record.keyPrefix }\n}\n"],
5
- "mappings": "AAAA,SAAS,YAAY,UAAU;AAC/B,SAAS,eAAe;AAGxB,SAAS,cAAc;AACvB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,YAAY;AACrB,SAAS,6BAA6B;AACtC,SAAS,6BAA6B;AACtC,SAAS,sBAAsB;AAExB,MAAM,uBAAuB;AAC7B,MAAM,8BAA8B;AAiB3C,eAAe,eAAe,UAA0C;AACtE,MAAI;AACF,UAAM,MAAM,MAAM,GAAG,SAAS,UAAU,MAAM;AAC9C,UAAM,UAAU,IAAI,KAAK;AACzB,WAAO,QAAQ,SAAS,IAAI,UAAU;AAAA,EACxC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAe,gBAAgB,UAAkB,QAA+B;AAC9E,QAAM,GAAG,MAAM,QAAQ,QAAQ,GAAG,EAAE,WAAW,KAAK,CAAC;AACrD,QAAM,UAAU,GAAG,QAAQ;AAK3B,QAAM,GAAG,UAAU,SAAS,GAAG,MAAM;AAAA,GAAM,EAAE,MAAM,IAAM,CAAC;AAC1D,QAAM,GAAG,OAAO,SAAS,QAAQ;AACjC,QAAM,GAAG,MAAM,UAAU,GAAK;AAChC;AAUA,eAAsB,gBAAgB,SAAiE;AACrG,QAAM,UAAU,QAAQ,SAAS,KAAK,KAAK;AAC3C,QAAM,iBAAiB,MAAM,eAAe,QAAQ,QAAQ;AAE5D,MAAI,kBAAkB,eAAe,WAAW,MAAM,KAAK,CAAC,QAAQ,QAAQ;AAC1E,UAAM,cAAc,MAAM,mBAAmB,QAAQ,IAAI,cAAc;AACvE,QAAI,eAAe,YAAY,SAAS,SAAS;AAC/C,aAAO,EAAE,QAAQ,SAAS,OAAO,YAAY,IAAI,WAAW,YAAY,UAAU;AAAA,IACpF;AAAA,EACF;AAEA,QAAM,aACJ,QAAQ,YAAY,KAAK,KACzB,QAAQ,IAAI,0BAA0B,KAAK,KAC3C;AAKF,QAAM,QAAQ,MAAM;AAAA,IAClB,QAAQ;AAAA,IACR;AAAA,IACA;AAAA,MACE,WAAW;AAAA,MACX,KAAK;AAAA,QACH,EAAE,OAAO,WAAW;AAAA,QACpB,EAAE,WAAW,EAAE,KAAK,sBAAsB,UAAU,EAAE,EAAE;AAAA,MAC1D;AAAA,IACF;AAAA,IACA,CAAC;AAAA,IACD,EAAE,UAAU,MAAM,gBAAgB,KAAK;AAAA,EACzC;AACA,MAAI,CAAC,OAAO;AACV,UAAM,IAAI;AAAA,MACR,sEAAsE,UAAU;AAAA,IAElF;AAAA,EACF;AAEA,QAAM,UAAU,MAAM,eAAe,QAAQ,IAAI,MAAM,IAAI,MAAM,YAAY,IAAI;AAIjF,QAAM,YAAY,MAAM,QAAQ,GAAG,KAAK,QAAQ;AAAA,IAC9C,MAAM;AAAA,IACN,UAAU,MAAM,YAAY;AAAA,IAC5B,WAAW;AAAA,EACb,CAAC;AACD,aAAW,YAAY,WAAW;AAChC,UAAM,aAAa,QAAQ,IAAI,SAAS,IAAI,EAAE,MAAM,QAAQ,KAAK,CAAC;AAAA,EACpE;AAEA,QAAM,EAAE,QAAQ,OAAO,IAAI,MAAM;AAAA,IAC/B,QAAQ;AAAA,IACR;AAAA,MACE,MAAM;AAAA,MACN,aAAa;AAAA,MACb,UAAU,MAAM,YAAY;AAAA,MAC5B,gBAAgB;AAAA,MAChB,OAAO;AAAA,MACP,WAAW,MAAM;AAAA,IACnB;AAAA,IACA,EAAE,MAAM,QAAQ,KAAK;AAAA,EACvB;AAEA,QAAM,gBAAgB,QAAQ,UAAU,MAAM;AAE9C,SAAO,EAAE,QAAQ,WAAW,OAAO,OAAO,IAAI,WAAW,OAAO,UAAU;AAC5E;",
6
- "names": []
7
- }
@@ -1,23 +0,0 @@
1
- const SCOPE_FIELDS = ["tenantId", "organizationId"];
2
- const DANGEROUS_KEYS = ["__proto__", "constructor", "prototype"];
3
- function applyContextScopeToQuery(query, ctx) {
4
- const result = { ...query };
5
- for (const field of DANGEROUS_KEYS) delete result[field];
6
- for (const field of SCOPE_FIELDS) delete result[field];
7
- if (ctx.tenantId) result.tenantId = ctx.tenantId;
8
- if (ctx.organizationId) result.organizationId = ctx.organizationId;
9
- return result;
10
- }
11
- function applyContextScopeToBody(body, ctx) {
12
- const result = { ...body };
13
- for (const field of DANGEROUS_KEYS) delete result[field];
14
- for (const field of SCOPE_FIELDS) delete result[field];
15
- if (ctx.tenantId) result.tenantId = ctx.tenantId;
16
- if (ctx.organizationId) result.organizationId = ctx.organizationId;
17
- return result;
18
- }
19
- export {
20
- applyContextScopeToBody,
21
- applyContextScopeToQuery
22
- };
23
- //# sourceMappingURL=scope-injection.js.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../../src/modules/ai_assistant/lib/scope-injection.ts"],
4
- "sourcesContent": ["import type { McpToolContext } from './types'\n\n/**\n * Tenant/org scope for outgoing api.request() calls is derived strictly from the\n * operating context \u2014 never from sandbox (AI-authored) code. Any tenantId/organizationId\n * the sandbox placed in query/body is stripped first, then re-applied from ctx only when\n * present. When the ctx scope is null the fields stay removed (fail closed), so AI-supplied\n * scope can never survive regardless of whether the context is tenant-bound.\n *\n * Path safety (traversal, percent-encoded separators, embedded query strings) is handled\n * upstream by normalizeApiRequestPath / isUnsafeApiRequestPath in codemode-tools.\n */\n\nconst SCOPE_FIELDS = ['tenantId', 'organizationId'] as const\n\n// Dropped from any sandbox-supplied payload defensively so a top-level \"__proto__\" (etc.)\n// can never ride along to a downstream handler that merges request data unsafely.\nconst DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'] as const\n\ntype ContextScope = Pick<McpToolContext, 'tenantId' | 'organizationId'>\n\nexport function applyContextScopeToQuery(\n query: Record<string, string> | undefined,\n ctx: ContextScope\n): Record<string, string> {\n const result: Record<string, string> = { ...query }\n for (const field of DANGEROUS_KEYS) delete result[field]\n for (const field of SCOPE_FIELDS) delete result[field]\n if (ctx.tenantId) result.tenantId = ctx.tenantId\n if (ctx.organizationId) result.organizationId = ctx.organizationId\n return result\n}\n\nexport function applyContextScopeToBody(\n body: Record<string, unknown> | undefined,\n ctx: ContextScope\n): Record<string, unknown> {\n // Object spread copies own enumerable props via data-property definition (not the\n // `__proto__` setter), so the result is a plain object; deleting the dangerous keys\n // then removes any that rode in as own properties.\n const result: Record<string, unknown> = { ...body }\n for (const field of DANGEROUS_KEYS) delete result[field]\n for (const field of SCOPE_FIELDS) delete result[field]\n if (ctx.tenantId) result.tenantId = ctx.tenantId\n if (ctx.organizationId) result.organizationId = ctx.organizationId\n return result\n}\n"],
5
- "mappings": "AAaA,MAAM,eAAe,CAAC,YAAY,gBAAgB;AAIlD,MAAM,iBAAiB,CAAC,aAAa,eAAe,WAAW;AAIxD,SAAS,yBACd,OACA,KACwB;AACxB,QAAM,SAAiC,EAAE,GAAG,MAAM;AAClD,aAAW,SAAS,eAAgB,QAAO,OAAO,KAAK;AACvD,aAAW,SAAS,aAAc,QAAO,OAAO,KAAK;AACrD,MAAI,IAAI,SAAU,QAAO,WAAW,IAAI;AACxC,MAAI,IAAI,eAAgB,QAAO,iBAAiB,IAAI;AACpD,SAAO;AACT;AAEO,SAAS,wBACd,MACA,KACyB;AAIzB,QAAM,SAAkC,EAAE,GAAG,KAAK;AAClD,aAAW,SAAS,eAAgB,QAAO,OAAO,KAAK;AACvD,aAAW,SAAS,aAAc,QAAO,OAAO,KAAK;AACrD,MAAI,IAAI,SAAU,QAAO,WAAW,IAAI;AACxC,MAAI,IAAI,eAAgB,QAAO,iBAAiB,IAAI;AACpD,SAAO;AACT;",
6
- "names": []
7
- }
@@ -1,18 +0,0 @@
1
- import { UserRole } from "@open-mercato/core/modules/auth/data/entities";
2
- import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
3
- async function getUserRoleIds(em, userId, tenantId) {
4
- if (!tenantId) return [];
5
- const links = await findWithDecryption(
6
- em,
7
- UserRole,
8
- { user: userId, role: { tenantId } },
9
- { populate: ["role"] },
10
- { tenantId, organizationId: null }
11
- );
12
- const linkList = Array.isArray(links) ? links : [];
13
- return linkList.map((link) => link.role?.id).filter((id) => typeof id === "string" && id.length > 0);
14
- }
15
- export {
16
- getUserRoleIds
17
- };
18
- //# sourceMappingURL=user-role-ids.js.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../../src/modules/ai_assistant/lib/user-role-ids.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\n/**\n * Resolve a user's role ids for the given tenant.\n *\n * Single source for every AI-assistant surface that mints a role-carrying\n * API key (the mcp-key and session-key routes, the chat route's ephemeral\n * session keys, and the mcp:ensure-api-key CLI), so all of those keys\n * inherit exactly the same ACL for the same user.\n */\nexport async function getUserRoleIds(\n em: EntityManager,\n userId: string,\n tenantId: string | null,\n): Promise<string[]> {\n if (!tenantId) return []\n const links = await findWithDecryption(\n em,\n UserRole,\n { user: userId, role: { tenantId } },\n { populate: ['role'] },\n { tenantId, organizationId: null },\n )\n const linkList = Array.isArray(links) ? links : []\n return linkList\n .map((link) => link.role?.id)\n .filter((id): id is string => typeof id === 'string' && id.length > 0)\n}\n"],
5
- "mappings": "AACA,SAAS,gBAAgB;AACzB,SAAS,0BAA0B;AAUnC,eAAsB,eACpB,IACA,QACA,UACmB;AACnB,MAAI,CAAC,SAAU,QAAO,CAAC;AACvB,QAAM,QAAQ,MAAM;AAAA,IAClB;AAAA,IACA;AAAA,IACA,EAAE,MAAM,QAAQ,MAAM,EAAE,SAAS,EAAE;AAAA,IACnC,EAAE,UAAU,CAAC,MAAM,EAAE;AAAA,IACrB,EAAE,UAAU,gBAAgB,KAAK;AAAA,EACnC;AACA,QAAM,WAAW,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAC;AACjD,SAAO,SACJ,IAAI,CAAC,SAAS,KAAK,MAAM,EAAE,EAC3B,OAAO,CAAC,OAAqB,OAAO,OAAO,YAAY,GAAG,SAAS,CAAC;AACzE;",
6
- "names": []
7
- }
@@ -1,182 +0,0 @@
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
- })
@@ -1,66 +0,0 @@
1
- import { applyContextScopeToQuery, applyContextScopeToBody } from '../scope-injection'
2
-
3
- const NULL_SCOPE = { tenantId: null, organizationId: null }
4
- const BOUND_SCOPE = { tenantId: 'ctx-tenant', organizationId: 'ctx-org' }
5
-
6
- describe('applyContextScopeToQuery', () => {
7
- it('strips AI-supplied scope when ctx scope is null (fail closed)', () => {
8
- const result = applyContextScopeToQuery(
9
- { tenantId: 'evil', organizationId: 'evil-org', city: 'NYC' },
10
- NULL_SCOPE
11
- )
12
- expect('tenantId' in result).toBe(false)
13
- expect('organizationId' in result).toBe(false)
14
- expect(result.city).toBe('NYC')
15
- })
16
-
17
- it('overrides AI-supplied scope with ctx scope', () => {
18
- const result = applyContextScopeToQuery({ tenantId: 'evil' }, BOUND_SCOPE)
19
- expect(result.tenantId).toBe('ctx-tenant')
20
- expect(result.organizationId).toBe('ctx-org')
21
- })
22
-
23
- it('handles undefined query', () => {
24
- expect(applyContextScopeToQuery(undefined, NULL_SCOPE)).toEqual({})
25
- })
26
-
27
- it('drops dangerous prototype-pollution keys', () => {
28
- const result = applyContextScopeToQuery(
29
- { ['__proto__']: 'x', city: 'NYC' } as Record<string, string>,
30
- NULL_SCOPE
31
- )
32
- expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(false)
33
- expect(result.city).toBe('NYC')
34
- })
35
- })
36
-
37
- describe('applyContextScopeToBody', () => {
38
- it('strips AI-supplied scope when ctx scope is null', () => {
39
- const result = applyContextScopeToBody(
40
- { name: 'Acme', tenantId: 'evil', organizationId: 'evil-org' },
41
- NULL_SCOPE
42
- )
43
- expect(result.name).toBe('Acme')
44
- expect('tenantId' in result).toBe(false)
45
- expect('organizationId' in result).toBe(false)
46
- })
47
-
48
- it('applies ctx tenant even when only tenantId is scoped', () => {
49
- const result = applyContextScopeToBody(
50
- { tenantId: 'evil' },
51
- { tenantId: 'ctx-tenant', organizationId: null }
52
- )
53
- expect(result.tenantId).toBe('ctx-tenant')
54
- expect('organizationId' in result).toBe(false)
55
- })
56
-
57
- it('handles undefined body', () => {
58
- expect(applyContextScopeToBody(undefined, NULL_SCOPE)).toEqual({})
59
- })
60
-
61
- it('does not mutate the original body object', () => {
62
- const original = { tenantId: 'evil' }
63
- applyContextScopeToBody(original, BOUND_SCOPE)
64
- expect(original.tenantId).toBe('evil')
65
- })
66
- })