@open-mercato/ai-assistant 0.10.1-develop.7767.1.440f7c2b4c → 0.10.1-develop.7768.1.90fa052c11

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.
@@ -1,2 +1,2 @@
1
- [build:ai-assistant] found 248 entry points
1
+ [build:ai-assistant] found 249 entry points
2
2
  [build:ai-assistant] built successfully
package/AGENTS.md CHANGED
@@ -9,6 +9,8 @@
9
9
  - Run `yarn generate` after adding or changing agents, tools, API discovery metadata, or tool packs.
10
10
  - Route model selection through `createModelFactory(container)` instead of ad hoc provider clients.
11
11
  - Route mutation-capable AI tools through the mutation approval path before execution.
12
+ - Bind MCP and session keys with `sessionUserId`; RBAC intersects their role grants with the owner's effective ACL. Unbound machine keys retain independent role grants.
13
+ - In delegated-key tests, check organization isolation on business records. The UserAcl API clears an empty feature list; retain an unrelated feature when testing revocation.
12
14
 
13
15
  ## Ask First
14
16
 
@@ -1169,26 +1171,13 @@ Tool call → Extract _sessionToken → Lookup in DB → Load ACL → Check perm
1169
1171
  | **Lookup** | MUST use `findApiKeyBySessionToken()` — never query directly |
1170
1172
  | **ACL** | MUST use `rbacService.loadAcl()` — never bypass |
1171
1173
 
1172
- **Code references**:
1173
- - Session creation: `packages/ai-assistant/src/modules/ai_assistant/api/chat/route.ts:133-157`
1174
- - Token lookup: `packages/core/src/modules/api_keys/services/apiKeyService.ts:143-158`
1175
- - Context resolution: `packages/ai-assistant/src/modules/ai_assistant/lib/http-server.ts:32-88`
1174
+ See `api/chat/route.ts`, `lib/http-server.ts`, and core `api_keys/services/apiKeyService.ts` for issuance and resolution.
1176
1175
 
1177
1176
  ---
1178
1177
 
1179
1178
  ## Rules for Session Tokens
1180
1179
 
1181
- ### Generate Tokens Using This Function
1182
-
1183
- ```typescript
1184
- // packages/core/src/modules/api_keys/services/apiKeyService.ts:99-101
1185
- export function generateSessionToken(): string {
1186
- return `sess_${randomBytes(16).toString('hex')}`
1187
- }
1188
- // Result: "sess_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
1189
- ```
1190
-
1191
- MUST NOT generate tokens with any other format. MUST use `randomBytes(16)`.
1180
+ Use `generateSessionToken()` from core `api_keys/services/apiKeyService.ts`. It uses `randomBytes(16)` and returns `sess_` plus 32 hex characters; preserve that format.
1192
1181
 
1193
1182
  ### Token Storage (api_keys table)
1194
1183
 
@@ -0,0 +1,139 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
+ import { hash } from "bcryptjs";
4
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
5
+ import {
6
+ createRoleFixture,
7
+ createUserFixture,
8
+ deleteRoleIfExists,
9
+ deleteUserIfExists,
10
+ setRoleAclFeatures,
11
+ setUserAclVisibility
12
+ } from "@open-mercato/core/helpers/integration/authFixtures";
13
+ import {
14
+ clearUserHomeOrganization,
15
+ createOrganizationInDb,
16
+ deleteOrganizationInDb,
17
+ deleteUserAclInDb,
18
+ withClient
19
+ } from "@open-mercato/core/helpers/integration/dbFixtures";
20
+ import { deleteEntityByBody } from "@open-mercato/core/helpers/integration/crmFixtures";
21
+ import { expectId, getTokenScope, readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
22
+ for (const { keyKind, floating } of [
23
+ { keyKind: "mcp", floating: false },
24
+ { keyKind: "session", floating: false },
25
+ { keyKind: "mcp", floating: true }
26
+ ]) {
27
+ test(`${floating ? "tenant-wide " : ""}${keyKind} key preserves the owner UserAcl on cold and warm requests`, async ({ request }) => {
28
+ const adminToken = await getAuthToken(request, "superadmin");
29
+ const { tenantId } = getTokenScope(adminToken);
30
+ const stamp = randomUUID();
31
+ const email = `ai-key-${stamp}@example.com`;
32
+ const password = `Secret-${stamp}!`;
33
+ const features = ["api_keys.create", "ai_assistant.view", "customers.people.view"];
34
+ const personIds = [];
35
+ let roleId = null;
36
+ let userId = null;
37
+ let organizationId = null;
38
+ let foreignOrganizationId = null;
39
+ try {
40
+ organizationId = await createOrganizationInDb({ name: `AI owner ${stamp}`, tenantId });
41
+ foreignOrganizationId = await createOrganizationInDb({ name: `AI excluded ${stamp}`, tenantId });
42
+ for (const fixtureOrganizationId of [organizationId, foreignOrganizationId]) {
43
+ const person = await apiRequest(request, "POST", "/api/customers/people", {
44
+ token: adminToken,
45
+ headers: { Cookie: `om_selected_org=${fixtureOrganizationId}` },
46
+ data: { firstName: "AI ACL", lastName: stamp, displayName: `AI ACL ${stamp} ${fixtureOrganizationId}` }
47
+ });
48
+ expect(person.status()).toBe(201);
49
+ const created = await readJsonSafe(person);
50
+ personIds.push(expectId(created?.id, "Person fixture must return an id"));
51
+ }
52
+ roleId = await createRoleFixture(request, adminToken, { name: `AI broad ${stamp}`, tenantId });
53
+ await setRoleAclFeatures(request, adminToken, {
54
+ roleId,
55
+ features: [...features, "customers.people.manage"],
56
+ organizations: null
57
+ });
58
+ userId = await createUserFixture(request, adminToken, { email, password, organizationId, roles: [roleId] });
59
+ await setUserAclVisibility(request, adminToken, { userId, features, organizations: [organizationId] });
60
+ if (floating) await clearUserHomeOrganization(userId);
61
+ const ownerToken = await getAuthToken(request, email, password);
62
+ const issued = await apiRequest(request, "POST", `/api/ai_assistant/${keyKind}-key`, {
63
+ token: ownerToken,
64
+ data: {}
65
+ });
66
+ expect(issued.status()).toBe(200);
67
+ const body = await readJsonSafe(issued);
68
+ let secret = body?.secret;
69
+ if (keyKind === "session") {
70
+ expect(body?.sessionToken).toMatch(/^sess_[a-f0-9]{32}$/);
71
+ secret = `omk_${randomBytes(4).toString("hex")}.${randomBytes(24).toString("hex")}`;
72
+ const keyHash = await hash(secret, 10);
73
+ const boundRows = await withClient((client) => client.query(
74
+ "update api_keys set key_hash = $1, key_prefix = $2 where session_token_hash = $3 and session_user_id = $4 and tenant_id = $5 returning id",
75
+ [keyHash, secret.slice(0, 12), createHash("sha256").update(body.sessionToken).digest("hex"), userId, tenantId]
76
+ ));
77
+ expect(boundRows.rows).toHaveLength(1);
78
+ }
79
+ expect(secret).toMatch(/^omk_/);
80
+ const checkFeatures = async (token, expected) => {
81
+ const response = await apiRequest(request, "POST", "/api/auth/feature-check", {
82
+ token,
83
+ data: { features: ["customers.people.view", "customers.people.manage"] },
84
+ headers: token.startsWith("omk_") ? { "x-api-key": token } : {}
85
+ });
86
+ expect(response.status()).toBe(200);
87
+ const result = await readJsonSafe(response);
88
+ expect(result?.granted).toEqual(expected);
89
+ };
90
+ const checkPeople = async (token, expectedId) => {
91
+ const people = await apiRequest(request, "GET", `/api/customers/people?ids=${personIds.join(",")}&pageSize=100`, {
92
+ token,
93
+ headers: { Cookie: "om_selected_org=__all__", ...token.startsWith("omk_") ? { "x-api-key": token } : {} }
94
+ });
95
+ expect(people.status()).toBe(200);
96
+ const result = await readJsonSafe(people);
97
+ expect(result?.items.map((item) => item.id)).toEqual([expectedId]);
98
+ };
99
+ for (let attempt = 0; attempt < 2; attempt += 1) {
100
+ await checkFeatures(ownerToken, ["customers.people.view"]);
101
+ await checkFeatures(secret, ["customers.people.view"]);
102
+ for (const token of [ownerToken, secret]) {
103
+ await checkPeople(token, personIds[0]);
104
+ const denied = await apiRequest(request, "PUT", "/api/customers/people", {
105
+ token,
106
+ headers: token.startsWith("omk_") ? { "x-api-key": token } : {},
107
+ data: { id: personIds[0], displayName: `Denied ${stamp}` }
108
+ });
109
+ expect(denied.status()).toBe(403);
110
+ }
111
+ }
112
+ if (floating) {
113
+ await setUserAclVisibility(request, adminToken, { userId, features, organizations: [foreignOrganizationId] });
114
+ for (const token of [ownerToken, secret]) {
115
+ await checkFeatures(token, ["customers.people.view"]);
116
+ await checkPeople(token, personIds[1]);
117
+ }
118
+ }
119
+ await setUserAclVisibility(request, adminToken, { userId, features: ["ai_assistant.view"], organizations: [] });
120
+ const persistedAcl = await apiRequest(request, "GET", `/api/auth/users/acl?userId=${userId}`, { token: adminToken });
121
+ expect(persistedAcl.status()).toBe(200);
122
+ const acl = await readJsonSafe(persistedAcl);
123
+ expect(acl).toMatchObject({ hasCustomAcl: true, features: ["ai_assistant.view"], organizations: [] });
124
+ await checkFeatures(ownerToken, []);
125
+ await checkFeatures(secret, []);
126
+ } finally {
127
+ for (const personId of personIds) await deleteEntityByBody(request, adminToken, "/api/customers/people", personId);
128
+ if (userId) {
129
+ await withClient((client) => client.query("delete from api_keys where session_user_id = $1 and tenant_id = $2", [userId, tenantId]));
130
+ await deleteUserAclInDb(userId);
131
+ }
132
+ await deleteUserIfExists(request, adminToken, userId);
133
+ await deleteRoleIfExists(request, adminToken, roleId);
134
+ await deleteOrganizationInDb(organizationId);
135
+ await deleteOrganizationInDb(foreignOrganizationId);
136
+ }
137
+ });
138
+ }
139
+ //# sourceMappingURL=TC-AI-KEY-ACL-7825.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/ai_assistant/__integration__/TC-AI-KEY-ACL-7825.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { createHash, randomBytes, randomUUID } from 'node:crypto'\nimport { hash } from 'bcryptjs'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport {\n createRoleFixture, createUserFixture, deleteRoleIfExists, deleteUserIfExists,\n setRoleAclFeatures, setUserAclVisibility,\n} from '@open-mercato/core/helpers/integration/authFixtures'\nimport {\n clearUserHomeOrganization, createOrganizationInDb, deleteOrganizationInDb, deleteUserAclInDb, withClient,\n} from '@open-mercato/core/helpers/integration/dbFixtures'\nimport { deleteEntityByBody } from '@open-mercato/core/helpers/integration/crmFixtures'\nimport { expectId, getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\n\nfor (const { keyKind, floating } of [\n { keyKind: 'mcp', floating: false },\n { keyKind: 'session', floating: false },\n { keyKind: 'mcp', floating: true },\n] as const) {\n test(`${floating ? 'tenant-wide ' : ''}${keyKind} key preserves the owner UserAcl on cold and warm requests`, async ({ request }) => {\n const adminToken = await getAuthToken(request, 'superadmin')\n const { tenantId } = getTokenScope(adminToken)\n const stamp = randomUUID()\n const email = `ai-key-${stamp}@example.com`\n const password = `Secret-${stamp}!`\n const features = ['api_keys.create', 'ai_assistant.view', 'customers.people.view']\n const personIds: string[] = []\n let roleId: string | null = null\n let userId: string | null = null\n let organizationId: string | null = null\n let foreignOrganizationId: string | null = null\n try {\n organizationId = await createOrganizationInDb({ name: `AI owner ${stamp}`, tenantId })\n foreignOrganizationId = await createOrganizationInDb({ name: `AI excluded ${stamp}`, tenantId })\n for (const fixtureOrganizationId of [organizationId, foreignOrganizationId]) {\n const person = await apiRequest(request, 'POST', '/api/customers/people', {\n token: adminToken,\n headers: { Cookie: `om_selected_org=${fixtureOrganizationId}` },\n data: { firstName: 'AI ACL', lastName: stamp, displayName: `AI ACL ${stamp} ${fixtureOrganizationId}` },\n })\n expect(person.status()).toBe(201)\n const created = await readJsonSafe<{ id?: string }>(person)\n personIds.push(expectId(created?.id, 'Person fixture must return an id'))\n }\n roleId = await createRoleFixture(request, adminToken, { name: `AI broad ${stamp}`, tenantId })\n await setRoleAclFeatures(request, adminToken, {\n roleId, features: [...features, 'customers.people.manage'], organizations: null,\n })\n userId = await createUserFixture(request, adminToken, { email, password, organizationId, roles: [roleId] })\n await setUserAclVisibility(request, adminToken, { userId, features, organizations: [organizationId] })\n if (floating) await clearUserHomeOrganization(userId)\n const ownerToken = await getAuthToken(request, email, password)\n const issued = await apiRequest(request, 'POST', `/api/ai_assistant/${keyKind}-key`, {\n token: ownerToken, data: {},\n })\n expect(issued.status()).toBe(200)\n const body = await readJsonSafe<{ secret?: string; sessionToken?: string }>(issued)\n let secret = body?.secret\n if (keyKind === 'session') {\n expect(body?.sessionToken).toMatch(/^sess_[a-f0-9]{32}$/)\n secret = `omk_${randomBytes(4).toString('hex')}.${randomBytes(24).toString('hex')}`\n const keyHash = await hash(secret, 10)\n const boundRows = await withClient((client) => client.query<{ id: string }>(\n 'update api_keys set key_hash = $1, key_prefix = $2 where session_token_hash = $3 and session_user_id = $4 and tenant_id = $5 returning id',\n [keyHash, secret!.slice(0, 12), createHash('sha256').update(body!.sessionToken!).digest('hex'), userId, tenantId],\n ))\n expect(boundRows.rows).toHaveLength(1)\n }\n expect(secret).toMatch(/^omk_/)\n const checkFeatures = async (token: string, expected: string[]) => {\n const response = await apiRequest(request, 'POST', '/api/auth/feature-check', {\n token, data: { features: ['customers.people.view', 'customers.people.manage'] },\n headers: token.startsWith('omk_') ? { 'x-api-key': token } : {},\n })\n expect(response.status()).toBe(200)\n const result = await readJsonSafe<{ granted: string[] }>(response)\n expect(result?.granted).toEqual(expected)\n }\n const checkPeople = async (token: string, expectedId: string) => {\n const people = await apiRequest(request, 'GET', `/api/customers/people?ids=${personIds.join(',')}&pageSize=100`, {\n token,\n headers: { Cookie: 'om_selected_org=__all__', ...(token.startsWith('omk_') ? { 'x-api-key': token } : {}) },\n })\n expect(people.status()).toBe(200)\n const result = await readJsonSafe<{ items: Array<{ id: string }> }>(people)\n expect(result?.items.map((item) => item.id)).toEqual([expectedId])\n }\n for (let attempt = 0; attempt < 2; attempt += 1) {\n await checkFeatures(ownerToken, ['customers.people.view'])\n await checkFeatures(secret!, ['customers.people.view'])\n for (const token of [ownerToken, secret!]) {\n await checkPeople(token, personIds[0])\n const denied = await apiRequest(request, 'PUT', '/api/customers/people', {\n token,\n headers: token.startsWith('omk_') ? { 'x-api-key': token } : {},\n data: { id: personIds[0], displayName: `Denied ${stamp}` },\n })\n expect(denied.status()).toBe(403)\n }\n }\n if (floating) {\n await setUserAclVisibility(request, adminToken, { userId, features, organizations: [foreignOrganizationId] })\n for (const token of [ownerToken, secret!]) {\n await checkFeatures(token, ['customers.people.view'])\n await checkPeople(token, personIds[1])\n }\n }\n await setUserAclVisibility(request, adminToken, { userId, features: ['ai_assistant.view'], organizations: [] })\n const persistedAcl = await apiRequest(request, 'GET', `/api/auth/users/acl?userId=${userId}`, { token: adminToken })\n expect(persistedAcl.status()).toBe(200)\n const acl = await readJsonSafe<{ hasCustomAcl: boolean; features: string[]; organizations: string[] }>(persistedAcl)\n expect(acl).toMatchObject({ hasCustomAcl: true, features: ['ai_assistant.view'], organizations: [] })\n await checkFeatures(ownerToken, [])\n await checkFeatures(secret!, [])\n } finally {\n for (const personId of personIds) await deleteEntityByBody(request, adminToken, '/api/customers/people', personId)\n if (userId) {\n await withClient((client) => client.query('delete from api_keys where session_user_id = $1 and tenant_id = $2', [userId, tenantId]))\n await deleteUserAclInDb(userId)\n }\n await deleteUserIfExists(request, adminToken, userId)\n await deleteRoleIfExists(request, adminToken, roleId)\n await deleteOrganizationInDb(organizationId)\n await deleteOrganizationInDb(foreignOrganizationId)\n }\n })\n}\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,aAAa,kBAAkB;AACpD,SAAS,YAAY;AACrB,SAAS,YAAY,oBAAoB;AACzC;AAAA,EACE;AAAA,EAAmB;AAAA,EAAmB;AAAA,EAAoB;AAAA,EAC1D;AAAA,EAAoB;AAAA,OACf;AACP;AAAA,EACE;AAAA,EAA2B;AAAA,EAAwB;AAAA,EAAwB;AAAA,EAAmB;AAAA,OACzF;AACP,SAAS,0BAA0B;AACnC,SAAS,UAAU,eAAe,oBAAoB;AAEtD,WAAW,EAAE,SAAS,SAAS,KAAK;AAAA,EAClC,EAAE,SAAS,OAAO,UAAU,MAAM;AAAA,EAClC,EAAE,SAAS,WAAW,UAAU,MAAM;AAAA,EACtC,EAAE,SAAS,OAAO,UAAU,KAAK;AACnC,GAAY;AACV,OAAK,GAAG,WAAW,iBAAiB,EAAE,GAAG,OAAO,8DAA8D,OAAO,EAAE,QAAQ,MAAM;AACnI,UAAM,aAAa,MAAM,aAAa,SAAS,YAAY;AAC3D,UAAM,EAAE,SAAS,IAAI,cAAc,UAAU;AAC7C,UAAM,QAAQ,WAAW;AACzB,UAAM,QAAQ,UAAU,KAAK;AAC7B,UAAM,WAAW,UAAU,KAAK;AAChC,UAAM,WAAW,CAAC,mBAAmB,qBAAqB,uBAAuB;AACjF,UAAM,YAAsB,CAAC;AAC7B,QAAI,SAAwB;AAC5B,QAAI,SAAwB;AAC5B,QAAI,iBAAgC;AACpC,QAAI,wBAAuC;AAC3C,QAAI;AACF,uBAAiB,MAAM,uBAAuB,EAAE,MAAM,YAAY,KAAK,IAAI,SAAS,CAAC;AACrF,8BAAwB,MAAM,uBAAuB,EAAE,MAAM,eAAe,KAAK,IAAI,SAAS,CAAC;AAC/F,iBAAW,yBAAyB,CAAC,gBAAgB,qBAAqB,GAAG;AAC3E,cAAM,SAAS,MAAM,WAAW,SAAS,QAAQ,yBAAyB;AAAA,UACxE,OAAO;AAAA,UACP,SAAS,EAAE,QAAQ,mBAAmB,qBAAqB,GAAG;AAAA,UAC9D,MAAM,EAAE,WAAW,UAAU,UAAU,OAAO,aAAa,UAAU,KAAK,IAAI,qBAAqB,GAAG;AAAA,QACxG,CAAC;AACD,eAAO,OAAO,OAAO,CAAC,EAAE,KAAK,GAAG;AAChC,cAAM,UAAU,MAAM,aAA8B,MAAM;AAC1D,kBAAU,KAAK,SAAS,SAAS,IAAI,kCAAkC,CAAC;AAAA,MAC1E;AACA,eAAS,MAAM,kBAAkB,SAAS,YAAY,EAAE,MAAM,YAAY,KAAK,IAAI,SAAS,CAAC;AAC7F,YAAM,mBAAmB,SAAS,YAAY;AAAA,QAC5C;AAAA,QAAQ,UAAU,CAAC,GAAG,UAAU,yBAAyB;AAAA,QAAG,eAAe;AAAA,MAC7E,CAAC;AACD,eAAS,MAAM,kBAAkB,SAAS,YAAY,EAAE,OAAO,UAAU,gBAAgB,OAAO,CAAC,MAAM,EAAE,CAAC;AAC1G,YAAM,qBAAqB,SAAS,YAAY,EAAE,QAAQ,UAAU,eAAe,CAAC,cAAc,EAAE,CAAC;AACrG,UAAI,SAAU,OAAM,0BAA0B,MAAM;AACpD,YAAM,aAAa,MAAM,aAAa,SAAS,OAAO,QAAQ;AAC9D,YAAM,SAAS,MAAM,WAAW,SAAS,QAAQ,qBAAqB,OAAO,QAAQ;AAAA,QACnF,OAAO;AAAA,QAAY,MAAM,CAAC;AAAA,MAC5B,CAAC;AACD,aAAO,OAAO,OAAO,CAAC,EAAE,KAAK,GAAG;AAChC,YAAM,OAAO,MAAM,aAAyD,MAAM;AAClF,UAAI,SAAS,MAAM;AACnB,UAAI,YAAY,WAAW;AACzB,eAAO,MAAM,YAAY,EAAE,QAAQ,qBAAqB;AACxD,iBAAS,OAAO,YAAY,CAAC,EAAE,SAAS,KAAK,CAAC,IAAI,YAAY,EAAE,EAAE,SAAS,KAAK,CAAC;AACjF,cAAM,UAAU,MAAM,KAAK,QAAQ,EAAE;AACrC,cAAM,YAAY,MAAM,WAAW,CAAC,WAAW,OAAO;AAAA,UACpD;AAAA,UACA,CAAC,SAAS,OAAQ,MAAM,GAAG,EAAE,GAAG,WAAW,QAAQ,EAAE,OAAO,KAAM,YAAa,EAAE,OAAO,KAAK,GAAG,QAAQ,QAAQ;AAAA,QAClH,CAAC;AACD,eAAO,UAAU,IAAI,EAAE,aAAa,CAAC;AAAA,MACvC;AACA,aAAO,MAAM,EAAE,QAAQ,OAAO;AAC9B,YAAM,gBAAgB,OAAO,OAAe,aAAuB;AACjE,cAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,2BAA2B;AAAA,UAC5E;AAAA,UAAO,MAAM,EAAE,UAAU,CAAC,yBAAyB,yBAAyB,EAAE;AAAA,UAC9E,SAAS,MAAM,WAAW,MAAM,IAAI,EAAE,aAAa,MAAM,IAAI,CAAC;AAAA,QAChE,CAAC;AACD,eAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAClC,cAAM,SAAS,MAAM,aAAoC,QAAQ;AACjE,eAAO,QAAQ,OAAO,EAAE,QAAQ,QAAQ;AAAA,MAC1C;AACA,YAAM,cAAc,OAAO,OAAe,eAAuB;AAC/D,cAAM,SAAS,MAAM,WAAW,SAAS,OAAO,6BAA6B,UAAU,KAAK,GAAG,CAAC,iBAAiB;AAAA,UAC/G;AAAA,UACA,SAAS,EAAE,QAAQ,2BAA2B,GAAI,MAAM,WAAW,MAAM,IAAI,EAAE,aAAa,MAAM,IAAI,CAAC,EAAG;AAAA,QAC5G,CAAC;AACD,eAAO,OAAO,OAAO,CAAC,EAAE,KAAK,GAAG;AAChC,cAAM,SAAS,MAAM,aAA+C,MAAM;AAC1E,eAAO,QAAQ,MAAM,IAAI,CAAC,SAAS,KAAK,EAAE,CAAC,EAAE,QAAQ,CAAC,UAAU,CAAC;AAAA,MACnE;AACA,eAAS,UAAU,GAAG,UAAU,GAAG,WAAW,GAAG;AAC/C,cAAM,cAAc,YAAY,CAAC,uBAAuB,CAAC;AACzD,cAAM,cAAc,QAAS,CAAC,uBAAuB,CAAC;AACtD,mBAAW,SAAS,CAAC,YAAY,MAAO,GAAG;AACzC,gBAAM,YAAY,OAAO,UAAU,CAAC,CAAC;AACrC,gBAAM,SAAS,MAAM,WAAW,SAAS,OAAO,yBAAyB;AAAA,YACvE;AAAA,YACA,SAAS,MAAM,WAAW,MAAM,IAAI,EAAE,aAAa,MAAM,IAAI,CAAC;AAAA,YAC9D,MAAM,EAAE,IAAI,UAAU,CAAC,GAAG,aAAa,UAAU,KAAK,GAAG;AAAA,UAC3D,CAAC;AACD,iBAAO,OAAO,OAAO,CAAC,EAAE,KAAK,GAAG;AAAA,QAClC;AAAA,MACF;AACA,UAAI,UAAU;AACZ,cAAM,qBAAqB,SAAS,YAAY,EAAE,QAAQ,UAAU,eAAe,CAAC,qBAAqB,EAAE,CAAC;AAC5G,mBAAW,SAAS,CAAC,YAAY,MAAO,GAAG;AACzC,gBAAM,cAAc,OAAO,CAAC,uBAAuB,CAAC;AACpD,gBAAM,YAAY,OAAO,UAAU,CAAC,CAAC;AAAA,QACvC;AAAA,MACF;AACA,YAAM,qBAAqB,SAAS,YAAY,EAAE,QAAQ,UAAU,CAAC,mBAAmB,GAAG,eAAe,CAAC,EAAE,CAAC;AAC9G,YAAM,eAAe,MAAM,WAAW,SAAS,OAAO,8BAA8B,MAAM,IAAI,EAAE,OAAO,WAAW,CAAC;AACnH,aAAO,aAAa,OAAO,CAAC,EAAE,KAAK,GAAG;AACtC,YAAM,MAAM,MAAM,aAAqF,YAAY;AACnH,aAAO,GAAG,EAAE,cAAc,EAAE,cAAc,MAAM,UAAU,CAAC,mBAAmB,GAAG,eAAe,CAAC,EAAE,CAAC;AACpG,YAAM,cAAc,YAAY,CAAC,CAAC;AAClC,YAAM,cAAc,QAAS,CAAC,CAAC;AAAA,IACjC,UAAE;AACA,iBAAW,YAAY,UAAW,OAAM,mBAAmB,SAAS,YAAY,yBAAyB,QAAQ;AACjH,UAAI,QAAQ;AACV,cAAM,WAAW,CAAC,WAAW,OAAO,MAAM,sEAAsE,CAAC,QAAQ,QAAQ,CAAC,CAAC;AACnI,cAAM,kBAAkB,MAAM;AAAA,MAChC;AACA,YAAM,mBAAmB,SAAS,YAAY,MAAM;AACpD,YAAM,mBAAmB,SAAS,YAAY,MAAM;AACpD,YAAM,uBAAuB,cAAc;AAC3C,YAAM,uBAAuB,qBAAqB;AAAA,IACpD;AAAA,EACF,CAAC;AACH;",
6
+ "names": []
7
+ }
@@ -30,7 +30,8 @@ async function POST(req) {
30
30
  tenantId: auth.tenantId ?? null,
31
31
  organizationId: auth.orgId ?? null,
32
32
  roles: roleIds,
33
- createdBy: auth.sub
33
+ createdBy: auth.sub,
34
+ sessionUserId: auth.sub
34
35
  });
35
36
  return NextResponse.json({
36
37
  id: record.id,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/ai_assistant/api/mcp-key/route.ts"],
4
- "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { createApiKey } from '@open-mercato/core/modules/api_keys/services/apiKeyService'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getUserRoleIds } from '../../lib/user-role-ids'\n\nconst logger = createLogger('ai_assistant')\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['api_keys.create'] },\n}\n\nconst bodySchema = z.object({\n name: z.string().min(1).max(120).optional(),\n description: z.string().max(1000).optional().nullable(),\n})\n\n/**\n * POST /api/ai_assistant/mcp-key\n *\n * Creates a persistent MCP API key (`omk_...`) for use in `.mcp.json`. Unlike a\n * raw `POST /api/api_keys/keys` call \u2014 which assigns no roles unless the client\n * passes them \u2014 this endpoint resolves the **caller's own roles** server-side\n * and attaches them to the key, so the key inherits the current user's ACL.\n * The user cannot escalate: they only ever grant their own roles to their own\n * key.\n */\nexport async function POST(req: NextRequest) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n try {\n const raw = await req.json().catch(() => ({}))\n const body = bodySchema.parse(raw ?? {})\n\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n\n const roleIds = await getUserRoleIds(em, auth.sub, auth.tenantId)\n\n const { record, secret } = await createApiKey(em, {\n name: body.name ?? `MCP Config - ${new Date().toISOString().slice(0, 10)}`,\n description: body.description ?? 'Generated from AI Assistant settings for MCP client',\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n roles: roleIds,\n createdBy: auth.sub,\n })\n\n return NextResponse.json({\n id: record.id,\n name: record.name,\n keyPrefix: record.keyPrefix,\n secret,\n tenantId: record.tenantId ?? null,\n organizationId: record.organizationId ?? null,\n roles: roleIds,\n })\n } catch (error) {\n logger.error('MCP Key \u2014 Error', { err: error })\n return NextResponse.json(\n { error: 'Failed to create MCP API key' },\n { status: 500 },\n )\n }\n}\n\nconst responseSchema = z.object({\n id: z.string(),\n name: z.string(),\n keyPrefix: z.string(),\n secret: z.string().describe('Full API key value (omk_...). Shown once.'),\n tenantId: z.string().uuid().nullable(),\n organizationId: z.string().uuid().nullable(),\n roles: z.array(z.string()).describe('Role ids inherited from the calling user.'),\n})\n\nconst errorSchema = z.object({ error: z.string() })\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Create MCP API key',\n description:\n 'Creates a persistent MCP API key that inherits the calling user\\'s roles, for use in an MCP client `.mcp.json`.',\n methods: {\n POST: {\n summary: 'Generate MCP API key',\n description:\n 'Generates a persistent `omk_` API key scoped to the calling user\\'s tenant/organization and carrying the caller\\'s own roles, so the key has the same ACL as the user.',\n responses: [\n { status: 200, description: 'API key created', schema: responseSchema },\n ],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 500, description: 'Failed to create MCP API key', schema: errorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,oBAAsC;AAC/C,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,oBAAoB;AAE7B,SAAS,sBAAsB;AAE/B,MAAM,SAAS,aAAa,cAAc;AAEnC,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,iBAAiB,EAAE;AAClE;AAEA,MAAM,aAAa,EAAE,OAAO;AAAA,EAC1B,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,SAAS;AAAA,EAC1C,aAAa,EAAE,OAAO,EAAE,IAAI,GAAI,EAAE,SAAS,EAAE,SAAS;AACxD,CAAC;AAYD,eAAsB,KAAK,KAAkB;AAC3C,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,KAAK;AACd,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,MAAI;AACF,UAAM,MAAM,MAAM,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC,EAAE;AAC7C,UAAM,OAAO,WAAW,MAAM,OAAO,CAAC,CAAC;AAEvC,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,KAAK,UAAU,QAAuB,IAAI;AAEhD,UAAM,UAAU,MAAM,eAAe,IAAI,KAAK,KAAK,KAAK,QAAQ;AAEhE,UAAM,EAAE,QAAQ,OAAO,IAAI,MAAM,aAAa,IAAI;AAAA,MAChD,MAAM,KAAK,QAAQ,iBAAgB,oBAAI,KAAK,GAAE,YAAY,EAAE,MAAM,GAAG,EAAE,CAAC;AAAA,MACxE,aAAa,KAAK,eAAe;AAAA,MACjC,UAAU,KAAK,YAAY;AAAA,MAC3B,gBAAgB,KAAK,SAAS;AAAA,MAC9B,OAAO;AAAA,MACP,WAAW,KAAK;AAAA,IAClB,CAAC;AAED,WAAO,aAAa,KAAK;AAAA,MACvB,IAAI,OAAO;AAAA,MACX,MAAM,OAAO;AAAA,MACb,WAAW,OAAO;AAAA,MAClB;AAAA,MACA,UAAU,OAAO,YAAY;AAAA,MAC7B,gBAAgB,OAAO,kBAAkB;AAAA,MACzC,OAAO;AAAA,IACT,CAAC;AAAA,EACH,SAAS,OAAO;AACd,WAAO,MAAM,wBAAmB,EAAE,KAAK,MAAM,CAAC;AAC9C,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,+BAA+B;AAAA,MACxC,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AACF;AAEA,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,IAAI,EAAE,OAAO;AAAA,EACb,MAAM,EAAE,OAAO;AAAA,EACf,WAAW,EAAE,OAAO;AAAA,EACpB,QAAQ,EAAE,OAAO,EAAE,SAAS,2CAA2C;AAAA,EACvE,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EACrC,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EAC3C,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS,2CAA2C;AACjF,CAAC;AAED,MAAM,cAAc,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;AAE3C,MAAM,UAA2B;AAAA,EACtC,SAAS;AAAA,EACT,aACE;AAAA,EACF,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aACE;AAAA,MACF,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,mBAAmB,QAAQ,eAAe;AAAA,MACxE;AAAA,MACA,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,YAAY;AAAA,QAChE,EAAE,QAAQ,KAAK,aAAa,gCAAgC,QAAQ,YAAY;AAAA,MAClF;AAAA,IACF;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { createApiKey } from '@open-mercato/core/modules/api_keys/services/apiKeyService'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getUserRoleIds } from '../../lib/user-role-ids'\n\nconst logger = createLogger('ai_assistant')\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['api_keys.create'] },\n}\n\nconst bodySchema = z.object({\n name: z.string().min(1).max(120).optional(),\n description: z.string().max(1000).optional().nullable(),\n})\n\n/**\n * POST /api/ai_assistant/mcp-key\n *\n * Creates a persistent MCP API key (`omk_...`) for use in `.mcp.json`. Unlike a\n * raw `POST /api/api_keys/keys` call \u2014 which assigns no roles unless the client\n * passes them \u2014 this endpoint resolves the **caller's own roles** server-side\n * and binds the key to the caller, so its role grants remain bounded by the\n * user's effective ACL.\n */\nexport async function POST(req: NextRequest) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n try {\n const raw = await req.json().catch(() => ({}))\n const body = bodySchema.parse(raw ?? {})\n\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n\n const roleIds = await getUserRoleIds(em, auth.sub, auth.tenantId)\n\n const { record, secret } = await createApiKey(em, {\n name: body.name ?? `MCP Config - ${new Date().toISOString().slice(0, 10)}`,\n description: body.description ?? 'Generated from AI Assistant settings for MCP client',\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n roles: roleIds,\n createdBy: auth.sub,\n sessionUserId: auth.sub,\n })\n\n return NextResponse.json({\n id: record.id,\n name: record.name,\n keyPrefix: record.keyPrefix,\n secret,\n tenantId: record.tenantId ?? null,\n organizationId: record.organizationId ?? null,\n roles: roleIds,\n })\n } catch (error) {\n logger.error('MCP Key \u2014 Error', { err: error })\n return NextResponse.json(\n { error: 'Failed to create MCP API key' },\n { status: 500 },\n )\n }\n}\n\nconst responseSchema = z.object({\n id: z.string(),\n name: z.string(),\n keyPrefix: z.string(),\n secret: z.string().describe('Full API key value (omk_...). Shown once.'),\n tenantId: z.string().uuid().nullable(),\n organizationId: z.string().uuid().nullable(),\n roles: z.array(z.string()).describe('Role ids inherited from the calling user.'),\n})\n\nconst errorSchema = z.object({ error: z.string() })\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Create MCP API key',\n description:\n 'Creates a persistent MCP API key that inherits the calling user\\'s roles, for use in an MCP client `.mcp.json`.',\n methods: {\n POST: {\n summary: 'Generate MCP API key',\n description:\n 'Generates a persistent `omk_` API key scoped to the calling user\\'s tenant/organization and carrying the caller\\'s own roles, so the key has the same ACL as the user.',\n responses: [\n { status: 200, description: 'API key created', schema: responseSchema },\n ],\n errors: [\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 500, description: 'Failed to create MCP API key', schema: errorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ai-assistant",
3
- "version": "0.10.1-develop.7767.1.440f7c2b4c",
3
+ "version": "0.10.1-develop.7768.1.90fa052c11",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -100,16 +100,16 @@
100
100
  "zod-to-json-schema": "^3.25.2"
101
101
  },
102
102
  "peerDependencies": {
103
- "@open-mercato/shared": "0.10.1-develop.7767.1.440f7c2b4c",
104
- "@open-mercato/ui": "0.10.1-develop.7767.1.440f7c2b4c",
103
+ "@open-mercato/shared": "0.10.1-develop.7768.1.90fa052c11",
104
+ "@open-mercato/ui": "0.10.1-develop.7768.1.90fa052c11",
105
105
  "react": "^19.0.0",
106
106
  "react-dom": "^19.0.0",
107
107
  "zod": ">=3.23.0"
108
108
  },
109
109
  "devDependencies": {
110
- "@open-mercato/cli": "0.10.1-develop.7767.1.440f7c2b4c",
111
- "@open-mercato/shared": "0.10.1-develop.7767.1.440f7c2b4c",
112
- "@open-mercato/ui": "0.10.1-develop.7767.1.440f7c2b4c",
110
+ "@open-mercato/cli": "0.10.1-develop.7768.1.90fa052c11",
111
+ "@open-mercato/shared": "0.10.1-develop.7768.1.90fa052c11",
112
+ "@open-mercato/ui": "0.10.1-develop.7768.1.90fa052c11",
113
113
  "@types/react": "^19.2.18",
114
114
  "@types/react-dom": "^19.2.5",
115
115
  "react": "19.2.8",
@@ -0,0 +1,127 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { createHash, randomBytes, randomUUID } from 'node:crypto'
3
+ import { hash } from 'bcryptjs'
4
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
5
+ import {
6
+ createRoleFixture, createUserFixture, deleteRoleIfExists, deleteUserIfExists,
7
+ setRoleAclFeatures, setUserAclVisibility,
8
+ } from '@open-mercato/core/helpers/integration/authFixtures'
9
+ import {
10
+ clearUserHomeOrganization, createOrganizationInDb, deleteOrganizationInDb, deleteUserAclInDb, withClient,
11
+ } from '@open-mercato/core/helpers/integration/dbFixtures'
12
+ import { deleteEntityByBody } from '@open-mercato/core/helpers/integration/crmFixtures'
13
+ import { expectId, getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
14
+
15
+ for (const { keyKind, floating } of [
16
+ { keyKind: 'mcp', floating: false },
17
+ { keyKind: 'session', floating: false },
18
+ { keyKind: 'mcp', floating: true },
19
+ ] as const) {
20
+ test(`${floating ? 'tenant-wide ' : ''}${keyKind} key preserves the owner UserAcl on cold and warm requests`, async ({ request }) => {
21
+ const adminToken = await getAuthToken(request, 'superadmin')
22
+ const { tenantId } = getTokenScope(adminToken)
23
+ const stamp = randomUUID()
24
+ const email = `ai-key-${stamp}@example.com`
25
+ const password = `Secret-${stamp}!`
26
+ const features = ['api_keys.create', 'ai_assistant.view', 'customers.people.view']
27
+ const personIds: string[] = []
28
+ let roleId: string | null = null
29
+ let userId: string | null = null
30
+ let organizationId: string | null = null
31
+ let foreignOrganizationId: string | null = null
32
+ try {
33
+ organizationId = await createOrganizationInDb({ name: `AI owner ${stamp}`, tenantId })
34
+ foreignOrganizationId = await createOrganizationInDb({ name: `AI excluded ${stamp}`, tenantId })
35
+ for (const fixtureOrganizationId of [organizationId, foreignOrganizationId]) {
36
+ const person = await apiRequest(request, 'POST', '/api/customers/people', {
37
+ token: adminToken,
38
+ headers: { Cookie: `om_selected_org=${fixtureOrganizationId}` },
39
+ data: { firstName: 'AI ACL', lastName: stamp, displayName: `AI ACL ${stamp} ${fixtureOrganizationId}` },
40
+ })
41
+ expect(person.status()).toBe(201)
42
+ const created = await readJsonSafe<{ id?: string }>(person)
43
+ personIds.push(expectId(created?.id, 'Person fixture must return an id'))
44
+ }
45
+ roleId = await createRoleFixture(request, adminToken, { name: `AI broad ${stamp}`, tenantId })
46
+ await setRoleAclFeatures(request, adminToken, {
47
+ roleId, features: [...features, 'customers.people.manage'], organizations: null,
48
+ })
49
+ userId = await createUserFixture(request, adminToken, { email, password, organizationId, roles: [roleId] })
50
+ await setUserAclVisibility(request, adminToken, { userId, features, organizations: [organizationId] })
51
+ if (floating) await clearUserHomeOrganization(userId)
52
+ const ownerToken = await getAuthToken(request, email, password)
53
+ const issued = await apiRequest(request, 'POST', `/api/ai_assistant/${keyKind}-key`, {
54
+ token: ownerToken, data: {},
55
+ })
56
+ expect(issued.status()).toBe(200)
57
+ const body = await readJsonSafe<{ secret?: string; sessionToken?: string }>(issued)
58
+ let secret = body?.secret
59
+ if (keyKind === 'session') {
60
+ expect(body?.sessionToken).toMatch(/^sess_[a-f0-9]{32}$/)
61
+ secret = `omk_${randomBytes(4).toString('hex')}.${randomBytes(24).toString('hex')}`
62
+ const keyHash = await hash(secret, 10)
63
+ const boundRows = await withClient((client) => client.query<{ id: string }>(
64
+ 'update api_keys set key_hash = $1, key_prefix = $2 where session_token_hash = $3 and session_user_id = $4 and tenant_id = $5 returning id',
65
+ [keyHash, secret!.slice(0, 12), createHash('sha256').update(body!.sessionToken!).digest('hex'), userId, tenantId],
66
+ ))
67
+ expect(boundRows.rows).toHaveLength(1)
68
+ }
69
+ expect(secret).toMatch(/^omk_/)
70
+ const checkFeatures = async (token: string, expected: string[]) => {
71
+ const response = await apiRequest(request, 'POST', '/api/auth/feature-check', {
72
+ token, data: { features: ['customers.people.view', 'customers.people.manage'] },
73
+ headers: token.startsWith('omk_') ? { 'x-api-key': token } : {},
74
+ })
75
+ expect(response.status()).toBe(200)
76
+ const result = await readJsonSafe<{ granted: string[] }>(response)
77
+ expect(result?.granted).toEqual(expected)
78
+ }
79
+ const checkPeople = async (token: string, expectedId: string) => {
80
+ const people = await apiRequest(request, 'GET', `/api/customers/people?ids=${personIds.join(',')}&pageSize=100`, {
81
+ token,
82
+ headers: { Cookie: 'om_selected_org=__all__', ...(token.startsWith('omk_') ? { 'x-api-key': token } : {}) },
83
+ })
84
+ expect(people.status()).toBe(200)
85
+ const result = await readJsonSafe<{ items: Array<{ id: string }> }>(people)
86
+ expect(result?.items.map((item) => item.id)).toEqual([expectedId])
87
+ }
88
+ for (let attempt = 0; attempt < 2; attempt += 1) {
89
+ await checkFeatures(ownerToken, ['customers.people.view'])
90
+ await checkFeatures(secret!, ['customers.people.view'])
91
+ for (const token of [ownerToken, secret!]) {
92
+ await checkPeople(token, personIds[0])
93
+ const denied = await apiRequest(request, 'PUT', '/api/customers/people', {
94
+ token,
95
+ headers: token.startsWith('omk_') ? { 'x-api-key': token } : {},
96
+ data: { id: personIds[0], displayName: `Denied ${stamp}` },
97
+ })
98
+ expect(denied.status()).toBe(403)
99
+ }
100
+ }
101
+ if (floating) {
102
+ await setUserAclVisibility(request, adminToken, { userId, features, organizations: [foreignOrganizationId] })
103
+ for (const token of [ownerToken, secret!]) {
104
+ await checkFeatures(token, ['customers.people.view'])
105
+ await checkPeople(token, personIds[1])
106
+ }
107
+ }
108
+ await setUserAclVisibility(request, adminToken, { userId, features: ['ai_assistant.view'], organizations: [] })
109
+ const persistedAcl = await apiRequest(request, 'GET', `/api/auth/users/acl?userId=${userId}`, { token: adminToken })
110
+ expect(persistedAcl.status()).toBe(200)
111
+ const acl = await readJsonSafe<{ hasCustomAcl: boolean; features: string[]; organizations: string[] }>(persistedAcl)
112
+ expect(acl).toMatchObject({ hasCustomAcl: true, features: ['ai_assistant.view'], organizations: [] })
113
+ await checkFeatures(ownerToken, [])
114
+ await checkFeatures(secret!, [])
115
+ } finally {
116
+ for (const personId of personIds) await deleteEntityByBody(request, adminToken, '/api/customers/people', personId)
117
+ if (userId) {
118
+ await withClient((client) => client.query('delete from api_keys where session_user_id = $1 and tenant_id = $2', [userId, tenantId]))
119
+ await deleteUserAclInDb(userId)
120
+ }
121
+ await deleteUserIfExists(request, adminToken, userId)
122
+ await deleteRoleIfExists(request, adminToken, roleId)
123
+ await deleteOrganizationInDb(organizationId)
124
+ await deleteOrganizationInDb(foreignOrganizationId)
125
+ }
126
+ })
127
+ }
@@ -95,6 +95,7 @@ describe('POST /api/ai_assistant/mcp-key', () => {
95
95
  expect(input.tenantId).toBe('tenant-1')
96
96
  expect(input.organizationId).toBe('org-1')
97
97
  expect(input.createdBy).toBe('user-1')
98
+ expect(input.sessionUserId).toBe('user-1')
98
99
  })
99
100
 
100
101
  it('scopes the role lookup to the caller\'s tenant', async () => {
@@ -25,9 +25,8 @@ const bodySchema = z.object({
25
25
  * Creates a persistent MCP API key (`omk_...`) for use in `.mcp.json`. Unlike a
26
26
  * raw `POST /api/api_keys/keys` call — which assigns no roles unless the client
27
27
  * passes them — this endpoint resolves the **caller's own roles** server-side
28
- * and attaches them to the key, so the key inherits the current user's ACL.
29
- * The user cannot escalate: they only ever grant their own roles to their own
30
- * key.
28
+ * and binds the key to the caller, so its role grants remain bounded by the
29
+ * user's effective ACL.
31
30
  */
32
31
  export async function POST(req: NextRequest) {
33
32
  const auth = await getAuthFromRequest(req)
@@ -51,6 +50,7 @@ export async function POST(req: NextRequest) {
51
50
  organizationId: auth.orgId ?? null,
52
51
  roles: roleIds,
53
52
  createdBy: auth.sub,
53
+ sessionUserId: auth.sub,
54
54
  })
55
55
 
56
56
  return NextResponse.json({