@open-mercato/ai-assistant 0.6.5-develop.5033.1.c970204a3f → 0.6.5-develop.5099.1.85c0aff78c
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,3 +1,4 @@
|
|
|
1
|
+
import { hasAllFeatures } from "@open-mercato/shared/lib/auth/featureMatch";
|
|
1
2
|
async function authenticateMcpRequest(apiKeySecret, container) {
|
|
2
3
|
if (!apiKeySecret || typeof apiKeySecret !== "string") {
|
|
3
4
|
return { success: false, error: "API key is required" };
|
|
@@ -49,17 +50,7 @@ function hasRequiredFeatures(requiredFeatures, userFeatures, isSuperAdmin, rbacS
|
|
|
49
50
|
if (rbacService) {
|
|
50
51
|
return rbacService.hasAllFeatures(requiredFeatures, userFeatures);
|
|
51
52
|
}
|
|
52
|
-
return requiredFeatures
|
|
53
|
-
if (userFeatures.includes(required)) return true;
|
|
54
|
-
if (userFeatures.includes("*")) return true;
|
|
55
|
-
return userFeatures.some((feature) => {
|
|
56
|
-
if (feature.endsWith(".*")) {
|
|
57
|
-
const prefix = feature.slice(0, -2);
|
|
58
|
-
return required.startsWith(prefix + ".");
|
|
59
|
-
}
|
|
60
|
-
return false;
|
|
61
|
-
});
|
|
62
|
-
});
|
|
53
|
+
return hasAllFeatures(requiredFeatures, userFeatures);
|
|
63
54
|
}
|
|
64
55
|
function extractApiKeyFromHeaders(headers) {
|
|
65
56
|
const getHeader = (name) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/ai_assistant/lib/auth.ts"],
|
|
4
|
-
"sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\n\n/**\n * Successful authentication result.\n */\nexport type McpAuthSuccess = {\n success: true\n keyId: string\n keyName: string\n tenantId: string | null\n organizationId: string | null\n userId: string\n features: string[]\n isSuperAdmin: boolean\n}\n\n/**\n * Failed authentication result.\n */\nexport type McpAuthFailure = {\n success: false\n error: string\n}\n\n/**\n * Result from MCP authentication.\n */\nexport type McpAuthResult = McpAuthSuccess | McpAuthFailure\n\n/**\n * Authenticate an MCP request using an API key.\n *\n * This function validates the API key secret and loads the associated\n * ACL (features, organizations, super admin status) from the key's roles.\n *\n * @param apiKeySecret - The full API key secret (e.g., 'omk_xxxx.yyyy...')\n * @param container - Awilix DI container with 'em' and 'rbacService'\n * @returns Authentication result with user context or error\n */\nexport async function authenticateMcpRequest(\n apiKeySecret: string,\n container: AwilixContainer\n): Promise<McpAuthResult> {\n if (!apiKeySecret || typeof apiKeySecret !== 'string') {\n return { success: false, error: 'API key is required' }\n }\n\n const trimmedSecret = apiKeySecret.trim()\n if (!trimmedSecret) {\n return { success: false, error: 'API key is required' }\n }\n\n if (!trimmedSecret.startsWith('omk_')) {\n return { success: false, error: 'Invalid API key format' }\n }\n\n try {\n const em = container.resolve('em') as EntityManager\n\n const { findApiKeyBySecret } = await import(\n '@open-mercato/core/modules/api_keys/services/apiKeyService'\n )\n\n const apiKey = await findApiKeyBySecret(em, trimmedSecret)\n\n if (!apiKey) {\n return { success: false, error: 'Invalid or expired API key' }\n }\n\n const userId = `api_key:${apiKey.id}`\n\n const rbacService = container.resolve('rbacService') as {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null }\n ) => Promise<{\n isSuperAdmin: boolean\n features: string[]\n organizations: string[] | null\n }>\n }\n\n const acl = await rbacService.loadAcl(userId, {\n tenantId: apiKey.tenantId ?? null,\n organizationId: apiKey.organizationId ?? null,\n })\n\n try {\n apiKey.lastUsedAt = new Date()\n await em.persist(apiKey).flush()\n } catch {\n // Best-effort update; ignore write failures\n }\n\n return {\n success: true,\n keyId: apiKey.id,\n keyName: apiKey.name,\n tenantId: apiKey.tenantId ?? null,\n organizationId: apiKey.organizationId ?? null,\n userId,\n features: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n console.error('[MCP Auth] Authentication failed:', message)\n return { success: false, error: 'Authentication failed' }\n }\n}\n\n/**\n * Check if user has the required features for a resource.\n *\n * Supports:\n * - Super admin bypass (always returns true)\n * - Direct feature match (e.g., 'customers.view')\n * - Global wildcard ('*' grants all features)\n * - Prefix wildcard (e.g., 'customers.*' grants 'customers.people.view')\n *\n * @param requiredFeatures - List of features required for access\n * @param userFeatures - List of features the user has\n * @param isSuperAdmin - Whether the user is a super admin\n * @param rbacService - Optional RbacService to delegate feature matching\n * @returns True if user has access\n */\nexport function hasRequiredFeatures(\n requiredFeatures: string[] | undefined,\n userFeatures: string[],\n isSuperAdmin: boolean,\n rbacService?: RbacService\n): boolean {\n if (isSuperAdmin) return true\n if (!requiredFeatures?.length) return true\n\n // Delegate to RbacService if provided\n if (rbacService) {\n return rbacService.hasAllFeatures(requiredFeatures, userFeatures)\n }\n\n // Fallback for cases without rbacService
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { hasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'\n\n/**\n * Successful authentication result.\n */\nexport type McpAuthSuccess = {\n success: true\n keyId: string\n keyName: string\n tenantId: string | null\n organizationId: string | null\n userId: string\n features: string[]\n isSuperAdmin: boolean\n}\n\n/**\n * Failed authentication result.\n */\nexport type McpAuthFailure = {\n success: false\n error: string\n}\n\n/**\n * Result from MCP authentication.\n */\nexport type McpAuthResult = McpAuthSuccess | McpAuthFailure\n\n/**\n * Authenticate an MCP request using an API key.\n *\n * This function validates the API key secret and loads the associated\n * ACL (features, organizations, super admin status) from the key's roles.\n *\n * @param apiKeySecret - The full API key secret (e.g., 'omk_xxxx.yyyy...')\n * @param container - Awilix DI container with 'em' and 'rbacService'\n * @returns Authentication result with user context or error\n */\nexport async function authenticateMcpRequest(\n apiKeySecret: string,\n container: AwilixContainer\n): Promise<McpAuthResult> {\n if (!apiKeySecret || typeof apiKeySecret !== 'string') {\n return { success: false, error: 'API key is required' }\n }\n\n const trimmedSecret = apiKeySecret.trim()\n if (!trimmedSecret) {\n return { success: false, error: 'API key is required' }\n }\n\n if (!trimmedSecret.startsWith('omk_')) {\n return { success: false, error: 'Invalid API key format' }\n }\n\n try {\n const em = container.resolve('em') as EntityManager\n\n const { findApiKeyBySecret } = await import(\n '@open-mercato/core/modules/api_keys/services/apiKeyService'\n )\n\n const apiKey = await findApiKeyBySecret(em, trimmedSecret)\n\n if (!apiKey) {\n return { success: false, error: 'Invalid or expired API key' }\n }\n\n const userId = `api_key:${apiKey.id}`\n\n const rbacService = container.resolve('rbacService') as {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null }\n ) => Promise<{\n isSuperAdmin: boolean\n features: string[]\n organizations: string[] | null\n }>\n }\n\n const acl = await rbacService.loadAcl(userId, {\n tenantId: apiKey.tenantId ?? null,\n organizationId: apiKey.organizationId ?? null,\n })\n\n try {\n apiKey.lastUsedAt = new Date()\n await em.persist(apiKey).flush()\n } catch {\n // Best-effort update; ignore write failures\n }\n\n return {\n success: true,\n keyId: apiKey.id,\n keyName: apiKey.name,\n tenantId: apiKey.tenantId ?? null,\n organizationId: apiKey.organizationId ?? null,\n userId,\n features: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n console.error('[MCP Auth] Authentication failed:', message)\n return { success: false, error: 'Authentication failed' }\n }\n}\n\n/**\n * Check if user has the required features for a resource.\n *\n * Supports:\n * - Super admin bypass (always returns true)\n * - Direct feature match (e.g., 'customers.view')\n * - Global wildcard ('*' grants all features)\n * - Prefix wildcard (e.g., 'customers.*' grants 'customers.people.view' and the\n * bare 'customers' segment itself)\n *\n * @param requiredFeatures - List of features required for access\n * @param userFeatures - List of features the user has\n * @param isSuperAdmin - Whether the user is a super admin\n * @param rbacService - Optional RbacService to delegate feature matching\n * @returns True if user has access\n */\nexport function hasRequiredFeatures(\n requiredFeatures: string[] | undefined,\n userFeatures: string[],\n isSuperAdmin: boolean,\n rbacService?: RbacService\n): boolean {\n if (isSuperAdmin) return true\n if (!requiredFeatures?.length) return true\n\n // Delegate to RbacService if provided\n if (rbacService) {\n return rbacService.hasAllFeatures(requiredFeatures, userFeatures)\n }\n\n // Fallback for cases without rbacService: delegate to the canonical\n // wildcard-aware matcher so this path stays consistent with\n // RbacService.hasAllFeatures (which uses the same helper). The previous\n // bespoke loop rejected a bare-segment requirement (e.g. 'entities')\n // against an 'entities.*' grant, diverging from the canonical matcher.\n return hasAllFeatures(requiredFeatures, userFeatures)\n}\n\n/**\n * Extract API key from HTTP request headers.\n *\n * Supports two header formats:\n * - x-api-key: <secret>\n * - Authorization: ApiKey <secret>\n *\n * @param headers - Request headers (Map, Headers, or plain object)\n * @returns The API key secret or null if not found\n */\nexport function extractApiKeyFromHeaders(\n headers: Headers | Map<string, string> | Record<string, string | undefined>\n): string | null {\n const getHeader = (name: string): string | null => {\n if (headers instanceof Headers) {\n return headers.get(name)\n }\n if (headers instanceof Map) {\n return headers.get(name) ?? null\n }\n const value = headers[name] ?? headers[name.toLowerCase()]\n return typeof value === 'string' ? value : null\n }\n\n const xApiKey = getHeader('x-api-key')?.trim()\n if (xApiKey) {\n return xApiKey\n }\n\n const authHeader = getHeader('authorization')?.trim()\n if (authHeader && authHeader.toLowerCase().startsWith('apikey ')) {\n return authHeader.slice(7).trim()\n }\n\n return null\n}\n"],
|
|
5
|
+
"mappings": "AAGA,SAAS,sBAAsB;AAuC/B,eAAsB,uBACpB,cACA,WACwB;AACxB,MAAI,CAAC,gBAAgB,OAAO,iBAAiB,UAAU;AACrD,WAAO,EAAE,SAAS,OAAO,OAAO,sBAAsB;AAAA,EACxD;AAEA,QAAM,gBAAgB,aAAa,KAAK;AACxC,MAAI,CAAC,eAAe;AAClB,WAAO,EAAE,SAAS,OAAO,OAAO,sBAAsB;AAAA,EACxD;AAEA,MAAI,CAAC,cAAc,WAAW,MAAM,GAAG;AACrC,WAAO,EAAE,SAAS,OAAO,OAAO,yBAAyB;AAAA,EAC3D;AAEA,MAAI;AACF,UAAM,KAAK,UAAU,QAAQ,IAAI;AAEjC,UAAM,EAAE,mBAAmB,IAAI,MAAM,OACnC,4DACF;AAEA,UAAM,SAAS,MAAM,mBAAmB,IAAI,aAAa;AAEzD,QAAI,CAAC,QAAQ;AACX,aAAO,EAAE,SAAS,OAAO,OAAO,6BAA6B;AAAA,IAC/D;AAEA,UAAM,SAAS,WAAW,OAAO,EAAE;AAEnC,UAAM,cAAc,UAAU,QAAQ,aAAa;AAWnD,UAAM,MAAM,MAAM,YAAY,QAAQ,QAAQ;AAAA,MAC5C,UAAU,OAAO,YAAY;AAAA,MAC7B,gBAAgB,OAAO,kBAAkB;AAAA,IAC3C,CAAC;AAED,QAAI;AACF,aAAO,aAAa,oBAAI,KAAK;AAC7B,YAAM,GAAG,QAAQ,MAAM,EAAE,MAAM;AAAA,IACjC,QAAQ;AAAA,IAER;AAEA,WAAO;AAAA,MACL,SAAS;AAAA,MACT,OAAO,OAAO;AAAA,MACd,SAAS,OAAO;AAAA,MAChB,UAAU,OAAO,YAAY;AAAA,MAC7B,gBAAgB,OAAO,kBAAkB;AAAA,MACzC;AAAA,MACA,UAAU,IAAI;AAAA,MACd,cAAc,IAAI;AAAA,IACpB;AAAA,EACF,SAAS,OAAO;AACd,UAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AACrE,YAAQ,MAAM,qCAAqC,OAAO;AAC1D,WAAO,EAAE,SAAS,OAAO,OAAO,wBAAwB;AAAA,EAC1D;AACF;AAkBO,SAAS,oBACd,kBACA,cACA,cACA,aACS;AACT,MAAI,aAAc,QAAO;AACzB,MAAI,CAAC,kBAAkB,OAAQ,QAAO;AAGtC,MAAI,aAAa;AACf,WAAO,YAAY,eAAe,kBAAkB,YAAY;AAAA,EAClE;AAOA,SAAO,eAAe,kBAAkB,YAAY;AACtD;AAYO,SAAS,yBACd,SACe;AACf,QAAM,YAAY,CAAC,SAAgC;AACjD,QAAI,mBAAmB,SAAS;AAC9B,aAAO,QAAQ,IAAI,IAAI;AAAA,IACzB;AACA,QAAI,mBAAmB,KAAK;AAC1B,aAAO,QAAQ,IAAI,IAAI,KAAK;AAAA,IAC9B;AACA,UAAM,QAAQ,QAAQ,IAAI,KAAK,QAAQ,KAAK,YAAY,CAAC;AACzD,WAAO,OAAO,UAAU,WAAW,QAAQ;AAAA,EAC7C;AAEA,QAAM,UAAU,UAAU,WAAW,GAAG,KAAK;AAC7C,MAAI,SAAS;AACX,WAAO;AAAA,EACT;AAEA,QAAM,aAAa,UAAU,eAAe,GAAG,KAAK;AACpD,MAAI,cAAc,WAAW,YAAY,EAAE,WAAW,SAAS,GAAG;AAChE,WAAO,WAAW,MAAM,CAAC,EAAE,KAAK;AAAA,EAClC;AAEA,SAAO;AACT;",
|
|
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.6.5-develop.
|
|
3
|
+
"version": "0.6.5-develop.5099.1.85c0aff78c",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0"
|
|
@@ -98,16 +98,16 @@
|
|
|
98
98
|
"zod-to-json-schema": "^3.25.2"
|
|
99
99
|
},
|
|
100
100
|
"peerDependencies": {
|
|
101
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
102
|
-
"@open-mercato/ui": "0.6.5-develop.
|
|
101
|
+
"@open-mercato/shared": "0.6.5-develop.5099.1.85c0aff78c",
|
|
102
|
+
"@open-mercato/ui": "0.6.5-develop.5099.1.85c0aff78c",
|
|
103
103
|
"react": "^19.0.0",
|
|
104
104
|
"react-dom": "^19.0.0",
|
|
105
105
|
"zod": ">=3.23.0"
|
|
106
106
|
},
|
|
107
107
|
"devDependencies": {
|
|
108
|
-
"@open-mercato/cli": "0.6.5-develop.
|
|
109
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
110
|
-
"@open-mercato/ui": "0.6.5-develop.
|
|
108
|
+
"@open-mercato/cli": "0.6.5-develop.5099.1.85c0aff78c",
|
|
109
|
+
"@open-mercato/shared": "0.6.5-develop.5099.1.85c0aff78c",
|
|
110
|
+
"@open-mercato/ui": "0.6.5-develop.5099.1.85c0aff78c",
|
|
111
111
|
"@types/react": "^19.2.16",
|
|
112
112
|
"@types/react-dom": "^19.2.3",
|
|
113
113
|
"react": "19.2.7",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { extractApiKeyFromHeaders, hasRequiredFeatures } from '../auth'
|
|
2
|
+
import { hasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'
|
|
2
3
|
|
|
3
4
|
describe('extractApiKeyFromHeaders', () => {
|
|
4
5
|
describe('plain object headers', () => {
|
|
@@ -92,6 +93,32 @@ describe('hasRequiredFeatures', () => {
|
|
|
92
93
|
).toBe(false)
|
|
93
94
|
})
|
|
94
95
|
|
|
96
|
+
it('grants a bare-segment requirement from a prefix wildcard (issue #2723)', () => {
|
|
97
|
+
expect(
|
|
98
|
+
hasRequiredFeatures(['entities'], ['entities.*'], false)
|
|
99
|
+
).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('matches the canonical matcher for bare-segment requirements with and without rbacService', () => {
|
|
103
|
+
const rbacService = {
|
|
104
|
+
hasAllFeatures: jest.fn((required: string[], granted: string[]) =>
|
|
105
|
+
hasAllFeatures(required, granted)
|
|
106
|
+
),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const withService = hasRequiredFeatures(
|
|
110
|
+
['entities'],
|
|
111
|
+
['entities.*'],
|
|
112
|
+
false,
|
|
113
|
+
rbacService as any
|
|
114
|
+
)
|
|
115
|
+
const withoutService = hasRequiredFeatures(['entities'], ['entities.*'], false)
|
|
116
|
+
|
|
117
|
+
expect(withService).toBe(true)
|
|
118
|
+
expect(withoutService).toBe(true)
|
|
119
|
+
expect(withService).toBe(withoutService)
|
|
120
|
+
})
|
|
121
|
+
|
|
95
122
|
it('requires all features (AND logic)', () => {
|
|
96
123
|
expect(
|
|
97
124
|
hasRequiredFeatures(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AwilixContainer } from 'awilix'
|
|
2
2
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
3
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
4
|
+
import { hasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Successful authentication result.
|
|
@@ -118,7 +119,8 @@ export async function authenticateMcpRequest(
|
|
|
118
119
|
* - Super admin bypass (always returns true)
|
|
119
120
|
* - Direct feature match (e.g., 'customers.view')
|
|
120
121
|
* - Global wildcard ('*' grants all features)
|
|
121
|
-
* - Prefix wildcard (e.g., 'customers.*' grants 'customers.people.view'
|
|
122
|
+
* - Prefix wildcard (e.g., 'customers.*' grants 'customers.people.view' and the
|
|
123
|
+
* bare 'customers' segment itself)
|
|
122
124
|
*
|
|
123
125
|
* @param requiredFeatures - List of features required for access
|
|
124
126
|
* @param userFeatures - List of features the user has
|
|
@@ -140,20 +142,12 @@ export function hasRequiredFeatures(
|
|
|
140
142
|
return rbacService.hasAllFeatures(requiredFeatures, userFeatures)
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
// Fallback for cases without rbacService
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return userFeatures.some((feature) => {
|
|
150
|
-
if (feature.endsWith('.*')) {
|
|
151
|
-
const prefix = feature.slice(0, -2)
|
|
152
|
-
return required.startsWith(prefix + '.')
|
|
153
|
-
}
|
|
154
|
-
return false
|
|
155
|
-
})
|
|
156
|
-
})
|
|
145
|
+
// Fallback for cases without rbacService: delegate to the canonical
|
|
146
|
+
// wildcard-aware matcher so this path stays consistent with
|
|
147
|
+
// RbacService.hasAllFeatures (which uses the same helper). The previous
|
|
148
|
+
// bespoke loop rejected a bare-segment requirement (e.g. 'entities')
|
|
149
|
+
// against an 'entities.*' grant, diverging from the canonical matcher.
|
|
150
|
+
return hasAllFeatures(requiredFeatures, userFeatures)
|
|
157
151
|
}
|
|
158
152
|
|
|
159
153
|
/**
|