@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +4 -15
- package/dist/modules/ai_assistant/__integration__/TC-AI-KEY-ACL-7825.spec.js +139 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-KEY-ACL-7825.spec.js.map +7 -0
- package/dist/modules/ai_assistant/api/mcp-key/route.js +2 -1
- package/dist/modules/ai_assistant/api/mcp-key/route.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/ai_assistant/__integration__/TC-AI-KEY-ACL-7825.spec.ts +127 -0
- package/src/modules/ai_assistant/api/mcp-key/__tests__/route.test.ts +1 -0
- package/src/modules/ai_assistant/api/mcp-key/route.ts +3 -3
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:ai-assistant] found
|
|
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
|
-
|
|
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
|
-
|
|
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": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -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
|
|
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;
|
|
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.
|
|
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.
|
|
104
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
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.
|
|
111
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
112
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
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
|
|
29
|
-
*
|
|
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({
|