@open-mercato/ai-assistant 0.6.7-develop.6775.1.c2313bb8a3 → 0.6.7-develop.6785.1.1dd7cfac55
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 +12 -29
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js +80 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js +124 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js +30 -0
- package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +7 -0
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/moderation-flags/route.js +121 -0
- package/dist/modules/ai_assistant/api/moderation-flags/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/settings/allowlist/route.js +10 -10
- package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/settings/route.js +34 -13
- package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +106 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js +139 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js +26 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities.js +42 -0
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +3 -1
- package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js +44 -0
- package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js.map +7 -0
- package/dist/modules/ai_assistant/di.js +6 -2
- package/dist/modules/ai_assistant/di.js.map +2 -2
- package/dist/modules/ai_assistant/events.js +6 -0
- package/dist/modules/ai_assistant/events.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +26 -0
- package/dist/modules/ai_assistant/i18n/en.json +26 -0
- package/dist/modules/ai_assistant/i18n/es.json +26 -0
- package/dist/modules/ai_assistant/i18n/pl.json +26 -0
- package/dist/modules/ai_assistant/lib/agent-runtime.js +158 -5
- package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
- package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
- package/dist/modules/ai_assistant/lib/auth.js +6 -7
- package/dist/modules/ai_assistant/lib/auth.js.map +2 -2
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +3 -0
- package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
- package/dist/modules/ai_assistant/lib/llm-adapters/openai.js +8 -1
- package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +2 -2
- package/dist/modules/ai_assistant/lib/model-factory.js +6 -1
- package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
- package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js +43 -0
- package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js.map +7 -0
- package/dist/modules/ai_assistant/lib/moderation-policy.js +24 -0
- package/dist/modules/ai_assistant/lib/moderation-policy.js.map +7 -0
- package/dist/modules/ai_assistant/lib/moderation.js +109 -0
- package/dist/modules/ai_assistant/lib/moderation.js.map +7 -0
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +2 -0
- package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
- package/dist/modules/ai_assistant/lib/tool-search.js +4 -12
- package/dist/modules/ai_assistant/lib/tool-search.js.map +2 -2
- package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js +19 -0
- package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js.map +7 -0
- package/generated/entities/ai_agent_runtime_override/index.ts +1 -0
- package/generated/entities/ai_moderation_flag/index.ts +9 -0
- package/generated/entities.ids.generated.ts +2 -1
- package/generated/entity-fields-registry.ts +13 -1
- package/package.json +6 -6
- package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.ts +126 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.ts +161 -0
- package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +48 -0
- package/src/modules/ai_assistant/api/ai/chat/route.ts +10 -0
- package/src/modules/ai_assistant/api/moderation-flags/__tests__/route.test.ts +122 -0
- package/src/modules/ai_assistant/api/moderation-flags/route.ts +137 -0
- package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +58 -8
- package/src/modules/ai_assistant/api/settings/allowlist/route.ts +10 -12
- package/src/modules/ai_assistant/api/settings/route.ts +33 -10
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +146 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.tsx +186 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.ts +24 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.tsx +12 -0
- package/src/modules/ai_assistant/data/entities.ts +56 -0
- package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +4 -0
- package/src/modules/ai_assistant/data/repositories/AiModerationFlagRepository.ts +79 -0
- package/src/modules/ai_assistant/di.ts +5 -1
- package/src/modules/ai_assistant/events.ts +21 -0
- package/src/modules/ai_assistant/i18n/de.json +26 -0
- package/src/modules/ai_assistant/i18n/en.json +26 -0
- package/src/modules/ai_assistant/i18n/es.json +26 -0
- package/src/modules/ai_assistant/i18n/pl.json +26 -0
- package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +2 -5
- package/src/modules/ai_assistant/lib/__tests__/input-moderation-gate.test.ts +121 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +13 -0
- package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +41 -0
- package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +60 -0
- package/src/modules/ai_assistant/lib/__tests__/moderation-flag-recorder.test.ts +76 -0
- package/src/modules/ai_assistant/lib/__tests__/moderation-policy.test.ts +86 -0
- package/src/modules/ai_assistant/lib/__tests__/moderation.test.ts +114 -0
- package/src/modules/ai_assistant/lib/agent-runtime.ts +259 -0
- package/src/modules/ai_assistant/lib/ai-agent-definition.ts +10 -0
- package/src/modules/ai_assistant/lib/auth.ts +6 -15
- package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +10 -0
- package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +32 -1
- package/src/modules/ai_assistant/lib/model-factory.ts +33 -0
- package/src/modules/ai_assistant/lib/moderation-flag-recorder.ts +75 -0
- package/src/modules/ai_assistant/lib/moderation-policy.ts +77 -0
- package/src/modules/ai_assistant/lib/moderation.ts +179 -0
- package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +2 -0
- package/src/modules/ai_assistant/lib/tool-search.ts +4 -17
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +388 -16
- package/src/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.ts +21 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TC-AI-MODERATION-009 — Moderation UI surfaces (Playwright).
|
|
6
|
+
* Source: spec `.ai/specs/2026-06-04-ai-input-moderation-and-safety-identifiers.md`.
|
|
7
|
+
*
|
|
8
|
+
* Covers the two new backend UI surfaces deterministically by stubbing the
|
|
9
|
+
* data routes (the page chrome + components render regardless of the live
|
|
10
|
+
* registry / DB):
|
|
11
|
+
* - `/backend/config/ai-assistant/moderation-flags` — audit DataTable.
|
|
12
|
+
* - `/backend/config/ai-assistant/agents` — per-agent moderation section.
|
|
13
|
+
*
|
|
14
|
+
* The chat enforced→rejection and chat-off→passthrough flows are intentionally
|
|
15
|
+
* NOT exercised here: the moderation gate runs SERVER-SIDE inside
|
|
16
|
+
* `runAiAgentText`, so `page.route()` cannot stub it, and a live-provider e2e
|
|
17
|
+
* would be non-deterministic (it needs a real OpenAI key + model behavior),
|
|
18
|
+
* which `.ai/qa/AGENTS.md` forbids. Those branches are covered deterministically
|
|
19
|
+
* by the gate unit tests (`input-moderation-gate.test.ts`), the moderation
|
|
20
|
+
* service tests, and the `<AiChat>` `moderation_blocked` render test.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const FLAGS_PAGE = '/backend/config/ai-assistant/moderation-flags';
|
|
24
|
+
const AGENTS_PAGE = '/backend/config/ai-assistant/agents';
|
|
25
|
+
|
|
26
|
+
test.describe('TC-AI-MODERATION-009: moderation UI surfaces', () => {
|
|
27
|
+
test('audit page renders a flagged row with its category badge', async ({ page }) => {
|
|
28
|
+
await login(page, 'superadmin');
|
|
29
|
+
|
|
30
|
+
await page.route('**/api/ai_assistant/moderation-flags**', async (route) => {
|
|
31
|
+
await route.fulfill({
|
|
32
|
+
status: 200,
|
|
33
|
+
contentType: 'application/json',
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
items: [
|
|
36
|
+
{
|
|
37
|
+
id: 'flag-it-1',
|
|
38
|
+
tenantId: 't-1',
|
|
39
|
+
organizationId: null,
|
|
40
|
+
agentId: 'support.portal_assistant',
|
|
41
|
+
userId: 'hashed-user-it',
|
|
42
|
+
providerId: 'openai',
|
|
43
|
+
modelId: 'gpt-5-mini',
|
|
44
|
+
categories: { hate: { flagged: true, score: 0.97 }, violence: { flagged: false, score: 0.01 } },
|
|
45
|
+
createdAt: '2026-06-10T12:00:00.000Z',
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
total: 1,
|
|
49
|
+
page: 1,
|
|
50
|
+
pageSize: 50,
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await page.goto(FLAGS_PAGE, { waitUntil: 'domcontentloaded' });
|
|
56
|
+
|
|
57
|
+
await expect(page.getByText('support.portal_assistant').first()).toBeVisible({ timeout: 20_000 });
|
|
58
|
+
await expect(page.getByText('hashed-user-it').first()).toBeVisible();
|
|
59
|
+
// Only the flagged category renders as a badge; the non-flagged one is omitted.
|
|
60
|
+
await expect(page.getByText('hate', { exact: true }).first()).toBeVisible();
|
|
61
|
+
await expect(page.getByText('violence', { exact: true })).toHaveCount(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('audit page renders the empty state when there are no flags', async ({ page }) => {
|
|
65
|
+
await login(page, 'superadmin');
|
|
66
|
+
|
|
67
|
+
await page.route('**/api/ai_assistant/moderation-flags**', async (route) => {
|
|
68
|
+
await route.fulfill({
|
|
69
|
+
status: 200,
|
|
70
|
+
contentType: 'application/json',
|
|
71
|
+
body: JSON.stringify({ items: [], total: 0, page: 1, pageSize: 50 }),
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await page.goto(FLAGS_PAGE, { waitUntil: 'domcontentloaded' });
|
|
76
|
+
await expect(page.getByText(/No flagged messages/i).first()).toBeVisible({ timeout: 20_000 });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('audit page redirects an unauthenticated visit to login', async ({ browser }) => {
|
|
80
|
+
const context = await browser.newContext();
|
|
81
|
+
const page = await context.newPage();
|
|
82
|
+
try {
|
|
83
|
+
await page.goto(FLAGS_PAGE, { waitUntil: 'domcontentloaded' });
|
|
84
|
+
await page.waitForURL(/\/login/, { timeout: 15_000 });
|
|
85
|
+
expect(page.url()).toMatch(/\/login/);
|
|
86
|
+
} finally {
|
|
87
|
+
await context.close();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('agent settings renders the input-moderation section (enforced badge for untrusted agents)', async ({
|
|
92
|
+
page,
|
|
93
|
+
}) => {
|
|
94
|
+
await login(page, 'superadmin');
|
|
95
|
+
|
|
96
|
+
const agent = {
|
|
97
|
+
id: 'support.portal_assistant',
|
|
98
|
+
moduleId: 'support',
|
|
99
|
+
label: 'Portal assistant',
|
|
100
|
+
description: 'Customer-facing support assistant.',
|
|
101
|
+
executionMode: 'chat',
|
|
102
|
+
readOnly: true,
|
|
103
|
+
mutationPolicy: 'read-only',
|
|
104
|
+
untrustedInput: true,
|
|
105
|
+
allowedTools: [],
|
|
106
|
+
acceptedMediaTypes: [],
|
|
107
|
+
tools: [],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
await page.route('**/api/ai_assistant/ai/agents', async (route) => {
|
|
111
|
+
await route.fulfill({
|
|
112
|
+
status: 200,
|
|
113
|
+
contentType: 'application/json',
|
|
114
|
+
body: JSON.stringify({ agents: [agent], total: 1 }),
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
await page.route('**/api/ai_assistant/settings', async (route) => {
|
|
118
|
+
await route.fulfill({
|
|
119
|
+
status: 200,
|
|
120
|
+
contentType: 'application/json',
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
availableProviders: [],
|
|
123
|
+
agents: [
|
|
124
|
+
{
|
|
125
|
+
agentId: agent.id,
|
|
126
|
+
moduleId: agent.moduleId,
|
|
127
|
+
allowRuntimeModelOverride: true,
|
|
128
|
+
codeDefaultProviderId: null,
|
|
129
|
+
codeDefaultModelId: null,
|
|
130
|
+
override: null,
|
|
131
|
+
runtimeOverrideAllowlist: {
|
|
132
|
+
env: null,
|
|
133
|
+
tenant: null,
|
|
134
|
+
effective: { providers: null, modelsByProvider: {}, hasRestrictions: false, tenantOverridesActive: false },
|
|
135
|
+
envVarNames: { providers: 'X', modelsByProvider: {} },
|
|
136
|
+
},
|
|
137
|
+
providerId: 'openai',
|
|
138
|
+
modelId: 'gpt-5-mini',
|
|
139
|
+
baseURL: null,
|
|
140
|
+
source: 'provider_default',
|
|
141
|
+
moderation: { enforced: true, override: null, effective: 'enforced' },
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
}),
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await page.goto(AGENTS_PAGE, { waitUntil: 'domcontentloaded' });
|
|
149
|
+
|
|
150
|
+
const moderationSection = page.locator('[data-ai-agent-moderation]');
|
|
151
|
+
const settingsContainer = page.locator('[data-ai-agent-settings]');
|
|
152
|
+
// Either the moderation section rendered (registry+detail loaded from the stub)
|
|
153
|
+
// or at least the settings page chrome loaded — never a hard failure.
|
|
154
|
+
await expect(moderationSection.or(settingsContainer).first()).toBeVisible({ timeout: 20_000 });
|
|
155
|
+
|
|
156
|
+
if (await moderationSection.first().isVisible().catch(() => false)) {
|
|
157
|
+
// The untrustedInput agent renders the non-editable Enforced badge.
|
|
158
|
+
await expect(page.locator('[data-ai-agent-moderation-enforced]').first()).toBeVisible();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -171,6 +171,54 @@ export async function deleteTenantAllowlistInDb(tenantId: string | null): Promis
|
|
|
171
171
|
});
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
export interface SeedModerationFlagInput {
|
|
175
|
+
tenantId: string;
|
|
176
|
+
organizationId?: string | null;
|
|
177
|
+
agentId?: string;
|
|
178
|
+
userId?: string;
|
|
179
|
+
providerId?: string;
|
|
180
|
+
modelId?: string;
|
|
181
|
+
/** Defaults to a single flagged `hate` category. */
|
|
182
|
+
categories?: Record<string, { flagged: boolean; score: number }>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Inserts an `ai_moderation_flags` audit row directly. A real flag is only ever
|
|
187
|
+
* born from the gate during a flagged LLM turn (needs a live moderation call),
|
|
188
|
+
* so seeding the row keeps audit-listing + tenant-isolation coverage
|
|
189
|
+
* deterministic and provider-free. Returns the new id for assertions/cleanup.
|
|
190
|
+
*/
|
|
191
|
+
export async function seedModerationFlagInDb(input: SeedModerationFlagInput): Promise<string> {
|
|
192
|
+
const categories = JSON.stringify(input.categories ?? { hate: { flagged: true, score: 0.97 } });
|
|
193
|
+
return withClient(async (client) => {
|
|
194
|
+
const result = await client.query<{ id: string }>(
|
|
195
|
+
`insert into ai_moderation_flags
|
|
196
|
+
(id, tenant_id, organization_id, agent_id, user_id, provider_id, model_id, categories, created_at)
|
|
197
|
+
values
|
|
198
|
+
(gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7::jsonb, now())
|
|
199
|
+
returning id`,
|
|
200
|
+
[
|
|
201
|
+
input.tenantId,
|
|
202
|
+
input.organizationId ?? null,
|
|
203
|
+
input.agentId ?? 'it_agent.moderation_fixture',
|
|
204
|
+
input.userId ?? `it-user-${randomUUID()}`,
|
|
205
|
+
input.providerId ?? 'openai',
|
|
206
|
+
input.modelId ?? 'gpt-5-mini',
|
|
207
|
+
categories,
|
|
208
|
+
],
|
|
209
|
+
);
|
|
210
|
+
return result.rows[0].id;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Hard-deletes every moderation-flag row for a tenant (best-effort cleanup). */
|
|
215
|
+
export async function deleteModerationFlagsInDb(tenantId: string | null): Promise<void> {
|
|
216
|
+
if (!tenantId) return;
|
|
217
|
+
await withClient(async (client) => {
|
|
218
|
+
await client.query('delete from ai_moderation_flags where tenant_id = $1', [tenantId]);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
174
222
|
/**
|
|
175
223
|
* Hard-deletes a conversation and its participant + message rows by the
|
|
176
224
|
* client-facing `conversation_id`. The DELETE route only soft-deletes, so this
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type AiAgentLoopBudgetPreset,
|
|
16
16
|
} from '../../../lib/agent-runtime'
|
|
17
17
|
import { AgentPolicyError } from '../../../lib/agent-tools'
|
|
18
|
+
import { AiModerationBlockedError, AiModerationUnavailableError } from '../../../lib/moderation'
|
|
18
19
|
import { readBaseurlAllowlist, isBaseurlAllowlisted } from '../../../lib/baseurl-allowlist'
|
|
19
20
|
import {
|
|
20
21
|
canonicalProviderId,
|
|
@@ -700,6 +701,15 @@ export async function POST(req: NextRequest): Promise<Response> {
|
|
|
700
701
|
if (error instanceof AgentPolicyError) {
|
|
701
702
|
return jsonError(statusForDenyCode(error.code), error.message, error.code)
|
|
702
703
|
}
|
|
704
|
+
// Input pre-moderation outcomes (spec 2026-06-04). The blocked categories
|
|
705
|
+
// are NEVER sent to the client — only the typed code, which the chat UI
|
|
706
|
+
// maps to a generic translated message.
|
|
707
|
+
if (error instanceof AiModerationBlockedError) {
|
|
708
|
+
return jsonError(400, 'Input rejected by the content safety filter.', 'moderation_blocked')
|
|
709
|
+
}
|
|
710
|
+
if (error instanceof AiModerationUnavailableError) {
|
|
711
|
+
return jsonError(503, 'Content safety check temporarily unavailable.', 'moderation_unavailable')
|
|
712
|
+
}
|
|
703
713
|
logger.error('AI Chat Agent — Dispatch failure', { err: error })
|
|
704
714
|
return jsonError(
|
|
705
715
|
500,
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const authMock = jest.fn()
|
|
2
|
+
const loadAclMock = jest.fn()
|
|
3
|
+
const createRequestContainerMock = jest.fn()
|
|
4
|
+
const listMock = jest.fn()
|
|
5
|
+
|
|
6
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => ({
|
|
7
|
+
getAuthFromRequest: (...args: unknown[]) => authMock(...args),
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
11
|
+
createRequestContainer: (...args: unknown[]) => createRequestContainerMock(...args),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
jest.mock('../../../data/repositories/AiModerationFlagRepository', () => ({
|
|
15
|
+
AiModerationFlagRepository: jest.fn().mockImplementation(() => ({ list: listMock })),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
import { GET } from '../route'
|
|
19
|
+
|
|
20
|
+
function buildRequest(params: Record<string, string> = {}) {
|
|
21
|
+
const url = new URL('http://localhost/api/ai_assistant/moderation-flags')
|
|
22
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
|
|
23
|
+
return new Request(url.toString(), { method: 'GET' })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeRow(overrides: Record<string, unknown> = {}) {
|
|
27
|
+
return {
|
|
28
|
+
id: 'flag-1',
|
|
29
|
+
tenantId: 'tenant-1',
|
|
30
|
+
organizationId: null,
|
|
31
|
+
agentId: 'catalog.assistant',
|
|
32
|
+
userId: 'user-9',
|
|
33
|
+
providerId: 'openai',
|
|
34
|
+
modelId: 'gpt-5-mini',
|
|
35
|
+
categories: { hate: { flagged: true, score: 0.97 } },
|
|
36
|
+
createdAt: new Date('2026-06-10T12:00:00Z'),
|
|
37
|
+
...overrides,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('GET /api/ai_assistant/moderation-flags', () => {
|
|
42
|
+
let consoleErrorSpy: jest.SpyInstance
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
jest.clearAllMocks()
|
|
46
|
+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
47
|
+
authMock.mockResolvedValue({ sub: 'user-1', tenantId: 'tenant-1', orgId: 'org-1' })
|
|
48
|
+
loadAclMock.mockResolvedValue({ features: ['ai_assistant.settings.manage'], isSuperAdmin: false })
|
|
49
|
+
createRequestContainerMock.mockResolvedValue({
|
|
50
|
+
resolve: (name: string) => {
|
|
51
|
+
if (name === 'rbacService') {
|
|
52
|
+
return {
|
|
53
|
+
loadAcl: loadAclMock,
|
|
54
|
+
hasAllFeatures: (req: string[], have: string[]) => req.every((r) => have.includes(r)),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (name === 'em') return {}
|
|
58
|
+
throw new Error(`Unknown token: ${name}`)
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
listMock.mockResolvedValue({ items: [], total: 0 })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
consoleErrorSpy.mockRestore()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns 401 when unauthenticated', async () => {
|
|
69
|
+
authMock.mockResolvedValue(null)
|
|
70
|
+
const res = await GET(buildRequest() as never)
|
|
71
|
+
expect(res.status).toBe(401)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('returns 403 when the caller lacks ai_assistant.settings.manage', async () => {
|
|
75
|
+
loadAclMock.mockResolvedValue({ features: ['ai_assistant.view'], isSuperAdmin: false })
|
|
76
|
+
const res = await GET(buildRequest() as never)
|
|
77
|
+
expect(res.status).toBe(403)
|
|
78
|
+
expect((await res.json()).code).toBe('forbidden')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('rejects pageSize above the 100 cap with a validation error', async () => {
|
|
82
|
+
const res = await GET(buildRequest({ pageSize: '101' }) as never)
|
|
83
|
+
expect(res.status).toBe(400)
|
|
84
|
+
expect((await res.json()).code).toBe('validation_error')
|
|
85
|
+
expect(listMock).not.toHaveBeenCalled()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('lists tenant-scoped rows and echoes pagination', async () => {
|
|
89
|
+
listMock.mockResolvedValue({ items: [makeRow()], total: 1 })
|
|
90
|
+
const res = await GET(buildRequest({ page: '2', pageSize: '25' }) as never)
|
|
91
|
+
expect(res.status).toBe(200)
|
|
92
|
+
const json = await res.json()
|
|
93
|
+
expect(json.total).toBe(1)
|
|
94
|
+
expect(json.page).toBe(2)
|
|
95
|
+
expect(json.pageSize).toBe(25)
|
|
96
|
+
expect(json.items[0]).toMatchObject({ id: 'flag-1', userId: 'user-9', categories: { hate: { flagged: true, score: 0.97 } } })
|
|
97
|
+
// Tenant-scoped only — the listing does NOT narrow by organization_id.
|
|
98
|
+
expect(listMock).toHaveBeenCalledWith(
|
|
99
|
+
expect.objectContaining({ tenantId: 'tenant-1', page: 2, pageSize: 25 }),
|
|
100
|
+
)
|
|
101
|
+
expect(listMock.mock.calls[0][0].organizationId).toBeUndefined()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('passes agentId/userId/date filters through to the repository', async () => {
|
|
105
|
+
await GET(buildRequest({ agentId: 'catalog.assistant', userId: 'user-9', from: '2026-06-01', to: '2026-06-10' }) as never)
|
|
106
|
+
const call = listMock.mock.calls[0][0]
|
|
107
|
+
expect(call.agentId).toBe('catalog.assistant')
|
|
108
|
+
expect(call.userId).toBe('user-9')
|
|
109
|
+
expect(call.from).toBeInstanceOf(Date)
|
|
110
|
+
expect(call.to).toBeInstanceOf(Date)
|
|
111
|
+
// `to` is extended to end-of-day so same-day rows are included.
|
|
112
|
+
expect((call.to as Date).toISOString()).toBe('2026-06-10T23:59:59.999Z')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('returns an empty page (no repo call) when the caller has no tenant scope', async () => {
|
|
116
|
+
authMock.mockResolvedValue({ sub: 'user-1', tenantId: null, orgId: null })
|
|
117
|
+
const res = await GET(buildRequest() as never)
|
|
118
|
+
expect(res.status).toBe(200)
|
|
119
|
+
expect((await res.json()).items).toEqual([])
|
|
120
|
+
expect(listMock).not.toHaveBeenCalled()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
4
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
5
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
6
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
7
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
8
|
+
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
9
|
+
import { AiModerationFlagRepository } from '../../data/repositories/AiModerationFlagRepository'
|
|
10
|
+
import { hasRequiredFeatures } from '../../lib/auth'
|
|
11
|
+
|
|
12
|
+
const logger = createLogger('ai_assistant')
|
|
13
|
+
|
|
14
|
+
const REQUIRED_FEATURE = 'ai_assistant.settings.manage'
|
|
15
|
+
const MAX_PAGE_SIZE = 100
|
|
16
|
+
|
|
17
|
+
const querySchema = z.object({
|
|
18
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
19
|
+
pageSize: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(50),
|
|
20
|
+
agentId: z.string().min(1).max(256).optional(),
|
|
21
|
+
userId: z.string().min(1).max(256).optional(),
|
|
22
|
+
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'from must be a date in YYYY-MM-DD format').optional(),
|
|
23
|
+
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'to must be a date in YYYY-MM-DD format').optional(),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export const openApi: OpenApiRouteDoc = {
|
|
27
|
+
tag: 'AI Assistant',
|
|
28
|
+
summary: 'Input moderation flags audit log',
|
|
29
|
+
methods: {
|
|
30
|
+
GET: {
|
|
31
|
+
operationId: 'aiAssistantModerationFlags',
|
|
32
|
+
summary: 'List input-moderation audit flags for the current tenant.',
|
|
33
|
+
description:
|
|
34
|
+
'Returns the append-only `ai_moderation_flags` audit rows (category flags + scores only — never ' +
|
|
35
|
+
'prompt content) for the caller\'s tenant. Always tenant-scoped; cross-tenant access is impossible. ' +
|
|
36
|
+
'Optionally filtered by `agentId`, `userId`, and a `from`/`to` date window. Paginated (`pageSize` ≤ 100). ' +
|
|
37
|
+
'Requires `ai_assistant.settings.manage`.',
|
|
38
|
+
query: querySchema,
|
|
39
|
+
responses: [
|
|
40
|
+
{
|
|
41
|
+
status: 200,
|
|
42
|
+
description: 'Paginated moderation-flag rows: `{ items, total, page, pageSize }`.',
|
|
43
|
+
mediaType: 'application/json',
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
errors: [
|
|
47
|
+
{ status: 400, description: 'Invalid query parameters.' },
|
|
48
|
+
{ status: 401, description: 'Unauthenticated caller.' },
|
|
49
|
+
{ status: 403, description: 'Caller lacks `ai_assistant.settings.manage`.' },
|
|
50
|
+
{ status: 500, description: 'Internal failure.' },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const metadata = {
|
|
57
|
+
path: '/ai_assistant/moderation-flags',
|
|
58
|
+
GET: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function jsonError(status: number, message: string, code: string, extra?: Record<string, unknown>): NextResponse {
|
|
62
|
+
return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function GET(req: NextRequest): Promise<Response> {
|
|
66
|
+
const auth = await getAuthFromRequest(req)
|
|
67
|
+
if (!auth) {
|
|
68
|
+
return jsonError(401, 'Unauthorized', 'unauthenticated')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { searchParams } = new URL(req.url)
|
|
72
|
+
const queryResult = querySchema.safeParse({
|
|
73
|
+
page: searchParams.get('page') ?? undefined,
|
|
74
|
+
pageSize: searchParams.get('pageSize') ?? undefined,
|
|
75
|
+
agentId: searchParams.get('agentId') ?? undefined,
|
|
76
|
+
userId: searchParams.get('userId') ?? undefined,
|
|
77
|
+
from: searchParams.get('from') ?? undefined,
|
|
78
|
+
to: searchParams.get('to') ?? undefined,
|
|
79
|
+
})
|
|
80
|
+
if (!queryResult.success) {
|
|
81
|
+
return jsonError(400, 'Invalid query parameters.', 'validation_error', {
|
|
82
|
+
issues: queryResult.error.issues,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { page, pageSize, agentId, userId, from, to } = queryResult.data
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const container = await createRequestContainer()
|
|
90
|
+
const rbacService = container.resolve<RbacService>('rbacService')
|
|
91
|
+
const acl = await rbacService.loadAcl(auth.sub, {
|
|
92
|
+
tenantId: auth.tenantId,
|
|
93
|
+
organizationId: auth.orgId,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {
|
|
97
|
+
return jsonError(403, `Caller lacks required feature "${REQUIRED_FEATURE}".`, 'forbidden')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!auth.tenantId) {
|
|
101
|
+
return NextResponse.json({ items: [], total: 0, page, pageSize })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const em = container.resolve<EntityManager>('em')
|
|
105
|
+
const repo = new AiModerationFlagRepository(em)
|
|
106
|
+
const { items, total } = await repo.list({
|
|
107
|
+
tenantId: auth.tenantId,
|
|
108
|
+
// Tenant-scoped abuse audit: do NOT narrow by organization_id. Tenant is
|
|
109
|
+
// the hard isolation boundary; portal-surface flags often carry no org,
|
|
110
|
+
// and a tenant admin reviewing abuse needs every flag in the tenant.
|
|
111
|
+
agentId,
|
|
112
|
+
userId,
|
|
113
|
+
// `to` is an inclusive day; extend to end-of-day so same-day rows match.
|
|
114
|
+
from: from ? new Date(`${from}T00:00:00.000Z`) : undefined,
|
|
115
|
+
to: to ? new Date(`${to}T23:59:59.999Z`) : undefined,
|
|
116
|
+
page,
|
|
117
|
+
pageSize,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const serialized = items.map((row) => ({
|
|
121
|
+
id: row.id,
|
|
122
|
+
tenantId: row.tenantId,
|
|
123
|
+
organizationId: row.organizationId ?? null,
|
|
124
|
+
agentId: row.agentId,
|
|
125
|
+
userId: row.userId,
|
|
126
|
+
providerId: row.providerId,
|
|
127
|
+
modelId: row.modelId,
|
|
128
|
+
categories: row.categories,
|
|
129
|
+
createdAt: row.createdAt.toISOString(),
|
|
130
|
+
}))
|
|
131
|
+
|
|
132
|
+
return NextResponse.json({ items: serialized, total, page, pageSize })
|
|
133
|
+
} catch (error) {
|
|
134
|
+
logger.error('Moderation flags GET failed', { err: error })
|
|
135
|
+
return jsonError(500, 'Failed to fetch moderation flags.', 'internal_error')
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const authMock = jest.fn()
|
|
11
|
-
const
|
|
11
|
+
const userHasAllFeaturesMock = jest.fn()
|
|
12
12
|
const createRequestContainerMock = jest.fn()
|
|
13
13
|
const upsertDefaultMock = jest.fn()
|
|
14
14
|
const clearDefaultMock = jest.fn()
|
|
@@ -96,10 +96,10 @@ describe('PUT /api/ai_assistant/settings', () => {
|
|
|
96
96
|
jest.clearAllMocks()
|
|
97
97
|
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
98
98
|
authMock.mockResolvedValue({ sub: 'user-1', tenantId: 'tenant-1', orgId: 'org-1' })
|
|
99
|
-
|
|
99
|
+
userHasAllFeaturesMock.mockResolvedValue(true)
|
|
100
100
|
createRequestContainerMock.mockResolvedValue({
|
|
101
101
|
resolve: (name: string) => {
|
|
102
|
-
if (name === 'rbacService') return {
|
|
102
|
+
if (name === 'rbacService') return { userHasAllFeatures: userHasAllFeaturesMock }
|
|
103
103
|
if (name === 'em') return {}
|
|
104
104
|
return null
|
|
105
105
|
},
|
|
@@ -131,7 +131,7 @@ describe('PUT /api/ai_assistant/settings', () => {
|
|
|
131
131
|
})
|
|
132
132
|
|
|
133
133
|
it('returns 403 when caller lacks ai_assistant.settings.manage', async () => {
|
|
134
|
-
|
|
134
|
+
userHasAllFeaturesMock.mockResolvedValueOnce(false)
|
|
135
135
|
|
|
136
136
|
const response = await PUT(buildRequest('PUT', { providerId: 'openai' }) as any)
|
|
137
137
|
|
|
@@ -161,6 +161,56 @@ describe('PUT /api/ai_assistant/settings', () => {
|
|
|
161
161
|
)
|
|
162
162
|
})
|
|
163
163
|
|
|
164
|
+
it('forwards inputModeration to the override upsert and echoes it back', async () => {
|
|
165
|
+
upsertDefaultMock.mockResolvedValueOnce({
|
|
166
|
+
id: 'row-1',
|
|
167
|
+
tenantId: 'tenant-1',
|
|
168
|
+
organizationId: 'org-1',
|
|
169
|
+
agentId: 'catalog.catalog_assistant',
|
|
170
|
+
providerId: null,
|
|
171
|
+
modelId: null,
|
|
172
|
+
baseUrl: null,
|
|
173
|
+
inputModeration: true,
|
|
174
|
+
updatedAt: new Date('2026-06-10T00:00:00Z'),
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const response = await PUT(
|
|
178
|
+
buildRequest('PUT', { agentId: 'catalog.catalog_assistant', inputModeration: true }) as any,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
expect(response.status).toBe(200)
|
|
182
|
+
const json = await response.json()
|
|
183
|
+
expect(json.inputModeration).toBe(true)
|
|
184
|
+
expect(upsertDefaultMock).toHaveBeenCalledWith(
|
|
185
|
+
expect.objectContaining({ agentId: 'catalog.catalog_assistant', inputModeration: true }),
|
|
186
|
+
expect.objectContaining({ tenantId: 'tenant-1' }),
|
|
187
|
+
)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('passes inputModeration: null (inherit) through to the upsert', async () => {
|
|
191
|
+
upsertDefaultMock.mockResolvedValueOnce({
|
|
192
|
+
id: 'row-1',
|
|
193
|
+
tenantId: 'tenant-1',
|
|
194
|
+
organizationId: 'org-1',
|
|
195
|
+
agentId: 'catalog.catalog_assistant',
|
|
196
|
+
providerId: null,
|
|
197
|
+
modelId: null,
|
|
198
|
+
baseUrl: null,
|
|
199
|
+
inputModeration: null,
|
|
200
|
+
updatedAt: new Date('2026-06-10T00:00:00Z'),
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const response = await PUT(
|
|
204
|
+
buildRequest('PUT', { agentId: 'catalog.catalog_assistant', inputModeration: null }) as any,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
expect(response.status).toBe(200)
|
|
208
|
+
expect(upsertDefaultMock).toHaveBeenCalledWith(
|
|
209
|
+
expect.objectContaining({ inputModeration: null }),
|
|
210
|
+
expect.anything(),
|
|
211
|
+
)
|
|
212
|
+
})
|
|
213
|
+
|
|
164
214
|
it('saves per-agent chat override allowlist without requiring a model override', async () => {
|
|
165
215
|
upsertDefaultMock.mockResolvedValueOnce({
|
|
166
216
|
id: 'row-1',
|
|
@@ -268,7 +318,7 @@ describe('PUT /api/ai_assistant/settings', () => {
|
|
|
268
318
|
})
|
|
269
319
|
|
|
270
320
|
it('allows superAdmin even without the manage feature', async () => {
|
|
271
|
-
|
|
321
|
+
userHasAllFeaturesMock.mockResolvedValueOnce(true)
|
|
272
322
|
|
|
273
323
|
const response = await PUT(buildRequest('PUT', { providerId: 'openai' }) as any)
|
|
274
324
|
|
|
@@ -337,10 +387,10 @@ describe('DELETE /api/ai_assistant/settings', () => {
|
|
|
337
387
|
jest.clearAllMocks()
|
|
338
388
|
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
339
389
|
authMock.mockResolvedValue({ sub: 'user-1', tenantId: 'tenant-1', orgId: 'org-1' })
|
|
340
|
-
|
|
390
|
+
userHasAllFeaturesMock.mockResolvedValue(true)
|
|
341
391
|
createRequestContainerMock.mockResolvedValue({
|
|
342
392
|
resolve: (name: string) => {
|
|
343
|
-
if (name === 'rbacService') return {
|
|
393
|
+
if (name === 'rbacService') return { userHasAllFeatures: userHasAllFeaturesMock }
|
|
344
394
|
if (name === 'em') return {}
|
|
345
395
|
return null
|
|
346
396
|
},
|
|
@@ -361,7 +411,7 @@ describe('DELETE /api/ai_assistant/settings', () => {
|
|
|
361
411
|
})
|
|
362
412
|
|
|
363
413
|
it('returns 403 when caller lacks ai_assistant.settings.manage', async () => {
|
|
364
|
-
|
|
414
|
+
userHasAllFeaturesMock.mockResolvedValueOnce(false)
|
|
365
415
|
|
|
366
416
|
const response = await DELETE(buildRequest('DELETE', {}) as any)
|
|
367
417
|
|
|
@@ -151,12 +151,11 @@ export async function PUT(req: NextRequest) {
|
|
|
151
151
|
try {
|
|
152
152
|
const container = await createRequestContainer()
|
|
153
153
|
const rbacService = container.resolve<RbacService>('rbacService')
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
acl.isSuperAdmin || acl.features.includes('ai_assistant.settings.manage')
|
|
154
|
+
const canManage = await rbacService.userHasAllFeatures(
|
|
155
|
+
auth.sub,
|
|
156
|
+
['ai_assistant.settings.manage'],
|
|
157
|
+
{ tenantId: auth.tenantId, organizationId: auth.orgId },
|
|
158
|
+
)
|
|
160
159
|
if (!canManage) {
|
|
161
160
|
return NextResponse.json({ error: 'Forbidden', code: 'forbidden' }, { status: 403 })
|
|
162
161
|
}
|
|
@@ -197,12 +196,11 @@ export async function DELETE(req: NextRequest) {
|
|
|
197
196
|
try {
|
|
198
197
|
const container = await createRequestContainer()
|
|
199
198
|
const rbacService = container.resolve<RbacService>('rbacService')
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
acl.isSuperAdmin || acl.features.includes('ai_assistant.settings.manage')
|
|
199
|
+
const canManage = await rbacService.userHasAllFeatures(
|
|
200
|
+
auth.sub,
|
|
201
|
+
['ai_assistant.settings.manage'],
|
|
202
|
+
{ tenantId: auth.tenantId, organizationId: auth.orgId },
|
|
203
|
+
)
|
|
206
204
|
if (!canManage) {
|
|
207
205
|
return NextResponse.json({ error: 'Forbidden', code: 'forbidden' }, { status: 403 })
|
|
208
206
|
}
|