@open-mercato/ai-assistant 0.6.7 → 0.6.8-develop.6875.1.871a4afc94
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 +121 -15
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.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/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +4 -4
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
- 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 +42 -0
- package/dist/modules/ai_assistant/i18n/en.json +42 -0
- package/dist/modules/ai_assistant/i18n/es.json +42 -0
- package/dist/modules/ai_assistant/i18n/ko.json +527 -0
- package/dist/modules/ai_assistant/i18n/pl.json +42 -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/entity-graph.js +3 -3
- package/dist/modules/ai_assistant/lib/entity-graph.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 +13 -12
- 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 +161 -15
- package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +2 -2
- 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/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +4 -4
- 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 +42 -0
- package/src/modules/ai_assistant/i18n/en.json +42 -0
- package/src/modules/ai_assistant/i18n/es.json +42 -0
- package/src/modules/ai_assistant/i18n/ko.json +527 -0
- package/src/modules/ai_assistant/i18n/pl.json +42 -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/entity-graph.ts +3 -3
- 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
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:ai-assistant] found
|
|
1
|
+
[build:ai-assistant] found 231 entry points
|
|
2
2
|
[build:ai-assistant] built successfully
|
package/AGENTS.md
CHANGED
|
@@ -880,6 +880,16 @@ yarn mcp:serve
|
|
|
880
880
|
|
|
881
881
|
Typed pending-action lifecycle events live in `src/modules/ai_assistant/events.ts` and are emitted via the shared `emitAiAssistantEvent` helper (`createModuleEvents`). The three ids are FROZEN per `BACKWARD_COMPATIBILITY.md` §5 and MUST NOT be renamed; payload fields are additive-only. `ai.action.confirmed` fires from `executePendingActionConfirm` with `{ pendingActionId, agentId, toolName, status, tenantId, organizationId, userId, resolvedByUserId, resolvedAt, executionResult, failedRecords? }`; `ai.action.cancelled` fires from `executePendingActionCancel` with the same shape plus an optional `reason`; `ai.action.expired` fires from the cancel helper's TTL short-circuit (and the Step 5.12 cleanup worker) with `resolvedByUserId: null` and additional `expiresAt` / `expiredAt` timestamps. All three use `category: 'system'` and `entity: 'ai_pending_action'`.
|
|
882
882
|
|
|
883
|
+
`ai_assistant.moderation_flag.created` (`entity: 'ai_moderation_flag'`) fires best-effort from the input-moderation gate; payload carries flagged category names only, never prompt content.
|
|
884
|
+
|
|
885
|
+
## Input moderation & safety identifiers
|
|
886
|
+
|
|
887
|
+
Guide: [`moderation.mdx`](../../apps/docs/docs/framework/ai-assistant/moderation.mdx) + spec `.ai/specs/2026-06-04-ai-input-moderation-and-safety-identifiers.md`. Envs: `OM_AI_INPUT_MODERATION`, `OM_AI_MODERATION_MODEL`.
|
|
888
|
+
|
|
889
|
+
- Enforced surfaces (`untrustedInput`) fail **closed**; opt-in surfaces fail **open**.
|
|
890
|
+
- Flagged categories are audit-only — never send them to the client.
|
|
891
|
+
- The audit write is best-effort and MUST NOT block the rejection.
|
|
892
|
+
|
|
883
893
|
## Rules for the OpenCode Client
|
|
884
894
|
|
|
885
895
|
Located in `lib/opencode-client.ts`. Use these methods when interacting with OpenCode:
|
|
@@ -1620,12 +1630,6 @@ Note: the guard is intentionally NOT added to `mcp-client.ts` `connectHttp`, whi
|
|
|
1620
1630
|
- `src/modules/ai_assistant/lib/opencode-handlers.ts` - Fixed Promise.race completion bug
|
|
1621
1631
|
- `src/frontend/hooks/useCommandPalette.ts` - Added ref pattern for sessionId
|
|
1622
1632
|
|
|
1623
|
-
**Diagnostic logging added** (can be removed after verification):
|
|
1624
|
-
- `[handleSubmit] DIAGNOSTIC` - Session check before routing
|
|
1625
|
-
- `[sendAgenticMessage] DIAGNOSTIC` - Request payload before fetch
|
|
1626
|
-
- `[startAgenticChat] DIAGNOSTIC` - Done event handling
|
|
1627
|
-
- `[AI Chat] DIAGNOSTIC` - Backend request received
|
|
1628
|
-
|
|
1629
1633
|
### 2026-01 - OpenCode Integration
|
|
1630
1634
|
|
|
1631
1635
|
**Lesson learned:** When replacing an AI backend, preserve the session management contract — the frontend depends on `sessionId` in `done` events regardless of the underlying AI engine.
|
|
@@ -1645,30 +1649,9 @@ Note: the guard is intentionally NOT added to `mcp-client.ts` `connectHttp`, whi
|
|
|
1645
1649
|
- `src/frontend/components/CommandPalette/ToolChatPage.tsx` - Added thinking UI
|
|
1646
1650
|
- `src/frontend/types.ts` - Added ChatSSEEvent, isThinking
|
|
1647
1651
|
|
|
1648
|
-
### 2026-01 - API Discovery Tools
|
|
1649
|
-
|
|
1650
|
-
**Lesson learned:** Exposing hundreds of individual tools overwhelms the AI context. Use meta-tools (discover, schema, execute) to let the agent dynamically find what it needs.
|
|
1652
|
+
### 2026-01 - API Discovery Tools / Hybrid Tool Discovery (superseded)
|
|
1651
1653
|
|
|
1652
|
-
**
|
|
1653
|
-
|
|
1654
|
-
**What changed**:
|
|
1655
|
-
- Added `api_discover`, `api_execute`, `api_schema` tools
|
|
1656
|
-
- Created `ApiEndpointIndex` for OpenAPI introspection
|
|
1657
|
-
- Hybrid discovery: search + OpenAPI
|
|
1658
|
-
- 405 endpoints available via discovery
|
|
1659
|
-
|
|
1660
|
-
**Files created**:
|
|
1661
|
-
- `lib/api-discovery-tools.ts`
|
|
1662
|
-
- `lib/api-endpoint-index.ts`
|
|
1663
|
-
|
|
1664
|
-
### 2026-01 - Hybrid Tool Discovery
|
|
1665
|
-
|
|
1666
|
-
**Lesson learned:** Neither search-based nor OpenAPI-based discovery alone covers all tools — combine both for comprehensive results.
|
|
1667
|
-
|
|
1668
|
-
**What changed**:
|
|
1669
|
-
- Combined semantic search with OpenAPI introspection
|
|
1670
|
-
- Tools indexed for fulltext search
|
|
1671
|
-
- API endpoints indexed from OpenAPI spec
|
|
1654
|
+
**Lesson learned:** Exposing hundreds of individual tools overwhelms the AI context — use meta-tools the agent can search. Superseded by Code Mode (2026-02-22); the tools and files these entries described (`api_discover`, `api_schema`, `lib/api-discovery-tools.ts`, `lib/entity-graph-tools.ts`) were deleted in #1876. See git history for the detail.
|
|
1672
1655
|
|
|
1673
1656
|
### Previous Changes
|
|
1674
1657
|
|
package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { test, expect, request as playwrightRequest } from "@playwright/test";
|
|
2
|
+
import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
|
|
3
|
+
import { getTokenScope, readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
|
|
4
|
+
import {
|
|
5
|
+
deleteAgentOverridesInDb,
|
|
6
|
+
deleteModerationFlagsInDb,
|
|
7
|
+
seedModerationFlagInDb
|
|
8
|
+
} from "./helpers/aiAssistantFixtures.js";
|
|
9
|
+
const SETTINGS = "/api/ai_assistant/settings";
|
|
10
|
+
const FLAGS = "/api/ai_assistant/moderation-flags";
|
|
11
|
+
test.describe("TC-AI-MODERATION-008: input moderation settings + audit", () => {
|
|
12
|
+
test("PUT inputModeration persists per agent and round-trips on/off/inherit", async ({ request }) => {
|
|
13
|
+
test.slow();
|
|
14
|
+
const adminToken = await getAuthToken(request, "admin");
|
|
15
|
+
const { tenantId } = getTokenScope(adminToken);
|
|
16
|
+
const agentId = "it.moderation_settings";
|
|
17
|
+
try {
|
|
18
|
+
const on = await apiRequest(request, "PUT", SETTINGS, {
|
|
19
|
+
token: adminToken,
|
|
20
|
+
data: { agentId, inputModeration: true }
|
|
21
|
+
});
|
|
22
|
+
expect(on.status(), "PUT inputModeration=true returns 200").toBe(200);
|
|
23
|
+
expect((await readJsonSafe(on))?.inputModeration).toBe(true);
|
|
24
|
+
const off = await apiRequest(request, "PUT", SETTINGS, {
|
|
25
|
+
token: adminToken,
|
|
26
|
+
data: { agentId, inputModeration: false }
|
|
27
|
+
});
|
|
28
|
+
expect(off.status()).toBe(200);
|
|
29
|
+
expect((await readJsonSafe(off))?.inputModeration).toBe(false);
|
|
30
|
+
const inherit = await apiRequest(request, "PUT", SETTINGS, {
|
|
31
|
+
token: adminToken,
|
|
32
|
+
data: { agentId, inputModeration: null }
|
|
33
|
+
});
|
|
34
|
+
expect(inherit.status()).toBe(200);
|
|
35
|
+
expect((await readJsonSafe(inherit))?.inputModeration).toBeNull();
|
|
36
|
+
} finally {
|
|
37
|
+
await deleteAgentOverridesInDb({ tenantId, agentId }).catch(() => void 0);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
test("GET /moderation-flags is tenant-scoped (cross-tenant rows are never returned)", async ({ request }) => {
|
|
41
|
+
test.slow();
|
|
42
|
+
const adminToken = await getAuthToken(request, "admin");
|
|
43
|
+
const { tenantId } = getTokenScope(adminToken);
|
|
44
|
+
const otherTenantId = "00000000-0000-4000-8000-0000000face7";
|
|
45
|
+
let mineId = "";
|
|
46
|
+
try {
|
|
47
|
+
mineId = await seedModerationFlagInDb({ tenantId, agentId: "it.moderation_audit", userId: "it-user-mine" });
|
|
48
|
+
await seedModerationFlagInDb({ tenantId: otherTenantId, agentId: "it.moderation_audit", userId: "it-user-other" });
|
|
49
|
+
const res = await apiRequest(request, "GET", `${FLAGS}?pageSize=100`, { token: adminToken });
|
|
50
|
+
expect(res.status()).toBe(200);
|
|
51
|
+
const body = await readJsonSafe(res);
|
|
52
|
+
expect(body?.items, "returns an items array").toBeTruthy();
|
|
53
|
+
const ids = (body?.items ?? []).map((row) => row.id);
|
|
54
|
+
expect(ids, "my tenant flag is visible").toContain(mineId);
|
|
55
|
+
for (const row of body?.items ?? []) {
|
|
56
|
+
expect(row.tenantId).toBe(tenantId);
|
|
57
|
+
}
|
|
58
|
+
} finally {
|
|
59
|
+
await deleteModerationFlagsInDb(tenantId).catch(() => void 0);
|
|
60
|
+
await deleteModerationFlagsInDb(otherTenantId).catch(() => void 0);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
test("GET /moderation-flags RBAC + validation gates", async ({ request, baseURL }) => {
|
|
64
|
+
const adminToken = await getAuthToken(request, "admin");
|
|
65
|
+
const tooLarge = await apiRequest(request, "GET", `${FLAGS}?pageSize=101`, { token: adminToken });
|
|
66
|
+
expect(tooLarge.status()).toBe(400);
|
|
67
|
+
expect((await readJsonSafe(tooLarge))?.code).toBe("validation_error");
|
|
68
|
+
const employeeToken = await getAuthToken(request, "employee");
|
|
69
|
+
const denied = await apiRequest(request, "GET", FLAGS, { token: employeeToken });
|
|
70
|
+
expect(denied.status(), "employee lacks settings.manage -> 403").toBe(403);
|
|
71
|
+
const anon = await playwrightRequest.newContext({ baseURL });
|
|
72
|
+
try {
|
|
73
|
+
const res = await anon.fetch(FLAGS, { method: "GET" });
|
|
74
|
+
expect(res.status(), "unauthenticated GET is 401").toBe(401);
|
|
75
|
+
} finally {
|
|
76
|
+
await anon.dispose();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
//# sourceMappingURL=TC-AI-MODERATION-008-settings-and-audit.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { test, expect, request as playwrightRequest } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport {\n deleteAgentOverridesInDb,\n deleteModerationFlagsInDb,\n seedModerationFlagInDb,\n} from './helpers/aiAssistantFixtures';\n\n/**\n * TC-AI-MODERATION-008 \u2014 Input-moderation settings + audit API.\n * Source: spec `.ai/specs/2026-06-04-ai-input-moderation-and-safety-identifiers.md`.\n *\n * Surfaces under test:\n * - PUT /api/ai_assistant/settings (persists `inputModeration` per agent)\n * - GET /api/ai_assistant/settings (reflects per-agent effective moderation policy)\n * - GET /api/ai_assistant/moderation-flags (tenant-scoped audit listing)\n *\n * Contract notes verified against the route handlers:\n * - PUT `{ agentId, inputModeration }` returns 200 echoing `inputModeration`.\n * - GET /settings exposes `agents[].moderation = { enforced, override, effective }`.\n * - GET /moderation-flags returns `{ items, total, page, pageSize }`, tenant-scoped,\n * gated by `ai_assistant.settings.manage`, `pageSize` capped at 100.\n *\n * Flags are seeded directly in the DB: a real flag is only ever born from the gate\n * during a flagged LLM turn (needs a live moderation call), so seeding keeps the\n * audit + isolation coverage deterministic and provider-free. This spec therefore\n * only runs under the coherent app+DB harness (`yarn test:integration` / `:ephemeral`).\n */\n\nconst SETTINGS = '/api/ai_assistant/settings';\nconst FLAGS = '/api/ai_assistant/moderation-flags';\n\ninterface FlagsResponse {\n items: Array<{ id: string; tenantId: string; agentId: string; userId: string; categories: Record<string, unknown> }>;\n total: number;\n page: number;\n pageSize: number;\n}\n\ntest.describe('TC-AI-MODERATION-008: input moderation settings + audit', () => {\n test('PUT inputModeration persists per agent and round-trips on/off/inherit', async ({ request }) => {\n test.slow();\n const adminToken = await getAuthToken(request, 'admin');\n const { tenantId } = getTokenScope(adminToken);\n // A synthetic agent id keeps the test independent of the (empty-in-CI) live\n // agent registry; the PUT upserts an override row keyed by this id. The\n // per-agent `moderation` reflection on GET (registered agents only) is\n // covered by the route unit tests + the stubbed UI spec (TC-AI-MODERATION-009).\n const agentId = 'it.moderation_settings';\n\n try {\n const on = await apiRequest(request, 'PUT', SETTINGS, {\n token: adminToken,\n data: { agentId, inputModeration: true },\n });\n expect(on.status(), 'PUT inputModeration=true returns 200').toBe(200);\n expect((await readJsonSafe<{ inputModeration: boolean | null }>(on))?.inputModeration).toBe(true);\n\n const off = await apiRequest(request, 'PUT', SETTINGS, {\n token: adminToken,\n data: { agentId, inputModeration: false },\n });\n expect(off.status()).toBe(200);\n expect((await readJsonSafe<{ inputModeration: boolean | null }>(off))?.inputModeration).toBe(false);\n\n const inherit = await apiRequest(request, 'PUT', SETTINGS, {\n token: adminToken,\n data: { agentId, inputModeration: null },\n });\n expect(inherit.status()).toBe(200);\n expect((await readJsonSafe<{ inputModeration: boolean | null }>(inherit))?.inputModeration).toBeNull();\n } finally {\n await deleteAgentOverridesInDb({ tenantId, agentId }).catch(() => undefined);\n }\n });\n\n test('GET /moderation-flags is tenant-scoped (cross-tenant rows are never returned)', async ({ request }) => {\n test.slow();\n const adminToken = await getAuthToken(request, 'admin');\n const { tenantId } = getTokenScope(adminToken);\n const otherTenantId = '00000000-0000-4000-8000-0000000face7';\n\n let mineId = '';\n try {\n mineId = await seedModerationFlagInDb({ tenantId, agentId: 'it.moderation_audit', userId: 'it-user-mine' });\n await seedModerationFlagInDb({ tenantId: otherTenantId, agentId: 'it.moderation_audit', userId: 'it-user-other' });\n\n const res = await apiRequest(request, 'GET', `${FLAGS}?pageSize=100`, { token: adminToken });\n expect(res.status()).toBe(200);\n const body = await readJsonSafe<FlagsResponse>(res);\n expect(body?.items, 'returns an items array').toBeTruthy();\n const ids = (body?.items ?? []).map((row) => row.id);\n expect(ids, 'my tenant flag is visible').toContain(mineId);\n // Every returned row is scoped to the caller's tenant \u2014 the other-tenant row never leaks.\n for (const row of body?.items ?? []) {\n expect(row.tenantId).toBe(tenantId);\n }\n } finally {\n await deleteModerationFlagsInDb(tenantId).catch(() => undefined);\n await deleteModerationFlagsInDb(otherTenantId).catch(() => undefined);\n }\n });\n\n test('GET /moderation-flags RBAC + validation gates', async ({ request, baseURL }) => {\n const adminToken = await getAuthToken(request, 'admin');\n\n // pageSize above the 100 cap is rejected by the zod query schema.\n const tooLarge = await apiRequest(request, 'GET', `${FLAGS}?pageSize=101`, { token: adminToken });\n expect(tooLarge.status()).toBe(400);\n expect((await readJsonSafe<{ code?: string }>(tooLarge))?.code).toBe('validation_error');\n\n // Employee carries ai_assistant.view but NOT ai_assistant.settings.manage.\n const employeeToken = await getAuthToken(request, 'employee');\n const denied = await apiRequest(request, 'GET', FLAGS, { token: employeeToken });\n expect(denied.status(), 'employee lacks settings.manage -> 403').toBe(403);\n\n const anon = await playwrightRequest.newContext({ baseURL });\n try {\n const res = await anon.fetch(FLAGS, { method: 'GET' });\n expect(res.status(), 'unauthenticated GET is 401').toBe(401);\n } finally {\n await anon.dispose();\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { test, expect } from "@playwright/test";
|
|
2
|
+
import { login } from "@open-mercato/core/modules/core/__integration__/helpers/auth";
|
|
3
|
+
const FLAGS_PAGE = "/backend/config/ai-assistant/moderation-flags";
|
|
4
|
+
const AGENTS_PAGE = "/backend/config/ai-assistant/agents";
|
|
5
|
+
test.describe("TC-AI-MODERATION-009: moderation UI surfaces", () => {
|
|
6
|
+
test("audit page renders a flagged row with its category badge", async ({ page }) => {
|
|
7
|
+
await login(page, "superadmin");
|
|
8
|
+
await page.route("**/api/ai_assistant/moderation-flags**", async (route) => {
|
|
9
|
+
await route.fulfill({
|
|
10
|
+
status: 200,
|
|
11
|
+
contentType: "application/json",
|
|
12
|
+
body: JSON.stringify({
|
|
13
|
+
items: [
|
|
14
|
+
{
|
|
15
|
+
id: "flag-it-1",
|
|
16
|
+
tenantId: "t-1",
|
|
17
|
+
organizationId: null,
|
|
18
|
+
agentId: "support.portal_assistant",
|
|
19
|
+
userId: "hashed-user-it",
|
|
20
|
+
providerId: "openai",
|
|
21
|
+
modelId: "gpt-5-mini",
|
|
22
|
+
categories: { hate: { flagged: true, score: 0.97 }, violence: { flagged: false, score: 0.01 } },
|
|
23
|
+
createdAt: "2026-06-10T12:00:00.000Z"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
total: 1,
|
|
27
|
+
page: 1,
|
|
28
|
+
pageSize: 50
|
|
29
|
+
})
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
await page.goto(FLAGS_PAGE, { waitUntil: "domcontentloaded" });
|
|
33
|
+
await expect(page.getByText("support.portal_assistant").first()).toBeVisible({ timeout: 2e4 });
|
|
34
|
+
await expect(page.getByText("hashed-user-it").first()).toBeVisible();
|
|
35
|
+
await expect(page.getByText("hate", { exact: true }).first()).toBeVisible();
|
|
36
|
+
await expect(page.getByText("violence", { exact: true })).toHaveCount(0);
|
|
37
|
+
});
|
|
38
|
+
test("audit page renders the empty state when there are no flags", async ({ page }) => {
|
|
39
|
+
await login(page, "superadmin");
|
|
40
|
+
await page.route("**/api/ai_assistant/moderation-flags**", async (route) => {
|
|
41
|
+
await route.fulfill({
|
|
42
|
+
status: 200,
|
|
43
|
+
contentType: "application/json",
|
|
44
|
+
body: JSON.stringify({ items: [], total: 0, page: 1, pageSize: 50 })
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
await page.goto(FLAGS_PAGE, { waitUntil: "domcontentloaded" });
|
|
48
|
+
await expect(page.getByText(/No flagged messages/i).first()).toBeVisible({ timeout: 2e4 });
|
|
49
|
+
});
|
|
50
|
+
test("audit page redirects an unauthenticated visit to login", async ({ browser }) => {
|
|
51
|
+
const context = await browser.newContext();
|
|
52
|
+
const page = await context.newPage();
|
|
53
|
+
try {
|
|
54
|
+
await page.goto(FLAGS_PAGE, { waitUntil: "domcontentloaded" });
|
|
55
|
+
await page.waitForURL(/\/login/, { timeout: 15e3 });
|
|
56
|
+
expect(page.url()).toMatch(/\/login/);
|
|
57
|
+
} finally {
|
|
58
|
+
await context.close();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
test("agent settings renders the input-moderation section (enforced badge for untrusted agents)", async ({
|
|
62
|
+
page
|
|
63
|
+
}) => {
|
|
64
|
+
await login(page, "superadmin");
|
|
65
|
+
const agent = {
|
|
66
|
+
id: "support.portal_assistant",
|
|
67
|
+
moduleId: "support",
|
|
68
|
+
label: "Portal assistant",
|
|
69
|
+
description: "Customer-facing support assistant.",
|
|
70
|
+
executionMode: "chat",
|
|
71
|
+
readOnly: true,
|
|
72
|
+
mutationPolicy: "read-only",
|
|
73
|
+
untrustedInput: true,
|
|
74
|
+
allowedTools: [],
|
|
75
|
+
acceptedMediaTypes: [],
|
|
76
|
+
tools: []
|
|
77
|
+
};
|
|
78
|
+
await page.route("**/api/ai_assistant/ai/agents", async (route) => {
|
|
79
|
+
await route.fulfill({
|
|
80
|
+
status: 200,
|
|
81
|
+
contentType: "application/json",
|
|
82
|
+
body: JSON.stringify({ agents: [agent], total: 1 })
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
await page.route("**/api/ai_assistant/settings", async (route) => {
|
|
86
|
+
await route.fulfill({
|
|
87
|
+
status: 200,
|
|
88
|
+
contentType: "application/json",
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
availableProviders: [],
|
|
91
|
+
agents: [
|
|
92
|
+
{
|
|
93
|
+
agentId: agent.id,
|
|
94
|
+
moduleId: agent.moduleId,
|
|
95
|
+
allowRuntimeModelOverride: true,
|
|
96
|
+
codeDefaultProviderId: null,
|
|
97
|
+
codeDefaultModelId: null,
|
|
98
|
+
override: null,
|
|
99
|
+
runtimeOverrideAllowlist: {
|
|
100
|
+
env: null,
|
|
101
|
+
tenant: null,
|
|
102
|
+
effective: { providers: null, modelsByProvider: {}, hasRestrictions: false, tenantOverridesActive: false },
|
|
103
|
+
envVarNames: { providers: "X", modelsByProvider: {} }
|
|
104
|
+
},
|
|
105
|
+
providerId: "openai",
|
|
106
|
+
modelId: "gpt-5-mini",
|
|
107
|
+
baseURL: null,
|
|
108
|
+
source: "provider_default",
|
|
109
|
+
moderation: { enforced: true, override: null, effective: "enforced" }
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
})
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
await page.goto(AGENTS_PAGE, { waitUntil: "domcontentloaded" });
|
|
116
|
+
const moderationSection = page.locator("[data-ai-agent-moderation]");
|
|
117
|
+
const settingsContainer = page.locator("[data-ai-agent-settings]");
|
|
118
|
+
await expect(moderationSection.or(settingsContainer).first()).toBeVisible({ timeout: 2e4 });
|
|
119
|
+
if (await moderationSection.first().isVisible().catch(() => false)) {
|
|
120
|
+
await expect(page.locator("[data-ai-agent-moderation-enforced]").first()).toBeVisible();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
//# sourceMappingURL=TC-AI-MODERATION-009-ui.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { test, expect } from '@playwright/test';\nimport { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth';\n\n/**\n * TC-AI-MODERATION-009 \u2014 Moderation UI surfaces (Playwright).\n * Source: spec `.ai/specs/2026-06-04-ai-input-moderation-and-safety-identifiers.md`.\n *\n * Covers the two new backend UI surfaces deterministically by stubbing the\n * data routes (the page chrome + components render regardless of the live\n * registry / DB):\n * - `/backend/config/ai-assistant/moderation-flags` \u2014 audit DataTable.\n * - `/backend/config/ai-assistant/agents` \u2014 per-agent moderation section.\n *\n * The chat enforced\u2192rejection and chat-off\u2192passthrough flows are intentionally\n * NOT exercised here: the moderation gate runs SERVER-SIDE inside\n * `runAiAgentText`, so `page.route()` cannot stub it, and a live-provider e2e\n * would be non-deterministic (it needs a real OpenAI key + model behavior),\n * which `.ai/qa/AGENTS.md` forbids. Those branches are covered deterministically\n * by the gate unit tests (`input-moderation-gate.test.ts`), the moderation\n * service tests, and the `<AiChat>` `moderation_blocked` render test.\n */\n\nconst FLAGS_PAGE = '/backend/config/ai-assistant/moderation-flags';\nconst AGENTS_PAGE = '/backend/config/ai-assistant/agents';\n\ntest.describe('TC-AI-MODERATION-009: moderation UI surfaces', () => {\n test('audit page renders a flagged row with its category badge', async ({ page }) => {\n await login(page, 'superadmin');\n\n await page.route('**/api/ai_assistant/moderation-flags**', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({\n items: [\n {\n id: 'flag-it-1',\n tenantId: 't-1',\n organizationId: null,\n agentId: 'support.portal_assistant',\n userId: 'hashed-user-it',\n providerId: 'openai',\n modelId: 'gpt-5-mini',\n categories: { hate: { flagged: true, score: 0.97 }, violence: { flagged: false, score: 0.01 } },\n createdAt: '2026-06-10T12:00:00.000Z',\n },\n ],\n total: 1,\n page: 1,\n pageSize: 50,\n }),\n });\n });\n\n await page.goto(FLAGS_PAGE, { waitUntil: 'domcontentloaded' });\n\n await expect(page.getByText('support.portal_assistant').first()).toBeVisible({ timeout: 20_000 });\n await expect(page.getByText('hashed-user-it').first()).toBeVisible();\n // Only the flagged category renders as a badge; the non-flagged one is omitted.\n await expect(page.getByText('hate', { exact: true }).first()).toBeVisible();\n await expect(page.getByText('violence', { exact: true })).toHaveCount(0);\n });\n\n test('audit page renders the empty state when there are no flags', async ({ page }) => {\n await login(page, 'superadmin');\n\n await page.route('**/api/ai_assistant/moderation-flags**', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({ items: [], total: 0, page: 1, pageSize: 50 }),\n });\n });\n\n await page.goto(FLAGS_PAGE, { waitUntil: 'domcontentloaded' });\n await expect(page.getByText(/No flagged messages/i).first()).toBeVisible({ timeout: 20_000 });\n });\n\n test('audit page redirects an unauthenticated visit to login', async ({ browser }) => {\n const context = await browser.newContext();\n const page = await context.newPage();\n try {\n await page.goto(FLAGS_PAGE, { waitUntil: 'domcontentloaded' });\n await page.waitForURL(/\\/login/, { timeout: 15_000 });\n expect(page.url()).toMatch(/\\/login/);\n } finally {\n await context.close();\n }\n });\n\n test('agent settings renders the input-moderation section (enforced badge for untrusted agents)', async ({\n page,\n }) => {\n await login(page, 'superadmin');\n\n const agent = {\n id: 'support.portal_assistant',\n moduleId: 'support',\n label: 'Portal assistant',\n description: 'Customer-facing support assistant.',\n executionMode: 'chat',\n readOnly: true,\n mutationPolicy: 'read-only',\n untrustedInput: true,\n allowedTools: [],\n acceptedMediaTypes: [],\n tools: [],\n };\n\n await page.route('**/api/ai_assistant/ai/agents', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({ agents: [agent], total: 1 }),\n });\n });\n await page.route('**/api/ai_assistant/settings', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({\n availableProviders: [],\n agents: [\n {\n agentId: agent.id,\n moduleId: agent.moduleId,\n allowRuntimeModelOverride: true,\n codeDefaultProviderId: null,\n codeDefaultModelId: null,\n override: null,\n runtimeOverrideAllowlist: {\n env: null,\n tenant: null,\n effective: { providers: null, modelsByProvider: {}, hasRestrictions: false, tenantOverridesActive: false },\n envVarNames: { providers: 'X', modelsByProvider: {} },\n },\n providerId: 'openai',\n modelId: 'gpt-5-mini',\n baseURL: null,\n source: 'provider_default',\n moderation: { enforced: true, override: null, effective: 'enforced' },\n },\n ],\n }),\n });\n });\n\n await page.goto(AGENTS_PAGE, { waitUntil: 'domcontentloaded' });\n\n const moderationSection = page.locator('[data-ai-agent-moderation]');\n const settingsContainer = page.locator('[data-ai-agent-settings]');\n // Either the moderation section rendered (registry+detail loaded from the stub)\n // or at least the settings page chrome loaded \u2014 never a hard failure.\n await expect(moderationSection.or(settingsContainer).first()).toBeVisible({ timeout: 20_000 });\n\n if (await moderationSection.first().isVisible().catch(() => false)) {\n // The untrustedInput agent renders the non-editable Enforced badge.\n await expect(page.locator('[data-ai-agent-moderation-enforced]').first()).toBeVisible();\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -100,6 +100,34 @@ async function deleteTenantAllowlistInDb(tenantId) {
|
|
|
100
100
|
await client.query("delete from ai_tenant_model_allowlists where tenant_id = $1", [tenantId]);
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
|
+
async function seedModerationFlagInDb(input) {
|
|
104
|
+
const categories = JSON.stringify(input.categories ?? { hate: { flagged: true, score: 0.97 } });
|
|
105
|
+
return withClient(async (client) => {
|
|
106
|
+
const result = await client.query(
|
|
107
|
+
`insert into ai_moderation_flags
|
|
108
|
+
(id, tenant_id, organization_id, agent_id, user_id, provider_id, model_id, categories, created_at)
|
|
109
|
+
values
|
|
110
|
+
(gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7::jsonb, now())
|
|
111
|
+
returning id`,
|
|
112
|
+
[
|
|
113
|
+
input.tenantId,
|
|
114
|
+
input.organizationId ?? null,
|
|
115
|
+
input.agentId ?? "it_agent.moderation_fixture",
|
|
116
|
+
input.userId ?? `it-user-${randomUUID()}`,
|
|
117
|
+
input.providerId ?? "openai",
|
|
118
|
+
input.modelId ?? "gpt-5-mini",
|
|
119
|
+
categories
|
|
120
|
+
]
|
|
121
|
+
);
|
|
122
|
+
return result.rows[0].id;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async function deleteModerationFlagsInDb(tenantId) {
|
|
126
|
+
if (!tenantId) return;
|
|
127
|
+
await withClient(async (client) => {
|
|
128
|
+
await client.query("delete from ai_moderation_flags where tenant_id = $1", [tenantId]);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
103
131
|
async function deleteConversationCascadeInDb(input) {
|
|
104
132
|
if (!input.tenantId || !input.conversationId) return;
|
|
105
133
|
await withClient(async (client) => {
|
|
@@ -120,8 +148,10 @@ async function deleteConversationCascadeInDb(input) {
|
|
|
120
148
|
export {
|
|
121
149
|
deleteAgentOverridesInDb,
|
|
122
150
|
deleteConversationCascadeInDb,
|
|
151
|
+
deleteModerationFlagsInDb,
|
|
123
152
|
deletePendingActionInDb,
|
|
124
153
|
deleteTenantAllowlistInDb,
|
|
154
|
+
seedModerationFlagInDb,
|
|
125
155
|
seedPendingActionInDb
|
|
126
156
|
};
|
|
127
157
|
//# sourceMappingURL=aiAssistantFixtures.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts"],
|
|
4
|
-
"sourcesContent": ["import { readFileSync } from 'node:fs';\nimport { randomUUID } from 'node:crypto';\nimport path from 'node:path';\nimport { Client } from 'pg';\n\n/**\n * Direct-Postgres fixtures for AI Assistant integration specs.\n *\n * Mirrors the sanctioned pattern in\n * `@open-mercato/core/helpers/integration/dbFixtures` (raw SQL against\n * `DATABASE_URL`) for the surfaces that have NO public create route:\n * - `ai_pending_actions`: a pending mutation approval is only ever born from\n * the internal `prepareMutation` path during a real LLM agent turn. Seeding\n * the row directly keeps confirm/cancel/GET coverage deterministic and\n * provider-free.\n * - prompt overrides: the `prompt-override` route exposes no DELETE, so\n * versioned rows must be swept via SQL after a test.\n *\n * The app server and this helper MUST share one `DATABASE_URL`, so specs using\n * these helpers only run under the coherent app+DB harness\n * (`yarn test:integration` / `:ephemeral`), never against an arbitrary dev\n * server whose `DATABASE_URL` differs from `apps/mercato/.env`.\n */\n\nfunction resolveAppRoot(): string {\n const fromEnv = process.env.OM_TEST_APP_ROOT?.trim();\n return fromEnv ? path.resolve(fromEnv) : path.resolve(process.cwd(), 'apps/mercato');\n}\n\nfunction readEnvValue(key: string): string | undefined {\n if (process.env[key]) return process.env[key];\n const candidatePaths = [\n path.resolve(resolveAppRoot(), '.env'),\n path.resolve(process.cwd(), 'apps/mercato/.env'),\n path.resolve(process.cwd(), '.env'),\n ];\n for (const envPath of candidatePaths) {\n try {\n const content = readFileSync(envPath, 'utf-8');\n const match = content.match(new RegExp(`^${key}=(.+)$`, 'm'));\n if (match?.[1]) return match[1].trim();\n } catch {\n continue;\n }\n }\n return undefined;\n}\n\nfunction resolveDatabaseUrl(): string {\n const url = readEnvValue('DATABASE_URL');\n if (!url) throw new Error('[internal] DATABASE_URL is not configured for AI Assistant integration DB fixtures');\n return url;\n}\n\nasync function withClient<T>(run: (client: Client) => Promise<T>): Promise<T> {\n const client = new Client({ connectionString: resolveDatabaseUrl() });\n await client.connect();\n try {\n return await run(client);\n } finally {\n await client.end();\n }\n}\n\nexport type SeededPendingActionStatus =\n | 'pending'\n | 'confirmed'\n | 'cancelled'\n | 'expired'\n | 'executing'\n | 'failed';\n\nexport interface SeedPendingActionInput {\n tenantId: string;\n organizationId?: string | null;\n createdByUserId: string;\n status?: SeededPendingActionStatus;\n agentId?: string;\n toolName?: string;\n /** Minutes from now until expiry. Negative => the row is already expired. */\n expiresInMinutes?: number;\n normalizedInput?: Record<string, unknown>;\n executionResult?: Record<string, unknown> | null;\n idempotencyKey?: string;\n}\n\nexport interface SeededPendingAction {\n id: string;\n idempotencyKey: string;\n}\n\n/**\n * Inserts an `ai_pending_actions` row directly. Returns the new id so the test\n * can act on it and clean it up. Defaults produce an actionable `pending` row\n * with a future TTL; override `status`/`expiresInMinutes`/`executionResult` to\n * cover the idempotency short-circuit and 409 error branches.\n */\nexport async function seedPendingActionInDb(input: SeedPendingActionInput): Promise<SeededPendingAction> {\n const idempotencyKey = input.idempotencyKey ?? `it-pending-${randomUUID()}`;\n const status = input.status ?? 'pending';\n const expiresInMinutes = input.expiresInMinutes ?? 60;\n const normalizedInput = JSON.stringify(input.normalizedInput ?? {});\n const executionResult =\n input.executionResult === undefined || input.executionResult === null\n ? null\n : JSON.stringify(input.executionResult);\n return withClient(async (client) => {\n const result = await client.query<{ id: string }>(\n `insert into ai_pending_actions\n (id, tenant_id, organization_id, agent_id, tool_name, normalized_input,\n field_diff, attachment_ids, idempotency_key, created_by_user_id, status,\n queue_mode, execution_result, created_at, expires_at)\n values\n (gen_random_uuid(), $1, $2, $3, $4, $5::jsonb,\n '[]'::jsonb, '[]'::jsonb, $6, $7, $8,\n 'inline', $9::jsonb, now(), now() + make_interval(mins => $10::int))\n returning id`,\n [\n input.tenantId,\n input.organizationId ?? null,\n input.agentId ?? 'it_agent.pending_fixture',\n input.toolName ?? 'it_tool.noop',\n normalizedInput,\n idempotencyKey,\n input.createdByUserId,\n status,\n executionResult,\n expiresInMinutes,\n ],\n );\n return { id: result.rows[0].id, idempotencyKey };\n });\n}\n\n/** Hard-deletes a seeded pending action row (best-effort test cleanup). */\nexport async function deletePendingActionInDb(id: string | null): Promise<void> {\n if (!id) return;\n await withClient(async (client) => {\n await client.query('delete from ai_pending_actions where id = $1', [id]);\n });\n}\n\n/**\n * Sweeps every per-agent override row for a tenant across the three override\n * tables. Required for `prompt-override` cleanup (no DELETE route) and used as a\n * belt-and-braces teardown for mutation-policy / loop overrides.\n */\nexport async function deleteAgentOverridesInDb(input: { tenantId: string; agentId: string }): Promise<void> {\n if (!input.tenantId || !input.agentId) return;\n await withClient(async (client) => {\n await client.query('delete from ai_agent_prompt_overrides where tenant_id = $1 and agent_id = $2', [\n input.tenantId,\n input.agentId,\n ]);\n await client.query('delete from ai_agent_mutation_policy_overrides where tenant_id = $1 and agent_id = $2', [\n input.tenantId,\n input.agentId,\n ]);\n await client.query('delete from ai_agent_runtime_overrides where tenant_id = $1 and agent_id = $2', [\n input.tenantId,\n input.agentId,\n ]);\n });\n}\n\n/** Hard-deletes any tenant model-allowlist rows (best-effort cleanup). */\nexport async function deleteTenantAllowlistInDb(tenantId: string | null): Promise<void> {\n if (!tenantId) return;\n await withClient(async (client) => {\n await client.query('delete from ai_tenant_model_allowlists where tenant_id = $1', [tenantId]);\n });\n}\n\n/**\n * Hard-deletes a conversation and its participant + message rows by the\n * client-facing `conversation_id`. The DELETE route only soft-deletes, so this\n * gives specs a true teardown for the rows they created.\n */\nexport async function deleteConversationCascadeInDb(input: {\n tenantId: string;\n conversationId: string;\n}): Promise<void> {\n if (!input.tenantId || !input.conversationId) return;\n await withClient(async (client) => {\n await client.query('delete from ai_chat_messages where tenant_id = $1 and conversation_id = $2', [\n input.tenantId,\n input.conversationId,\n ]);\n await client.query(\n 'delete from ai_chat_conversation_participants where tenant_id = $1 and conversation_id = $2',\n [input.tenantId, input.conversationId],\n );\n await client.query('delete from ai_chat_conversations where tenant_id = $1 and conversation_id = $2', [\n input.tenantId,\n input.conversationId,\n ]);\n });\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { readFileSync } from 'node:fs';\nimport { randomUUID } from 'node:crypto';\nimport path from 'node:path';\nimport { Client } from 'pg';\n\n/**\n * Direct-Postgres fixtures for AI Assistant integration specs.\n *\n * Mirrors the sanctioned pattern in\n * `@open-mercato/core/helpers/integration/dbFixtures` (raw SQL against\n * `DATABASE_URL`) for the surfaces that have NO public create route:\n * - `ai_pending_actions`: a pending mutation approval is only ever born from\n * the internal `prepareMutation` path during a real LLM agent turn. Seeding\n * the row directly keeps confirm/cancel/GET coverage deterministic and\n * provider-free.\n * - prompt overrides: the `prompt-override` route exposes no DELETE, so\n * versioned rows must be swept via SQL after a test.\n *\n * The app server and this helper MUST share one `DATABASE_URL`, so specs using\n * these helpers only run under the coherent app+DB harness\n * (`yarn test:integration` / `:ephemeral`), never against an arbitrary dev\n * server whose `DATABASE_URL` differs from `apps/mercato/.env`.\n */\n\nfunction resolveAppRoot(): string {\n const fromEnv = process.env.OM_TEST_APP_ROOT?.trim();\n return fromEnv ? path.resolve(fromEnv) : path.resolve(process.cwd(), 'apps/mercato');\n}\n\nfunction readEnvValue(key: string): string | undefined {\n if (process.env[key]) return process.env[key];\n const candidatePaths = [\n path.resolve(resolveAppRoot(), '.env'),\n path.resolve(process.cwd(), 'apps/mercato/.env'),\n path.resolve(process.cwd(), '.env'),\n ];\n for (const envPath of candidatePaths) {\n try {\n const content = readFileSync(envPath, 'utf-8');\n const match = content.match(new RegExp(`^${key}=(.+)$`, 'm'));\n if (match?.[1]) return match[1].trim();\n } catch {\n continue;\n }\n }\n return undefined;\n}\n\nfunction resolveDatabaseUrl(): string {\n const url = readEnvValue('DATABASE_URL');\n if (!url) throw new Error('[internal] DATABASE_URL is not configured for AI Assistant integration DB fixtures');\n return url;\n}\n\nasync function withClient<T>(run: (client: Client) => Promise<T>): Promise<T> {\n const client = new Client({ connectionString: resolveDatabaseUrl() });\n await client.connect();\n try {\n return await run(client);\n } finally {\n await client.end();\n }\n}\n\nexport type SeededPendingActionStatus =\n | 'pending'\n | 'confirmed'\n | 'cancelled'\n | 'expired'\n | 'executing'\n | 'failed';\n\nexport interface SeedPendingActionInput {\n tenantId: string;\n organizationId?: string | null;\n createdByUserId: string;\n status?: SeededPendingActionStatus;\n agentId?: string;\n toolName?: string;\n /** Minutes from now until expiry. Negative => the row is already expired. */\n expiresInMinutes?: number;\n normalizedInput?: Record<string, unknown>;\n executionResult?: Record<string, unknown> | null;\n idempotencyKey?: string;\n}\n\nexport interface SeededPendingAction {\n id: string;\n idempotencyKey: string;\n}\n\n/**\n * Inserts an `ai_pending_actions` row directly. Returns the new id so the test\n * can act on it and clean it up. Defaults produce an actionable `pending` row\n * with a future TTL; override `status`/`expiresInMinutes`/`executionResult` to\n * cover the idempotency short-circuit and 409 error branches.\n */\nexport async function seedPendingActionInDb(input: SeedPendingActionInput): Promise<SeededPendingAction> {\n const idempotencyKey = input.idempotencyKey ?? `it-pending-${randomUUID()}`;\n const status = input.status ?? 'pending';\n const expiresInMinutes = input.expiresInMinutes ?? 60;\n const normalizedInput = JSON.stringify(input.normalizedInput ?? {});\n const executionResult =\n input.executionResult === undefined || input.executionResult === null\n ? null\n : JSON.stringify(input.executionResult);\n return withClient(async (client) => {\n const result = await client.query<{ id: string }>(\n `insert into ai_pending_actions\n (id, tenant_id, organization_id, agent_id, tool_name, normalized_input,\n field_diff, attachment_ids, idempotency_key, created_by_user_id, status,\n queue_mode, execution_result, created_at, expires_at)\n values\n (gen_random_uuid(), $1, $2, $3, $4, $5::jsonb,\n '[]'::jsonb, '[]'::jsonb, $6, $7, $8,\n 'inline', $9::jsonb, now(), now() + make_interval(mins => $10::int))\n returning id`,\n [\n input.tenantId,\n input.organizationId ?? null,\n input.agentId ?? 'it_agent.pending_fixture',\n input.toolName ?? 'it_tool.noop',\n normalizedInput,\n idempotencyKey,\n input.createdByUserId,\n status,\n executionResult,\n expiresInMinutes,\n ],\n );\n return { id: result.rows[0].id, idempotencyKey };\n });\n}\n\n/** Hard-deletes a seeded pending action row (best-effort test cleanup). */\nexport async function deletePendingActionInDb(id: string | null): Promise<void> {\n if (!id) return;\n await withClient(async (client) => {\n await client.query('delete from ai_pending_actions where id = $1', [id]);\n });\n}\n\n/**\n * Sweeps every per-agent override row for a tenant across the three override\n * tables. Required for `prompt-override` cleanup (no DELETE route) and used as a\n * belt-and-braces teardown for mutation-policy / loop overrides.\n */\nexport async function deleteAgentOverridesInDb(input: { tenantId: string; agentId: string }): Promise<void> {\n if (!input.tenantId || !input.agentId) return;\n await withClient(async (client) => {\n await client.query('delete from ai_agent_prompt_overrides where tenant_id = $1 and agent_id = $2', [\n input.tenantId,\n input.agentId,\n ]);\n await client.query('delete from ai_agent_mutation_policy_overrides where tenant_id = $1 and agent_id = $2', [\n input.tenantId,\n input.agentId,\n ]);\n await client.query('delete from ai_agent_runtime_overrides where tenant_id = $1 and agent_id = $2', [\n input.tenantId,\n input.agentId,\n ]);\n });\n}\n\n/** Hard-deletes any tenant model-allowlist rows (best-effort cleanup). */\nexport async function deleteTenantAllowlistInDb(tenantId: string | null): Promise<void> {\n if (!tenantId) return;\n await withClient(async (client) => {\n await client.query('delete from ai_tenant_model_allowlists where tenant_id = $1', [tenantId]);\n });\n}\n\nexport interface SeedModerationFlagInput {\n tenantId: string;\n organizationId?: string | null;\n agentId?: string;\n userId?: string;\n providerId?: string;\n modelId?: string;\n /** Defaults to a single flagged `hate` category. */\n categories?: Record<string, { flagged: boolean; score: number }>;\n}\n\n/**\n * Inserts an `ai_moderation_flags` audit row directly. A real flag is only ever\n * born from the gate during a flagged LLM turn (needs a live moderation call),\n * so seeding the row keeps audit-listing + tenant-isolation coverage\n * deterministic and provider-free. Returns the new id for assertions/cleanup.\n */\nexport async function seedModerationFlagInDb(input: SeedModerationFlagInput): Promise<string> {\n const categories = JSON.stringify(input.categories ?? { hate: { flagged: true, score: 0.97 } });\n return withClient(async (client) => {\n const result = await client.query<{ id: string }>(\n `insert into ai_moderation_flags\n (id, tenant_id, organization_id, agent_id, user_id, provider_id, model_id, categories, created_at)\n values\n (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7::jsonb, now())\n returning id`,\n [\n input.tenantId,\n input.organizationId ?? null,\n input.agentId ?? 'it_agent.moderation_fixture',\n input.userId ?? `it-user-${randomUUID()}`,\n input.providerId ?? 'openai',\n input.modelId ?? 'gpt-5-mini',\n categories,\n ],\n );\n return result.rows[0].id;\n });\n}\n\n/** Hard-deletes every moderation-flag row for a tenant (best-effort cleanup). */\nexport async function deleteModerationFlagsInDb(tenantId: string | null): Promise<void> {\n if (!tenantId) return;\n await withClient(async (client) => {\n await client.query('delete from ai_moderation_flags where tenant_id = $1', [tenantId]);\n });\n}\n\n/**\n * Hard-deletes a conversation and its participant + message rows by the\n * client-facing `conversation_id`. The DELETE route only soft-deletes, so this\n * gives specs a true teardown for the rows they created.\n */\nexport async function deleteConversationCascadeInDb(input: {\n tenantId: string;\n conversationId: string;\n}): Promise<void> {\n if (!input.tenantId || !input.conversationId) return;\n await withClient(async (client) => {\n await client.query('delete from ai_chat_messages where tenant_id = $1 and conversation_id = $2', [\n input.tenantId,\n input.conversationId,\n ]);\n await client.query(\n 'delete from ai_chat_conversation_participants where tenant_id = $1 and conversation_id = $2',\n [input.tenantId, input.conversationId],\n );\n await client.query('delete from ai_chat_conversations where tenant_id = $1 and conversation_id = $2', [\n input.tenantId,\n input.conversationId,\n ]);\n });\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
resolveLoopBudgetPreset
|
|
12
12
|
} from "../../../lib/agent-runtime.js";
|
|
13
13
|
import { AgentPolicyError } from "../../../lib/agent-tools.js";
|
|
14
|
+
import { AiModerationBlockedError, AiModerationUnavailableError } from "../../../lib/moderation.js";
|
|
14
15
|
import { readBaseurlAllowlist, isBaseurlAllowlisted } from "../../../lib/baseurl-allowlist.js";
|
|
15
16
|
import {
|
|
16
17
|
canonicalProviderId,
|
|
@@ -549,6 +550,12 @@ async function POST(req) {
|
|
|
549
550
|
if (error instanceof AgentPolicyError) {
|
|
550
551
|
return jsonError(statusForDenyCode(error.code), error.message, error.code);
|
|
551
552
|
}
|
|
553
|
+
if (error instanceof AiModerationBlockedError) {
|
|
554
|
+
return jsonError(400, "Input rejected by the content safety filter.", "moderation_blocked");
|
|
555
|
+
}
|
|
556
|
+
if (error instanceof AiModerationUnavailableError) {
|
|
557
|
+
return jsonError(503, "Content safety check temporarily unavailable.", "moderation_unavailable");
|
|
558
|
+
}
|
|
552
559
|
logger.error("AI Chat Agent \u2014 Dispatch failure", { err: error });
|
|
553
560
|
return jsonError(
|
|
554
561
|
500,
|