@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.
Files changed (120) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +12 -29
  3. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js +80 -0
  4. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js.map +7 -0
  5. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js +124 -0
  6. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js.map +7 -0
  7. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js +30 -0
  8. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js.map +2 -2
  9. package/dist/modules/ai_assistant/api/ai/chat/route.js +7 -0
  10. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  11. package/dist/modules/ai_assistant/api/moderation-flags/route.js +121 -0
  12. package/dist/modules/ai_assistant/api/moderation-flags/route.js.map +7 -0
  13. package/dist/modules/ai_assistant/api/settings/allowlist/route.js +10 -10
  14. package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +2 -2
  15. package/dist/modules/ai_assistant/api/settings/route.js +34 -13
  16. package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
  17. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +121 -15
  18. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
  19. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +2 -2
  20. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +2 -2
  21. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js +139 -0
  22. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js.map +7 -0
  23. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js +10 -0
  24. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js.map +7 -0
  25. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js +26 -0
  26. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js.map +7 -0
  27. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +4 -4
  28. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  29. package/dist/modules/ai_assistant/data/entities.js +42 -0
  30. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  31. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +3 -1
  32. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
  33. package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js +44 -0
  34. package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js.map +7 -0
  35. package/dist/modules/ai_assistant/di.js +6 -2
  36. package/dist/modules/ai_assistant/di.js.map +2 -2
  37. package/dist/modules/ai_assistant/events.js +6 -0
  38. package/dist/modules/ai_assistant/events.js.map +2 -2
  39. package/dist/modules/ai_assistant/i18n/de.json +42 -0
  40. package/dist/modules/ai_assistant/i18n/en.json +42 -0
  41. package/dist/modules/ai_assistant/i18n/es.json +42 -0
  42. package/dist/modules/ai_assistant/i18n/ko.json +527 -0
  43. package/dist/modules/ai_assistant/i18n/pl.json +42 -0
  44. package/dist/modules/ai_assistant/lib/agent-runtime.js +158 -5
  45. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
  46. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  47. package/dist/modules/ai_assistant/lib/auth.js +6 -7
  48. package/dist/modules/ai_assistant/lib/auth.js.map +2 -2
  49. package/dist/modules/ai_assistant/lib/entity-graph.js +3 -3
  50. package/dist/modules/ai_assistant/lib/entity-graph.js.map +2 -2
  51. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +3 -0
  52. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
  53. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js +8 -1
  54. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +2 -2
  55. package/dist/modules/ai_assistant/lib/model-factory.js +6 -1
  56. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  57. package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js +43 -0
  58. package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js.map +7 -0
  59. package/dist/modules/ai_assistant/lib/moderation-policy.js +24 -0
  60. package/dist/modules/ai_assistant/lib/moderation-policy.js.map +7 -0
  61. package/dist/modules/ai_assistant/lib/moderation.js +109 -0
  62. package/dist/modules/ai_assistant/lib/moderation.js.map +7 -0
  63. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +2 -0
  64. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
  65. package/dist/modules/ai_assistant/lib/tool-search.js +4 -12
  66. package/dist/modules/ai_assistant/lib/tool-search.js.map +2 -2
  67. package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js +19 -0
  68. package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js.map +7 -0
  69. package/generated/entities/ai_agent_runtime_override/index.ts +1 -0
  70. package/generated/entities/ai_moderation_flag/index.ts +9 -0
  71. package/generated/entities.ids.generated.ts +2 -1
  72. package/generated/entity-fields-registry.ts +13 -1
  73. package/package.json +13 -12
  74. package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.ts +126 -0
  75. package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.ts +161 -0
  76. package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +48 -0
  77. package/src/modules/ai_assistant/api/ai/chat/route.ts +10 -0
  78. package/src/modules/ai_assistant/api/moderation-flags/__tests__/route.test.ts +122 -0
  79. package/src/modules/ai_assistant/api/moderation-flags/route.ts +137 -0
  80. package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +58 -8
  81. package/src/modules/ai_assistant/api/settings/allowlist/route.ts +10 -12
  82. package/src/modules/ai_assistant/api/settings/route.ts +33 -10
  83. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +161 -15
  84. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +2 -2
  85. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.tsx +186 -0
  86. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.ts +24 -0
  87. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.tsx +12 -0
  88. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +4 -4
  89. package/src/modules/ai_assistant/data/entities.ts +56 -0
  90. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +4 -0
  91. package/src/modules/ai_assistant/data/repositories/AiModerationFlagRepository.ts +79 -0
  92. package/src/modules/ai_assistant/di.ts +5 -1
  93. package/src/modules/ai_assistant/events.ts +21 -0
  94. package/src/modules/ai_assistant/i18n/de.json +42 -0
  95. package/src/modules/ai_assistant/i18n/en.json +42 -0
  96. package/src/modules/ai_assistant/i18n/es.json +42 -0
  97. package/src/modules/ai_assistant/i18n/ko.json +527 -0
  98. package/src/modules/ai_assistant/i18n/pl.json +42 -0
  99. package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +2 -5
  100. package/src/modules/ai_assistant/lib/__tests__/input-moderation-gate.test.ts +121 -0
  101. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +13 -0
  102. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +41 -0
  103. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +60 -0
  104. package/src/modules/ai_assistant/lib/__tests__/moderation-flag-recorder.test.ts +76 -0
  105. package/src/modules/ai_assistant/lib/__tests__/moderation-policy.test.ts +86 -0
  106. package/src/modules/ai_assistant/lib/__tests__/moderation.test.ts +114 -0
  107. package/src/modules/ai_assistant/lib/agent-runtime.ts +259 -0
  108. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +10 -0
  109. package/src/modules/ai_assistant/lib/auth.ts +6 -15
  110. package/src/modules/ai_assistant/lib/entity-graph.ts +3 -3
  111. package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +10 -0
  112. package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +32 -1
  113. package/src/modules/ai_assistant/lib/model-factory.ts +33 -0
  114. package/src/modules/ai_assistant/lib/moderation-flag-recorder.ts +75 -0
  115. package/src/modules/ai_assistant/lib/moderation-policy.ts +77 -0
  116. package/src/modules/ai_assistant/lib/moderation.ts +179 -0
  117. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +2 -0
  118. package/src/modules/ai_assistant/lib/tool-search.ts +4 -17
  119. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +388 -16
  120. package/src/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.ts +21 -0
@@ -1,2 +1,2 @@
1
- [build:ai-assistant] found 220 entry points
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
- **Major change**: Replaced 600+ individual tools with 3 meta-tools.
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
 
@@ -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": "AAAA,SAAS,MAAM,QAAQ,WAAW,yBAAyB;AAC3D,SAAS,YAAY,oBAAoB;AACzC,SAAS,eAAe,oBAAoB;AAC5C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAuBP,MAAM,WAAW;AACjB,MAAM,QAAQ;AASd,KAAK,SAAS,2DAA2D,MAAM;AAC7E,OAAK,yEAAyE,OAAO,EAAE,QAAQ,MAAM;AACnG,SAAK,KAAK;AACV,UAAM,aAAa,MAAM,aAAa,SAAS,OAAO;AACtD,UAAM,EAAE,SAAS,IAAI,cAAc,UAAU;AAK7C,UAAM,UAAU;AAEhB,QAAI;AACF,YAAM,KAAK,MAAM,WAAW,SAAS,OAAO,UAAU;AAAA,QACpD,OAAO;AAAA,QACP,MAAM,EAAE,SAAS,iBAAiB,KAAK;AAAA,MACzC,CAAC;AACD,aAAO,GAAG,OAAO,GAAG,sCAAsC,EAAE,KAAK,GAAG;AACpE,cAAQ,MAAM,aAAkD,EAAE,IAAI,eAAe,EAAE,KAAK,IAAI;AAEhG,YAAM,MAAM,MAAM,WAAW,SAAS,OAAO,UAAU;AAAA,QACrD,OAAO;AAAA,QACP,MAAM,EAAE,SAAS,iBAAiB,MAAM;AAAA,MAC1C,CAAC;AACD,aAAO,IAAI,OAAO,CAAC,EAAE,KAAK,GAAG;AAC7B,cAAQ,MAAM,aAAkD,GAAG,IAAI,eAAe,EAAE,KAAK,KAAK;AAElG,YAAM,UAAU,MAAM,WAAW,SAAS,OAAO,UAAU;AAAA,QACzD,OAAO;AAAA,QACP,MAAM,EAAE,SAAS,iBAAiB,KAAK;AAAA,MACzC,CAAC;AACD,aAAO,QAAQ,OAAO,CAAC,EAAE,KAAK,GAAG;AACjC,cAAQ,MAAM,aAAkD,OAAO,IAAI,eAAe,EAAE,SAAS;AAAA,IACvG,UAAE;AACA,YAAM,yBAAyB,EAAE,UAAU,QAAQ,CAAC,EAAE,MAAM,MAAM,MAAS;AAAA,IAC7E;AAAA,EACF,CAAC;AAED,OAAK,iFAAiF,OAAO,EAAE,QAAQ,MAAM;AAC3G,SAAK,KAAK;AACV,UAAM,aAAa,MAAM,aAAa,SAAS,OAAO;AACtD,UAAM,EAAE,SAAS,IAAI,cAAc,UAAU;AAC7C,UAAM,gBAAgB;AAEtB,QAAI,SAAS;AACb,QAAI;AACF,eAAS,MAAM,uBAAuB,EAAE,UAAU,SAAS,uBAAuB,QAAQ,eAAe,CAAC;AAC1G,YAAM,uBAAuB,EAAE,UAAU,eAAe,SAAS,uBAAuB,QAAQ,gBAAgB,CAAC;AAEjH,YAAM,MAAM,MAAM,WAAW,SAAS,OAAO,GAAG,KAAK,iBAAiB,EAAE,OAAO,WAAW,CAAC;AAC3F,aAAO,IAAI,OAAO,CAAC,EAAE,KAAK,GAAG;AAC7B,YAAM,OAAO,MAAM,aAA4B,GAAG;AAClD,aAAO,MAAM,OAAO,wBAAwB,EAAE,WAAW;AACzD,YAAM,OAAO,MAAM,SAAS,CAAC,GAAG,IAAI,CAAC,QAAQ,IAAI,EAAE;AACnD,aAAO,KAAK,2BAA2B,EAAE,UAAU,MAAM;AAEzD,iBAAW,OAAO,MAAM,SAAS,CAAC,GAAG;AACnC,eAAO,IAAI,QAAQ,EAAE,KAAK,QAAQ;AAAA,MACpC;AAAA,IACF,UAAE;AACA,YAAM,0BAA0B,QAAQ,EAAE,MAAM,MAAM,MAAS;AAC/D,YAAM,0BAA0B,aAAa,EAAE,MAAM,MAAM,MAAS;AAAA,IACtE;AAAA,EACF,CAAC;AAED,OAAK,iDAAiD,OAAO,EAAE,SAAS,QAAQ,MAAM;AACpF,UAAM,aAAa,MAAM,aAAa,SAAS,OAAO;AAGtD,UAAM,WAAW,MAAM,WAAW,SAAS,OAAO,GAAG,KAAK,iBAAiB,EAAE,OAAO,WAAW,CAAC;AAChG,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAClC,YAAQ,MAAM,aAAgC,QAAQ,IAAI,IAAI,EAAE,KAAK,kBAAkB;AAGvF,UAAM,gBAAgB,MAAM,aAAa,SAAS,UAAU;AAC5D,UAAM,SAAS,MAAM,WAAW,SAAS,OAAO,OAAO,EAAE,OAAO,cAAc,CAAC;AAC/E,WAAO,OAAO,OAAO,GAAG,uCAAuC,EAAE,KAAK,GAAG;AAEzE,UAAM,OAAO,MAAM,kBAAkB,WAAW,EAAE,QAAQ,CAAC;AAC3D,QAAI;AACF,YAAM,MAAM,MAAM,KAAK,MAAM,OAAO,EAAE,QAAQ,MAAM,CAAC;AACrD,aAAO,IAAI,OAAO,GAAG,4BAA4B,EAAE,KAAK,GAAG;AAAA,IAC7D,UAAE;AACA,YAAM,KAAK,QAAQ;AAAA,IACrB;AAAA,EACF,CAAC;AACH,CAAC;",
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,