@open-mercato/ai-assistant 0.9.1-develop.7499.1.fcec456e4e → 0.9.1-develop.7513.1.ae2715553f

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.
@@ -11,7 +11,7 @@ import { executePendingActionCancel } from "../../../../../lib/pending-action-ca
11
11
  const logger = createLogger("ai_assistant");
12
12
  const REQUIRED_FEATURE = "ai_assistant.view";
13
13
  const idParamSchema = z.object({
14
- id: z.string().trim().min(1, "id must be a non-empty string").max(128, "id exceeds the maximum length of 128 characters")
14
+ id: z.string().trim().pipe(z.guid("id must be a UUID"))
15
15
  });
16
16
  const bodySchema = z.object({
17
17
  reason: z.string().max(500, "reason must be at most 500 characters").optional()
@@ -32,7 +32,7 @@ const openApi = {
32
32
  }
33
33
  ],
34
34
  errors: [
35
- { status: 400, description: "Invalid cancel request body (unknown field, reason exceeds 500 chars, wrong type)." },
35
+ { status: 400, description: "The `:id` path parameter is not a UUID, or the cancel request body is invalid (unknown field, reason exceeds 500 chars, wrong type)." },
36
36
  { status: 401, description: "Unauthenticated caller." },
37
37
  { status: 403, description: "Caller lacks `ai_assistant.view`." },
38
38
  { status: 404, description: "Pending action not found in the caller scope." },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../src/modules/ai_assistant/api/ai/actions/%5Bid%5D/cancel/route.ts"],
4
- "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { AiPendingActionRepository } from '../../../../../data/repositories/AiPendingActionRepository'\nimport { hasRequiredFeatures } from '../../../../../lib/auth'\nimport { serializePendingActionForClient } from '../../../../../lib/pending-action-client'\nimport { checkStatusAndExpiry } from '../../../../../lib/pending-action-recheck'\nimport { executePendingActionCancel } from '../../../../../lib/pending-action-cancel'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * POST `/api/ai/actions/:id/cancel` \u2014 mutation approval gate cancel\n * endpoint (spec \u00A79.4, Step 5.9).\n *\n * Siblings the Step 5.8 confirm route: flips `pending \u2192 cancelled` and\n * emits `ai.action.cancelled`. The tool handler is NEVER invoked.\n *\n * The route re-uses only the `status + expiry + tenant-scope` guards\n * from `pending-action-recheck.ts` \u2014 the agent / tool / attachment /\n * record-version guards are confirm-only. A caller may cancel even when\n * they'd be blocked from confirming; cancelling does not touch data.\n *\n * Idempotency: calling this endpoint twice on an already-`cancelled`\n * row returns 200 with the current row without re-emitting the event.\n */\n\nconst REQUIRED_FEATURE = 'ai_assistant.view'\n\nconst idParamSchema = z.object({\n id: z\n .string()\n .trim()\n .min(1, 'id must be a non-empty string')\n .max(128, 'id exceeds the maximum length of 128 characters'),\n})\n\nconst bodySchema = z\n .object({\n reason: z\n .string()\n .max(500, 'reason must be at most 500 characters')\n .optional(),\n })\n .strict()\n .optional()\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'Pending action (mutation approval gate) cancel',\n methods: {\n POST: {\n operationId: 'aiAssistantCancelPendingAction',\n summary: 'Cancel an AI pending action without executing the wrapped tool.',\n description:\n 'Flips a pending AI action from `pending` to `cancelled` and emits the ' +\n '`ai.action.cancelled` event. The tool handler is never invoked. Idempotent: ' +\n 'a second call on a row already in `cancelled` status returns 200 with the ' +\n 'current row without re-emitting the event. Rows whose `expiresAt` is in the ' +\n 'past are flipped to `expired` and returned as 409 `expired` so the client can ' +\n 'surface the TTL loss instead of silently masking it as a cancellation.',\n responses: [\n {\n status: 200,\n description: 'Cancellation complete (or idempotent replay); body includes the serialized pending action with status `cancelled`.',\n mediaType: 'application/json',\n },\n ],\n errors: [\n { status: 400, description: 'Invalid cancel request body (unknown field, reason exceeds 500 chars, wrong type).' },\n { status: 401, description: 'Unauthenticated caller.' },\n { status: 403, description: 'Caller lacks `ai_assistant.view`.' },\n { status: 404, description: 'Pending action not found in the caller scope.' },\n { status: 409, description: 'Pending action is not in `pending` status (already confirmed/failed/executing) or has expired.' },\n { status: 500, description: 'Unexpected server failure during cancel.' },\n ],\n },\n },\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nfunction jsonError(\n status: number,\n message: string,\n code: string,\n extra?: Record<string, unknown>,\n): NextResponse {\n return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })\n}\n\nasync function readRequestBody(req: NextRequest): Promise<unknown> {\n try {\n const text = await req.text()\n if (!text || text.trim().length === 0) return undefined\n return JSON.parse(text)\n } catch {\n return Symbol.for('ai_assistant.cancel.bad_json')\n }\n}\n\nexport async function POST(req: NextRequest, context: RouteContext): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return jsonError(401, 'Unauthorized', 'unauthenticated')\n }\n\n const rawParams = await context.params\n const paramResult = idParamSchema.safeParse(rawParams)\n if (!paramResult.success) {\n return jsonError(400, 'Invalid pending action id.', 'validation_error', {\n issues: paramResult.error.issues,\n })\n }\n const pendingActionId = paramResult.data.id\n\n const rawBody = await readRequestBody(req)\n if (rawBody === Symbol.for('ai_assistant.cancel.bad_json')) {\n return jsonError(400, 'Invalid JSON body.', 'validation_error')\n }\n const bodyResult = bodySchema.safeParse(rawBody)\n if (!bodyResult.success) {\n return jsonError(400, 'Invalid cancel body.', 'validation_error', {\n issues: bodyResult.error.issues,\n })\n }\n const parsedBody = bodyResult.data ?? {}\n\n try {\n const container = await createRequestContainer()\n const rbacService = container.resolve<RbacService>('rbacService')\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {\n return jsonError(403, `Caller lacks required feature \"${REQUIRED_FEATURE}\".`, 'forbidden')\n }\n\n if (!auth.tenantId) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n const em = container.resolve<EntityManager>('em')\n const repo = new AiPendingActionRepository(em)\n const row = await repo.getById(pendingActionId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n })\n if (!row) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n // Idempotent replay: second cancel on an already-cancelled row returns\n // 200 with the current row and does NOT emit a second event.\n if (row.status === 'cancelled') {\n return NextResponse.json({\n ok: true,\n pendingAction: serializePendingActionForClient(row),\n })\n }\n\n const statusCheck = checkStatusAndExpiry(row)\n if (!statusCheck.ok) {\n // Expired short-circuit: flip to `expired` + emit `ai.action.expired`\n // so Step 5.12 does not race to do it. Return 409 so the client\n // surfaces the TTL loss rather than silently cancelling.\n if (statusCheck.code === 'expired') {\n const cancelResult = await executePendingActionCancel({\n action: row,\n ctx: {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n container,\n },\n repo,\n })\n return jsonError(409, statusCheck.message, 'expired', {\n pendingAction: serializePendingActionForClient(cancelResult.row),\n })\n }\n return jsonError(statusCheck.status, statusCheck.message, statusCheck.code, statusCheck.extra)\n }\n\n const cancelResult = await executePendingActionCancel({\n action: row,\n ctx: {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n container,\n },\n reason: parsedBody.reason,\n repo,\n })\n\n if (cancelResult.status === 'expired') {\n return jsonError(\n 409,\n 'Pending action has expired. The model must re-propose the mutation.',\n 'expired',\n { pendingAction: serializePendingActionForClient(cancelResult.row) },\n )\n }\n\n return NextResponse.json({\n ok: true,\n pendingAction: serializePendingActionForClient(cancelResult.row),\n })\n } catch (error) {\n logger.error('AI Pending Action CANCEL \u2014 Failure', { err: error })\n return jsonError(\n 500,\n error instanceof Error ? error.message : 'Failed to cancel pending action.',\n 'cancel_internal_error',\n )\n }\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,oBAAsC;AAC/C,SAAS,SAAS;AAGlB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AAEvC,SAAS,iCAAiC;AAC1C,SAAS,2BAA2B;AACpC,SAAS,uCAAuC;AAChD,SAAS,4BAA4B;AACrC,SAAS,kCAAkC;AAE3C,MAAM,SAAS,aAAa,cAAc;AAkB1C,MAAM,mBAAmB;AAEzB,MAAM,gBAAgB,EAAE,OAAO;AAAA,EAC7B,IAAI,EACD,OAAO,EACP,KAAK,EACL,IAAI,GAAG,+BAA+B,EACtC,IAAI,KAAK,iDAAiD;AAC/D,CAAC;AAED,MAAM,aAAa,EAChB,OAAO;AAAA,EACN,QAAQ,EACL,OAAO,EACP,IAAI,KAAK,uCAAuC,EAChD,SAAS;AACd,CAAC,EACA,OAAO,EACP,SAAS;AAEL,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,aAAa;AAAA,MACb,SAAS;AAAA,MACT,aACE;AAAA,MAMF,WAAW;AAAA,QACT;AAAA,UACE,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,WAAW;AAAA,QACb;AAAA,MACF;AAAA,MACA,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,qFAAqF;AAAA,QACjH,EAAE,QAAQ,KAAK,aAAa,0BAA0B;AAAA,QACtD,EAAE,QAAQ,KAAK,aAAa,oCAAoC;AAAA,QAChE,EAAE,QAAQ,KAAK,aAAa,gDAAgD;AAAA,QAC5E,EAAE,QAAQ,KAAK,aAAa,iGAAiG;AAAA,QAC7H,EAAE,QAAQ,KAAK,aAAa,2CAA2C;AAAA,MACzE;AAAA,IACF;AAAA,EACF;AACF;AAEO,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,gBAAgB,EAAE;AACjE;AAMA,SAAS,UACP,QACA,SACA,MACA,OACc;AACd,SAAO,aAAa,KAAK,EAAE,OAAO,SAAS,MAAM,GAAI,SAAS,CAAC,EAAG,GAAG,EAAE,OAAO,CAAC;AACjF;AAEA,eAAe,gBAAgB,KAAoC;AACjE,MAAI;AACF,UAAM,OAAO,MAAM,IAAI,KAAK;AAC5B,QAAI,CAAC,QAAQ,KAAK,KAAK,EAAE,WAAW,EAAG,QAAO;AAC9C,WAAO,KAAK,MAAM,IAAI;AAAA,EACxB,QAAQ;AACN,WAAO,uBAAO,IAAI,8BAA8B;AAAA,EAClD;AACF;AAEA,eAAsB,KAAK,KAAkB,SAA0C;AACrF,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM;AACT,WAAO,UAAU,KAAK,gBAAgB,iBAAiB;AAAA,EACzD;AAEA,QAAM,YAAY,MAAM,QAAQ;AAChC,QAAM,cAAc,cAAc,UAAU,SAAS;AACrD,MAAI,CAAC,YAAY,SAAS;AACxB,WAAO,UAAU,KAAK,8BAA8B,oBAAoB;AAAA,MACtE,QAAQ,YAAY,MAAM;AAAA,IAC5B,CAAC;AAAA,EACH;AACA,QAAM,kBAAkB,YAAY,KAAK;AAEzC,QAAM,UAAU,MAAM,gBAAgB,GAAG;AACzC,MAAI,YAAY,uBAAO,IAAI,8BAA8B,GAAG;AAC1D,WAAO,UAAU,KAAK,sBAAsB,kBAAkB;AAAA,EAChE;AACA,QAAM,aAAa,WAAW,UAAU,OAAO;AAC/C,MAAI,CAAC,WAAW,SAAS;AACvB,WAAO,UAAU,KAAK,wBAAwB,oBAAoB;AAAA,MAChE,QAAQ,WAAW,MAAM;AAAA,IAC3B,CAAC;AAAA,EACH;AACA,QAAM,aAAa,WAAW,QAAQ,CAAC;AAEvC,MAAI;AACF,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,cAAc,UAAU,QAAqB,aAAa;AAChE,UAAM,MAAM,MAAM,YAAY,QAAQ,KAAK,KAAK;AAAA,MAC9C,UAAU,KAAK;AAAA,MACf,gBAAgB,KAAK;AAAA,IACvB,CAAC;AAED,QAAI,CAAC,oBAAoB,CAAC,gBAAgB,GAAG,IAAI,UAAU,IAAI,cAAc,WAAW,GAAG;AACzF,aAAO,UAAU,KAAK,kCAAkC,gBAAgB,MAAM,WAAW;AAAA,IAC3F;AAEA,QAAI,CAAC,KAAK,UAAU;AAClB,aAAO;AAAA,QACL;AAAA,QACA,sBAAsB,eAAe;AAAA,QACrC;AAAA,MACF;AAAA,IACF;AAEA,UAAM,KAAK,UAAU,QAAuB,IAAI;AAChD,UAAM,OAAO,IAAI,0BAA0B,EAAE;AAC7C,UAAM,MAAM,MAAM,KAAK,QAAQ,iBAAiB;AAAA,MAC9C,UAAU,KAAK;AAAA,MACf,gBAAgB,KAAK,SAAS;AAAA,MAC9B,QAAQ,KAAK;AAAA,IACf,CAAC;AACD,QAAI,CAAC,KAAK;AACR,aAAO;AAAA,QACL;AAAA,QACA,sBAAsB,eAAe;AAAA,QACrC;AAAA,MACF;AAAA,IACF;AAIA,QAAI,IAAI,WAAW,aAAa;AAC9B,aAAO,aAAa,KAAK;AAAA,QACvB,IAAI;AAAA,QACJ,eAAe,gCAAgC,GAAG;AAAA,MACpD,CAAC;AAAA,IACH;AAEA,UAAM,cAAc,qBAAqB,GAAG;AAC5C,QAAI,CAAC,YAAY,IAAI;AAInB,UAAI,YAAY,SAAS,WAAW;AAClC,cAAMA,gBAAe,MAAM,2BAA2B;AAAA,UACpD,QAAQ;AAAA,UACR,KAAK;AAAA,YACH,UAAU,KAAK;AAAA,YACf,gBAAgB,KAAK,SAAS;AAAA,YAC9B,QAAQ,KAAK;AAAA,YACb;AAAA,UACF;AAAA,UACA;AAAA,QACF,CAAC;AACD,eAAO,UAAU,KAAK,YAAY,SAAS,WAAW;AAAA,UACpD,eAAe,gCAAgCA,cAAa,GAAG;AAAA,QACjE,CAAC;AAAA,MACH;AACA,aAAO,UAAU,YAAY,QAAQ,YAAY,SAAS,YAAY,MAAM,YAAY,KAAK;AAAA,IAC/F;AAEA,UAAM,eAAe,MAAM,2BAA2B;AAAA,MACpD,QAAQ;AAAA,MACR,KAAK;AAAA,QACH,UAAU,KAAK;AAAA,QACf,gBAAgB,KAAK,SAAS;AAAA,QAC9B,QAAQ,KAAK;AAAA,QACb;AAAA,MACF;AAAA,MACA,QAAQ,WAAW;AAAA,MACnB;AAAA,IACF,CAAC;AAED,QAAI,aAAa,WAAW,WAAW;AACrC,aAAO;AAAA,QACL;AAAA,QACA;AAAA,QACA;AAAA,QACA,EAAE,eAAe,gCAAgC,aAAa,GAAG,EAAE;AAAA,MACrE;AAAA,IACF;AAEA,WAAO,aAAa,KAAK;AAAA,MACvB,IAAI;AAAA,MACJ,eAAe,gCAAgC,aAAa,GAAG;AAAA,IACjE,CAAC;AAAA,EACH,SAAS,OAAO;AACd,WAAO,MAAM,2CAAsC,EAAE,KAAK,MAAM,CAAC;AACjE,WAAO;AAAA,MACL;AAAA,MACA,iBAAiB,QAAQ,MAAM,UAAU;AAAA,MACzC;AAAA,IACF;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { AiPendingActionRepository } from '../../../../../data/repositories/AiPendingActionRepository'\nimport { hasRequiredFeatures } from '../../../../../lib/auth'\nimport { serializePendingActionForClient } from '../../../../../lib/pending-action-client'\nimport { checkStatusAndExpiry } from '../../../../../lib/pending-action-recheck'\nimport { executePendingActionCancel } from '../../../../../lib/pending-action-cancel'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * POST `/api/ai/actions/:id/cancel` \u2014 mutation approval gate cancel\n * endpoint (spec \u00A79.4, Step 5.9).\n *\n * Siblings the Step 5.8 confirm route: flips `pending \u2192 cancelled` and\n * emits `ai.action.cancelled`. The tool handler is NEVER invoked.\n *\n * The route re-uses only the `status + expiry + tenant-scope` guards\n * from `pending-action-recheck.ts` \u2014 the agent / tool / attachment /\n * record-version guards are confirm-only. A caller may cancel even when\n * they'd be blocked from confirming; cancelling does not touch data.\n *\n * Idempotency: calling this endpoint twice on an already-`cancelled`\n * row returns 200 with the current row without re-emitting the event.\n */\n\nconst REQUIRED_FEATURE = 'ai_assistant.view'\n\nconst idParamSchema = z.object({\n id: z.string().trim().pipe(z.guid('id must be a UUID')),\n})\n\nconst bodySchema = z\n .object({\n reason: z\n .string()\n .max(500, 'reason must be at most 500 characters')\n .optional(),\n })\n .strict()\n .optional()\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'Pending action (mutation approval gate) cancel',\n methods: {\n POST: {\n operationId: 'aiAssistantCancelPendingAction',\n summary: 'Cancel an AI pending action without executing the wrapped tool.',\n description:\n 'Flips a pending AI action from `pending` to `cancelled` and emits the ' +\n '`ai.action.cancelled` event. The tool handler is never invoked. Idempotent: ' +\n 'a second call on a row already in `cancelled` status returns 200 with the ' +\n 'current row without re-emitting the event. Rows whose `expiresAt` is in the ' +\n 'past are flipped to `expired` and returned as 409 `expired` so the client can ' +\n 'surface the TTL loss instead of silently masking it as a cancellation.',\n responses: [\n {\n status: 200,\n description: 'Cancellation complete (or idempotent replay); body includes the serialized pending action with status `cancelled`.',\n mediaType: 'application/json',\n },\n ],\n errors: [\n { status: 400, description: 'The `:id` path parameter is not a UUID, or the cancel request body is invalid (unknown field, reason exceeds 500 chars, wrong type).' },\n { status: 401, description: 'Unauthenticated caller.' },\n { status: 403, description: 'Caller lacks `ai_assistant.view`.' },\n { status: 404, description: 'Pending action not found in the caller scope.' },\n { status: 409, description: 'Pending action is not in `pending` status (already confirmed/failed/executing) or has expired.' },\n { status: 500, description: 'Unexpected server failure during cancel.' },\n ],\n },\n },\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nfunction jsonError(\n status: number,\n message: string,\n code: string,\n extra?: Record<string, unknown>,\n): NextResponse {\n return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })\n}\n\nasync function readRequestBody(req: NextRequest): Promise<unknown> {\n try {\n const text = await req.text()\n if (!text || text.trim().length === 0) return undefined\n return JSON.parse(text)\n } catch {\n return Symbol.for('ai_assistant.cancel.bad_json')\n }\n}\n\nexport async function POST(req: NextRequest, context: RouteContext): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return jsonError(401, 'Unauthorized', 'unauthenticated')\n }\n\n const rawParams = await context.params\n const paramResult = idParamSchema.safeParse(rawParams)\n if (!paramResult.success) {\n return jsonError(400, 'Invalid pending action id.', 'validation_error', {\n issues: paramResult.error.issues,\n })\n }\n const pendingActionId = paramResult.data.id\n\n const rawBody = await readRequestBody(req)\n if (rawBody === Symbol.for('ai_assistant.cancel.bad_json')) {\n return jsonError(400, 'Invalid JSON body.', 'validation_error')\n }\n const bodyResult = bodySchema.safeParse(rawBody)\n if (!bodyResult.success) {\n return jsonError(400, 'Invalid cancel body.', 'validation_error', {\n issues: bodyResult.error.issues,\n })\n }\n const parsedBody = bodyResult.data ?? {}\n\n try {\n const container = await createRequestContainer()\n const rbacService = container.resolve<RbacService>('rbacService')\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {\n return jsonError(403, `Caller lacks required feature \"${REQUIRED_FEATURE}\".`, 'forbidden')\n }\n\n if (!auth.tenantId) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n const em = container.resolve<EntityManager>('em')\n const repo = new AiPendingActionRepository(em)\n const row = await repo.getById(pendingActionId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n })\n if (!row) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n // Idempotent replay: second cancel on an already-cancelled row returns\n // 200 with the current row and does NOT emit a second event.\n if (row.status === 'cancelled') {\n return NextResponse.json({\n ok: true,\n pendingAction: serializePendingActionForClient(row),\n })\n }\n\n const statusCheck = checkStatusAndExpiry(row)\n if (!statusCheck.ok) {\n // Expired short-circuit: flip to `expired` + emit `ai.action.expired`\n // so Step 5.12 does not race to do it. Return 409 so the client\n // surfaces the TTL loss rather than silently cancelling.\n if (statusCheck.code === 'expired') {\n const cancelResult = await executePendingActionCancel({\n action: row,\n ctx: {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n container,\n },\n repo,\n })\n return jsonError(409, statusCheck.message, 'expired', {\n pendingAction: serializePendingActionForClient(cancelResult.row),\n })\n }\n return jsonError(statusCheck.status, statusCheck.message, statusCheck.code, statusCheck.extra)\n }\n\n const cancelResult = await executePendingActionCancel({\n action: row,\n ctx: {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n container,\n },\n reason: parsedBody.reason,\n repo,\n })\n\n if (cancelResult.status === 'expired') {\n return jsonError(\n 409,\n 'Pending action has expired. The model must re-propose the mutation.',\n 'expired',\n { pendingAction: serializePendingActionForClient(cancelResult.row) },\n )\n }\n\n return NextResponse.json({\n ok: true,\n pendingAction: serializePendingActionForClient(cancelResult.row),\n })\n } catch (error) {\n logger.error('AI Pending Action CANCEL \u2014 Failure', { err: error })\n return jsonError(\n 500,\n error instanceof Error ? error.message : 'Failed to cancel pending action.',\n 'cancel_internal_error',\n )\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["cancelResult"]
7
7
  }
@@ -20,7 +20,7 @@ import { isKnownMutationPolicy } from "../../../../../lib/agent-policy.js";
20
20
  const logger = createLogger("ai_assistant");
21
21
  const REQUIRED_FEATURE = "ai_assistant.view";
22
22
  const idParamSchema = z.object({
23
- id: z.string().trim().min(1, "id must be a non-empty string").max(128, "id exceeds the maximum length of 128 characters")
23
+ id: z.string().trim().pipe(z.guid("id must be a UUID"))
24
24
  });
25
25
  const openApi = {
26
26
  tag: "AI Assistant",
@@ -38,6 +38,7 @@ const openApi = {
38
38
  }
39
39
  ],
40
40
  errors: [
41
+ { status: 400, description: "The `:id` path parameter is not a UUID." },
41
42
  { status: 401, description: "Unauthenticated caller." },
42
43
  { status: 403, description: "Caller lacks `ai_assistant.view`, a required agent feature, tool whitelist, or accesses attachments outside their tenant." },
43
44
  { status: 404, description: "Pending action or agent not found in the caller scope." },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../src/modules/ai_assistant/api/ai/actions/%5Bid%5D/confirm/route.ts"],
4
- "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { AiPendingActionRepository } from '../../../../../data/repositories/AiPendingActionRepository'\nimport { hasRequiredFeatures } from '../../../../../lib/auth'\nimport { serializePendingActionForClient } from '../../../../../lib/pending-action-client'\nimport { getAgent, loadAgentRegistry } from '../../../../../lib/agent-registry'\nimport { toolRegistry } from '../../../../../lib/tool-registry'\nimport { loadAllModuleTools } from '../../../../../lib/tool-loader'\nimport type { AiToolDefinition } from '../../../../../lib/types'\nimport {\n runPendingActionRechecks,\n type PendingActionRecheckResult,\n} from '../../../../../lib/pending-action-recheck'\nimport {\n executePendingActionConfirm,\n type PendingActionExecuteContext,\n} from '../../../../../lib/pending-action-executor'\nimport { AiAgentMutationPolicyOverrideRepository } from '../../../../../data/repositories/AiAgentMutationPolicyOverrideRepository'\nimport type { AiAgentMutationPolicy } from '../../../../../lib/ai-agent-definition'\nimport { isKnownMutationPolicy } from '../../../../../lib/agent-policy'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * POST `/api/ai/actions/:id/confirm` \u2014 mutation approval gate confirm\n * endpoint (spec \u00A79.4, Step 5.8).\n *\n * Invariant: every check from spec \u00A79.4 is re-run on the server before the\n * tool handler executes. The original model-proposed tool arguments are\n * re-parsed through the CURRENT tool `inputSchema` so that a schema\n * migration between propose and confirm surfaces as `schema_drift`\n * (412) instead of silently passing through invalid data.\n *\n * Idempotency: calling this endpoint twice on the same pending-action id\n * returns the prior execution result without re-invoking the handler.\n */\n\nconst REQUIRED_FEATURE = 'ai_assistant.view'\n\nconst idParamSchema = z.object({\n id: z\n .string()\n .trim()\n .min(1, 'id must be a non-empty string')\n .max(128, 'id exceeds the maximum length of 128 characters'),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'Pending action (mutation approval gate) confirm',\n methods: {\n POST: {\n operationId: 'aiAssistantConfirmPendingAction',\n summary: 'Confirm an AI pending action, re-running every server-side check before execution.',\n description:\n 'Re-verifies the full contract from spec \u00A79.4 (status, expiry, agent registration, ' +\n 'required features, mutation policy, tool whitelist, attachment tenant scope, record ' +\n 'version, and schema drift), flips the pending-action state machine to `executing`, ' +\n 'invokes the wrapped tool handler, and persists `executionResult`. Idempotent: a ' +\n 'second call on a row already in `confirmed` state returns the prior result without ' +\n 're-executing the handler.',\n responses: [\n {\n status: 200,\n description: 'Confirmation complete; body includes the serialized pending action and the mutation result.',\n mediaType: 'application/json',\n },\n ],\n errors: [\n { status: 401, description: 'Unauthenticated caller.' },\n { status: 403, description: 'Caller lacks `ai_assistant.view`, a required agent feature, tool whitelist, or accesses attachments outside their tenant.' },\n { status: 404, description: 'Pending action or agent not found in the caller scope.' },\n { status: 409, description: 'Pending action is not in `pending` status or has expired.' },\n { status: 412, description: 'Record version changed between propose and confirm, or the input schema no longer accepts the stored payload.' },\n { status: 500, description: 'Unexpected server failure during confirm.' },\n ],\n },\n },\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nfunction jsonError(\n status: number,\n message: string,\n code: string,\n extra?: Record<string, unknown>,\n): NextResponse {\n return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })\n}\n\nfunction fromRecheckFailure(result: PendingActionRecheckResult & { ok: false }): NextResponse {\n return jsonError(result.status, result.message, result.code, result.extra)\n}\n\nexport async function POST(req: NextRequest, context: RouteContext): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return jsonError(401, 'Unauthorized', 'unauthenticated')\n }\n\n const rawParams = await context.params\n const paramResult = idParamSchema.safeParse(rawParams)\n if (!paramResult.success) {\n return jsonError(400, 'Invalid pending action id.', 'validation_error', {\n issues: paramResult.error.issues,\n })\n }\n const pendingActionId = paramResult.data.id\n\n try {\n const container = await createRequestContainer()\n const rbacService = container.resolve<RbacService>('rbacService')\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {\n return jsonError(403, `Caller lacks required feature \"${REQUIRED_FEATURE}\".`, 'forbidden')\n }\n\n if (!auth.tenantId) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n const em = container.resolve<EntityManager>('em')\n const repo = new AiPendingActionRepository(em)\n const row = await repo.getById(pendingActionId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n })\n if (!row) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n // Idempotency short-circuit \u2014 a prior confirm already ran.\n if (row.status === 'confirmed' || row.status === 'failed') {\n const executionResult = row.executionResult ?? null\n return NextResponse.json({\n ok: row.status === 'confirmed',\n pendingAction: serializePendingActionForClient(row),\n mutationResult: executionResult,\n })\n }\n\n await loadAgentRegistry()\n await loadAllModuleTools()\n const agent = getAgent(row.agentId)\n const tool = toolRegistry.getTool(row.toolName) as AiToolDefinition | undefined\n\n const policyOverrideRepo = new AiAgentMutationPolicyOverrideRepository(em)\n const overrideRow = await policyOverrideRepo.get(row.agentId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n })\n const rawOverridePolicy = overrideRow?.mutationPolicy ?? null\n const mutationPolicyOverride: AiAgentMutationPolicy | null =\n rawOverridePolicy && isKnownMutationPolicy(rawOverridePolicy) ? rawOverridePolicy : null\n\n const recheckResult = await runPendingActionRechecks({\n action: row,\n agent,\n tool,\n ctx: {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n userFeatures: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n container,\n em,\n },\n mutationPolicyOverride,\n })\n if (!recheckResult.ok) {\n return fromRecheckFailure(recheckResult)\n }\n\n const executeCtx: PendingActionExecuteContext = {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n userFeatures: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n container,\n }\n\n const executed = await executePendingActionConfirm({\n action: row,\n agent: agent!,\n tool: tool!,\n ctx: executeCtx,\n repo,\n failedRecords: recheckResult.failedRecords ?? null,\n })\n\n return NextResponse.json({\n ok: executed.ok,\n pendingAction: serializePendingActionForClient(executed.action),\n mutationResult: executed.executionResult,\n })\n } catch (error) {\n logger.error('AI Pending Action CONFIRM \u2014 Failure', { err: error })\n return jsonError(\n 500,\n error instanceof Error ? error.message : 'Failed to confirm pending action.',\n 'confirm_internal_error',\n )\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { AiPendingActionRepository } from '../../../../../data/repositories/AiPendingActionRepository'\nimport { hasRequiredFeatures } from '../../../../../lib/auth'\nimport { serializePendingActionForClient } from '../../../../../lib/pending-action-client'\nimport { getAgent, loadAgentRegistry } from '../../../../../lib/agent-registry'\nimport { toolRegistry } from '../../../../../lib/tool-registry'\nimport { loadAllModuleTools } from '../../../../../lib/tool-loader'\nimport type { AiToolDefinition } from '../../../../../lib/types'\nimport {\n runPendingActionRechecks,\n type PendingActionRecheckResult,\n} from '../../../../../lib/pending-action-recheck'\nimport {\n executePendingActionConfirm,\n type PendingActionExecuteContext,\n} from '../../../../../lib/pending-action-executor'\nimport { AiAgentMutationPolicyOverrideRepository } from '../../../../../data/repositories/AiAgentMutationPolicyOverrideRepository'\nimport type { AiAgentMutationPolicy } from '../../../../../lib/ai-agent-definition'\nimport { isKnownMutationPolicy } from '../../../../../lib/agent-policy'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * POST `/api/ai/actions/:id/confirm` \u2014 mutation approval gate confirm\n * endpoint (spec \u00A79.4, Step 5.8).\n *\n * Invariant: every check from spec \u00A79.4 is re-run on the server before the\n * tool handler executes. The original model-proposed tool arguments are\n * re-parsed through the CURRENT tool `inputSchema` so that a schema\n * migration between propose and confirm surfaces as `schema_drift`\n * (412) instead of silently passing through invalid data.\n *\n * Idempotency: calling this endpoint twice on the same pending-action id\n * returns the prior execution result without re-invoking the handler.\n */\n\nconst REQUIRED_FEATURE = 'ai_assistant.view'\n\nconst idParamSchema = z.object({\n id: z.string().trim().pipe(z.guid('id must be a UUID')),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'Pending action (mutation approval gate) confirm',\n methods: {\n POST: {\n operationId: 'aiAssistantConfirmPendingAction',\n summary: 'Confirm an AI pending action, re-running every server-side check before execution.',\n description:\n 'Re-verifies the full contract from spec \u00A79.4 (status, expiry, agent registration, ' +\n 'required features, mutation policy, tool whitelist, attachment tenant scope, record ' +\n 'version, and schema drift), flips the pending-action state machine to `executing`, ' +\n 'invokes the wrapped tool handler, and persists `executionResult`. Idempotent: a ' +\n 'second call on a row already in `confirmed` state returns the prior result without ' +\n 're-executing the handler.',\n responses: [\n {\n status: 200,\n description: 'Confirmation complete; body includes the serialized pending action and the mutation result.',\n mediaType: 'application/json',\n },\n ],\n errors: [\n { status: 400, description: 'The `:id` path parameter is not a UUID.' },\n { status: 401, description: 'Unauthenticated caller.' },\n { status: 403, description: 'Caller lacks `ai_assistant.view`, a required agent feature, tool whitelist, or accesses attachments outside their tenant.' },\n { status: 404, description: 'Pending action or agent not found in the caller scope.' },\n { status: 409, description: 'Pending action is not in `pending` status or has expired.' },\n { status: 412, description: 'Record version changed between propose and confirm, or the input schema no longer accepts the stored payload.' },\n { status: 500, description: 'Unexpected server failure during confirm.' },\n ],\n },\n },\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nfunction jsonError(\n status: number,\n message: string,\n code: string,\n extra?: Record<string, unknown>,\n): NextResponse {\n return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })\n}\n\nfunction fromRecheckFailure(result: PendingActionRecheckResult & { ok: false }): NextResponse {\n return jsonError(result.status, result.message, result.code, result.extra)\n}\n\nexport async function POST(req: NextRequest, context: RouteContext): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return jsonError(401, 'Unauthorized', 'unauthenticated')\n }\n\n const rawParams = await context.params\n const paramResult = idParamSchema.safeParse(rawParams)\n if (!paramResult.success) {\n return jsonError(400, 'Invalid pending action id.', 'validation_error', {\n issues: paramResult.error.issues,\n })\n }\n const pendingActionId = paramResult.data.id\n\n try {\n const container = await createRequestContainer()\n const rbacService = container.resolve<RbacService>('rbacService')\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {\n return jsonError(403, `Caller lacks required feature \"${REQUIRED_FEATURE}\".`, 'forbidden')\n }\n\n if (!auth.tenantId) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n const em = container.resolve<EntityManager>('em')\n const repo = new AiPendingActionRepository(em)\n const row = await repo.getById(pendingActionId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n })\n if (!row) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n // Idempotency short-circuit \u2014 a prior confirm already ran.\n if (row.status === 'confirmed' || row.status === 'failed') {\n const executionResult = row.executionResult ?? null\n return NextResponse.json({\n ok: row.status === 'confirmed',\n pendingAction: serializePendingActionForClient(row),\n mutationResult: executionResult,\n })\n }\n\n await loadAgentRegistry()\n await loadAllModuleTools()\n const agent = getAgent(row.agentId)\n const tool = toolRegistry.getTool(row.toolName) as AiToolDefinition | undefined\n\n const policyOverrideRepo = new AiAgentMutationPolicyOverrideRepository(em)\n const overrideRow = await policyOverrideRepo.get(row.agentId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n })\n const rawOverridePolicy = overrideRow?.mutationPolicy ?? null\n const mutationPolicyOverride: AiAgentMutationPolicy | null =\n rawOverridePolicy && isKnownMutationPolicy(rawOverridePolicy) ? rawOverridePolicy : null\n\n const recheckResult = await runPendingActionRechecks({\n action: row,\n agent,\n tool,\n ctx: {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n userFeatures: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n container,\n em,\n },\n mutationPolicyOverride,\n })\n if (!recheckResult.ok) {\n return fromRecheckFailure(recheckResult)\n }\n\n const executeCtx: PendingActionExecuteContext = {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n userFeatures: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n container,\n }\n\n const executed = await executePendingActionConfirm({\n action: row,\n agent: agent!,\n tool: tool!,\n ctx: executeCtx,\n repo,\n failedRecords: recheckResult.failedRecords ?? null,\n })\n\n return NextResponse.json({\n ok: executed.ok,\n pendingAction: serializePendingActionForClient(executed.action),\n mutationResult: executed.executionResult,\n })\n } catch (error) {\n logger.error('AI Pending Action CONFIRM \u2014 Failure', { err: error })\n return jsonError(\n 500,\n error instanceof Error ? error.message : 'Failed to confirm pending action.',\n 'confirm_internal_error',\n )\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -9,7 +9,7 @@ import { serializePendingActionForClient } from "../../../../lib/pending-action-
9
9
  const logger = createLogger("ai_assistant");
10
10
  const REQUIRED_FEATURE = "ai_assistant.view";
11
11
  const idParamSchema = z.object({
12
- id: z.string().trim().min(1, "id must be a non-empty string").max(128, "id exceeds the maximum length of 128 characters")
12
+ id: z.string().trim().pipe(z.guid("id must be a UUID"))
13
13
  });
14
14
  const openApi = {
15
15
  tag: "AI Assistant",
@@ -27,6 +27,7 @@ const openApi = {
27
27
  }
28
28
  ],
29
29
  errors: [
30
+ { status: 400, description: "The `:id` path parameter is not a UUID." },
30
31
  { status: 401, description: "Unauthenticated caller." },
31
32
  { status: 403, description: "Caller lacks the `ai_assistant.view` feature." },
32
33
  { status: 404, description: "No pending action with the given id is accessible to the caller." },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../src/modules/ai_assistant/api/ai/actions/%5Bid%5D/route.ts"],
4
- "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { AiPendingActionRepository } from '../../../../data/repositories/AiPendingActionRepository'\nimport { hasRequiredFeatures } from '../../../../lib/auth'\nimport { serializePendingActionForClient } from '../../../../lib/pending-action-client'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * GET `/api/ai/actions/:id` \u2014 reconnect/polling endpoint for the Phase 3 WS-C\n * mutation approval gate (spec \u00A79, Step 5.7).\n *\n * When the chat UI bounces (reload, tab switch, SSE reconnect) it carries a\n * `pendingActionId` from an earlier `mutation-preview-card` UI part and needs\n * to re-hydrate the pending row's current state. This route is the authoritative\n * read-side; the confirm/cancel routes (Steps 5.8 / 5.9) use the same\n * whitelist serializer so the UI always sees the same shape.\n */\n\nconst REQUIRED_FEATURE = 'ai_assistant.view'\n\nconst idParamSchema = z.object({\n id: z\n .string()\n .trim()\n .min(1, 'id must be a non-empty string')\n .max(128, 'id exceeds the maximum length of 128 characters'),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'Pending action (mutation approval gate) read-side',\n methods: {\n GET: {\n operationId: 'aiAssistantGetPendingAction',\n summary: 'Fetch the current state of an AI pending action by id.',\n description:\n 'Returns the tenant-scoped {@link AiPendingAction} addressed by `:id`. Powers the ' +\n 'chat UI reconnect/polling path: after a page reload or SSE reconnect the client ' +\n 'carries the `pendingActionId` from an earlier `mutation-preview-card` UI part and ' +\n 'calls this route to re-hydrate the card. Server-internal fields (`normalizedInput`, ' +\n '`createdByUserId`, `idempotencyKey`) are stripped by a whitelist serializer. ' +\n 'Enforces tenant/org scoping via the repository.',\n responses: [\n {\n status: 200,\n description:\n 'Serialized pending action. Never includes normalizedInput, createdByUserId, or idempotencyKey.',\n mediaType: 'application/json',\n },\n ],\n errors: [\n { status: 401, description: 'Unauthenticated caller.' },\n { status: 403, description: 'Caller lacks the `ai_assistant.view` feature.' },\n { status: 404, description: 'No pending action with the given id is accessible to the caller.' },\n { status: 500, description: 'Internal runtime failure.' },\n ],\n },\n },\n}\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nfunction jsonError(\n status: number,\n message: string,\n code: string,\n extra?: Record<string, unknown>,\n): NextResponse {\n return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })\n}\n\nexport async function GET(req: NextRequest, context: RouteContext): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return jsonError(401, 'Unauthorized', 'unauthenticated')\n }\n\n const rawParams = await context.params\n const paramResult = idParamSchema.safeParse(rawParams)\n if (!paramResult.success) {\n return jsonError(400, 'Invalid pending action id.', 'validation_error', {\n issues: paramResult.error.issues,\n })\n }\n const pendingActionId = paramResult.data.id\n\n try {\n const container = await createRequestContainer()\n const rbacService = container.resolve<RbacService>('rbacService')\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {\n return jsonError(403, `Caller lacks required feature \"${REQUIRED_FEATURE}\".`, 'forbidden')\n }\n\n if (!auth.tenantId) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n const em = container.resolve<EntityManager>('em')\n const repo = new AiPendingActionRepository(em)\n const row = await repo.getById(pendingActionId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n })\n\n if (!row) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n return NextResponse.json(serializePendingActionForClient(row))\n } catch (error) {\n logger.error('AI Pending Action GET \u2014 Failure', { err: error })\n return jsonError(\n 500,\n error instanceof Error ? error.message : 'Failed to load pending action.',\n 'internal_error',\n )\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { AiPendingActionRepository } from '../../../../data/repositories/AiPendingActionRepository'\nimport { hasRequiredFeatures } from '../../../../lib/auth'\nimport { serializePendingActionForClient } from '../../../../lib/pending-action-client'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * GET `/api/ai/actions/:id` \u2014 reconnect/polling endpoint for the Phase 3 WS-C\n * mutation approval gate (spec \u00A79, Step 5.7).\n *\n * When the chat UI bounces (reload, tab switch, SSE reconnect) it carries a\n * `pendingActionId` from an earlier `mutation-preview-card` UI part and needs\n * to re-hydrate the pending row's current state. This route is the authoritative\n * read-side; the confirm/cancel routes (Steps 5.8 / 5.9) use the same\n * whitelist serializer so the UI always sees the same shape.\n */\n\nconst REQUIRED_FEATURE = 'ai_assistant.view'\n\nconst idParamSchema = z.object({\n id: z.string().trim().pipe(z.guid('id must be a UUID')),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'Pending action (mutation approval gate) read-side',\n methods: {\n GET: {\n operationId: 'aiAssistantGetPendingAction',\n summary: 'Fetch the current state of an AI pending action by id.',\n description:\n 'Returns the tenant-scoped {@link AiPendingAction} addressed by `:id`. Powers the ' +\n 'chat UI reconnect/polling path: after a page reload or SSE reconnect the client ' +\n 'carries the `pendingActionId` from an earlier `mutation-preview-card` UI part and ' +\n 'calls this route to re-hydrate the card. Server-internal fields (`normalizedInput`, ' +\n '`createdByUserId`, `idempotencyKey`) are stripped by a whitelist serializer. ' +\n 'Enforces tenant/org scoping via the repository.',\n responses: [\n {\n status: 200,\n description:\n 'Serialized pending action. Never includes normalizedInput, createdByUserId, or idempotencyKey.',\n mediaType: 'application/json',\n },\n ],\n errors: [\n { status: 400, description: 'The `:id` path parameter is not a UUID.' },\n { status: 401, description: 'Unauthenticated caller.' },\n { status: 403, description: 'Caller lacks the `ai_assistant.view` feature.' },\n { status: 404, description: 'No pending action with the given id is accessible to the caller.' },\n { status: 500, description: 'Internal runtime failure.' },\n ],\n },\n },\n}\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nfunction jsonError(\n status: number,\n message: string,\n code: string,\n extra?: Record<string, unknown>,\n): NextResponse {\n return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })\n}\n\nexport async function GET(req: NextRequest, context: RouteContext): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return jsonError(401, 'Unauthorized', 'unauthenticated')\n }\n\n const rawParams = await context.params\n const paramResult = idParamSchema.safeParse(rawParams)\n if (!paramResult.success) {\n return jsonError(400, 'Invalid pending action id.', 'validation_error', {\n issues: paramResult.error.issues,\n })\n }\n const pendingActionId = paramResult.data.id\n\n try {\n const container = await createRequestContainer()\n const rbacService = container.resolve<RbacService>('rbacService')\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {\n return jsonError(403, `Caller lacks required feature \"${REQUIRED_FEATURE}\".`, 'forbidden')\n }\n\n if (!auth.tenantId) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n const em = container.resolve<EntityManager>('em')\n const repo = new AiPendingActionRepository(em)\n const row = await repo.getById(pendingActionId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n })\n\n if (!row) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n return NextResponse.json(serializePendingActionForClient(row))\n } catch (error) {\n logger.error('AI Pending Action GET \u2014 Failure', { err: error })\n return jsonError(\n 500,\n error instanceof Error ? error.message : 'Failed to load pending action.',\n 'internal_error',\n )\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ai-assistant",
3
- "version": "0.9.1-develop.7499.1.fcec456e4e",
3
+ "version": "0.9.1-develop.7513.1.ae2715553f",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -100,16 +100,16 @@
100
100
  "zod-to-json-schema": "^3.25.2"
101
101
  },
102
102
  "peerDependencies": {
103
- "@open-mercato/shared": "0.9.1-develop.7499.1.fcec456e4e",
104
- "@open-mercato/ui": "0.9.1-develop.7499.1.fcec456e4e",
103
+ "@open-mercato/shared": "0.9.1-develop.7513.1.ae2715553f",
104
+ "@open-mercato/ui": "0.9.1-develop.7513.1.ae2715553f",
105
105
  "react": "^19.0.0",
106
106
  "react-dom": "^19.0.0",
107
107
  "zod": ">=3.23.0"
108
108
  },
109
109
  "devDependencies": {
110
- "@open-mercato/cli": "0.9.1-develop.7499.1.fcec456e4e",
111
- "@open-mercato/shared": "0.9.1-develop.7499.1.fcec456e4e",
112
- "@open-mercato/ui": "0.9.1-develop.7499.1.fcec456e4e",
110
+ "@open-mercato/cli": "0.9.1-develop.7513.1.ae2715553f",
111
+ "@open-mercato/shared": "0.9.1-develop.7513.1.ae2715553f",
112
+ "@open-mercato/ui": "0.9.1-develop.7513.1.ae2715553f",
113
113
  "@types/react": "^19.2.18",
114
114
  "@types/react-dom": "^19.2.5",
115
115
  "react": "19.2.8",
@@ -19,8 +19,12 @@ jest.mock('../../../../../data/repositories/AiPendingActionRepository', () => ({
19
19
 
20
20
  import { GET } from '../route'
21
21
 
22
+ const PENDING_ACTION_ID = '3b7e1f2a-6c4d-4a9e-8f1b-2c3d4e5f6a7b'
23
+ const MISSING_PENDING_ACTION_ID = '5e2b8c71-9d4a-4f3e-8b6c-7a1d2e3f4b5c'
24
+ const OTHER_TENANT_PENDING_ACTION_ID = '7f3c9a40-2b1d-4e6f-9a8b-1c2d3e4f5a6b'
25
+
22
26
  function buildRequest(): Request {
23
- return new Request('http://localhost/api/ai_assistant/ai/actions/pa_123', {
27
+ return new Request(`http://localhost/api/ai_assistant/ai/actions/${PENDING_ACTION_ID}`, {
24
28
  method: 'GET',
25
29
  headers: { 'content-type': 'application/json' },
26
30
  })
@@ -32,7 +36,7 @@ function buildContext(id: string) {
32
36
 
33
37
  function makeRow(overrides: Record<string, unknown> = {}) {
34
38
  return {
35
- id: 'pa_123',
39
+ id: PENDING_ACTION_ID,
36
40
  agentId: 'catalog.merchandising_assistant',
37
41
  toolName: 'catalog.update_product',
38
42
  status: 'pending',
@@ -99,7 +103,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
99
103
  it('returns 401 when unauthenticated', async () => {
100
104
  authMock.mockResolvedValueOnce(null)
101
105
 
102
- const response = await GET(buildRequest() as any, buildContext('pa_123'))
106
+ const response = await GET(buildRequest() as any, buildContext(PENDING_ACTION_ID))
103
107
 
104
108
  expect(response.status).toBe(401)
105
109
  // 401 envelope does include `code: 'unauthenticated'` in this module's
@@ -113,11 +117,11 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
113
117
  it('happy path: tenant-matching id returns 200 + serialized row', async () => {
114
118
  repoGetByIdMock.mockResolvedValueOnce(makeRow())
115
119
 
116
- const response = await GET(buildRequest() as any, buildContext('pa_123'))
120
+ const response = await GET(buildRequest() as any, buildContext(PENDING_ACTION_ID))
117
121
 
118
122
  expect(response.status).toBe(200)
119
123
  const body = await response.json()
120
- expect(body.id).toBe('pa_123')
124
+ expect(body.id).toBe(PENDING_ACTION_ID)
121
125
  expect(body.agentId).toBe('catalog.merchandising_assistant')
122
126
  expect(body.status).toBe('pending')
123
127
  expect(body.toolName).toBe('catalog.update_product')
@@ -130,7 +134,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
130
134
 
131
135
  // Repository was called with the tenant/org scope from the auth context.
132
136
  expect(repoGetByIdMock).toHaveBeenCalledTimes(1)
133
- expect(repoGetByIdMock).toHaveBeenCalledWith('pa_123', {
137
+ expect(repoGetByIdMock).toHaveBeenCalledWith(PENDING_ACTION_ID, {
134
138
  tenantId: 'tenant-1',
135
139
  organizationId: 'org-1',
136
140
  userId: 'user-1',
@@ -142,7 +146,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
142
146
  // to a different tenant/org.
143
147
  repoGetByIdMock.mockResolvedValueOnce(null)
144
148
 
145
- const response = await GET(buildRequest() as any, buildContext('pa_from_other_tenant'))
149
+ const response = await GET(buildRequest() as any, buildContext(OTHER_TENANT_PENDING_ACTION_ID))
146
150
 
147
151
  expect(response.status).toBe(404)
148
152
  const body = await response.json()
@@ -152,7 +156,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
152
156
  it('returns 404 when the id is unknown (no row)', async () => {
153
157
  repoGetByIdMock.mockResolvedValueOnce(null)
154
158
 
155
- const response = await GET(buildRequest() as any, buildContext('pa_missing'))
159
+ const response = await GET(buildRequest() as any, buildContext(MISSING_PENDING_ACTION_ID))
156
160
 
157
161
  expect(response.status).toBe(404)
158
162
  const body = await response.json()
@@ -165,7 +169,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
165
169
  isSuperAdmin: false,
166
170
  })
167
171
 
168
- const response = await GET(buildRequest() as any, buildContext('pa_123'))
172
+ const response = await GET(buildRequest() as any, buildContext(PENDING_ACTION_ID))
169
173
 
170
174
  expect(response.status).toBe(403)
171
175
  const body = await response.json()
@@ -176,7 +180,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
176
180
  it('internal fields are never present in the response body', async () => {
177
181
  repoGetByIdMock.mockResolvedValueOnce(makeRow())
178
182
 
179
- const response = await GET(buildRequest() as any, buildContext('pa_123'))
183
+ const response = await GET(buildRequest() as any, buildContext(PENDING_ACTION_ID))
180
184
 
181
185
  expect(response.status).toBe(200)
182
186
  const body = await response.json()
@@ -195,6 +199,19 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
195
199
  expect(body.code).toBe('validation_error')
196
200
  })
197
201
 
202
+ it.each([
203
+ ['a non-UUID string', 'not-a-uuid'],
204
+ ['a 128-character string', 'a'.repeat(128)],
205
+ ])('returns 400 validation_error without querying the database for %s', async (_label, id) => {
206
+ const response = await GET(buildRequest() as any, buildContext(id))
207
+
208
+ expect(response.status).toBe(400)
209
+ const body = await response.json()
210
+ expect(body.code).toBe('validation_error')
211
+ expect(JSON.stringify(body)).not.toContain('invalid input syntax')
212
+ expect(repoGetByIdMock).not.toHaveBeenCalled()
213
+ })
214
+
198
215
  it('returns 404 when the caller has no tenant scope', async () => {
199
216
  authMock.mockResolvedValueOnce({
200
217
  sub: 'user-1',
@@ -202,7 +219,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
202
219
  orgId: null,
203
220
  })
204
221
 
205
- const response = await GET(buildRequest() as any, buildContext('pa_123'))
222
+ const response = await GET(buildRequest() as any, buildContext(PENDING_ACTION_ID))
206
223
 
207
224
  expect(response.status).toBe(404)
208
225
  const body = await response.json()
@@ -213,7 +230,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
213
230
  it('returns 500 when the repository throws', async () => {
214
231
  repoGetByIdMock.mockRejectedValueOnce(new Error('db down'))
215
232
 
216
- const response = await GET(buildRequest() as any, buildContext('pa_123'))
233
+ const response = await GET(buildRequest() as any, buildContext(PENDING_ACTION_ID))
217
234
 
218
235
  expect(response.status).toBe(500)
219
236
  const body = await response.json()
@@ -23,6 +23,9 @@ jest.mock('../../../../../../data/repositories/AiPendingActionRepository', () =>
23
23
  import { setGlobalEventBus } from '@open-mercato/shared/modules/events'
24
24
  import { POST } from '../route'
25
25
 
26
+ const PENDING_ACTION_ID = '3b7e1f2a-6c4d-4a9e-8f1b-2c3d4e5f6a7b'
27
+ const MISSING_PENDING_ACTION_ID = '5e2b8c71-9d4a-4f3e-8b6c-7a1d2e3f4b5c'
28
+
26
29
  function buildRequest(body?: unknown): Request {
27
30
  const init: RequestInit = {
28
31
  method: 'POST',
@@ -31,7 +34,7 @@ function buildRequest(body?: unknown): Request {
31
34
  if (body !== undefined) {
32
35
  init.body = typeof body === 'string' ? body : JSON.stringify(body)
33
36
  }
34
- return new Request('http://localhost/api/ai_assistant/ai/actions/pa_123/cancel', init)
37
+ return new Request(`http://localhost/api/ai_assistant/ai/actions/${PENDING_ACTION_ID}/cancel`, init)
35
38
  }
36
39
 
37
40
  function buildContext(id: string) {
@@ -40,7 +43,7 @@ function buildContext(id: string) {
40
43
 
41
44
  function makeRow(overrides: Record<string, unknown> = {}) {
42
45
  return {
43
- id: 'pa_123',
46
+ id: PENDING_ACTION_ID,
44
47
  tenantId: 'tenant-1',
45
48
  organizationId: 'org-1',
46
49
  agentId: 'catalog.merchandising_assistant',
@@ -127,14 +130,14 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
127
130
 
128
131
  it('returns 401 when unauthenticated', async () => {
129
132
  authMock.mockResolvedValueOnce(null)
130
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
133
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
131
134
  expect(response.status).toBe(401)
132
135
  })
133
136
 
134
137
  it('happy path: pending → cancelled returns 200 with pendingAction.status === cancelled', async () => {
135
138
  repoGetByIdMock.mockResolvedValueOnce(makeRow())
136
139
 
137
- const response = await POST(buildRequest({ reason: 'Wrong price' }) as any, buildContext('pa_123'))
140
+ const response = await POST(buildRequest({ reason: 'Wrong price' }) as any, buildContext(PENDING_ACTION_ID))
138
141
  expect(response.status).toBe(200)
139
142
  const body = await response.json()
140
143
  expect(body.ok).toBe(true)
@@ -159,7 +162,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
159
162
  })
160
163
  repoGetByIdMock.mockResolvedValueOnce(cancelledRow)
161
164
 
162
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
165
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
163
166
  expect(response.status).toBe(200)
164
167
  const body = await response.json()
165
168
  expect(body.ok).toBe(true)
@@ -172,7 +175,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
172
175
  const expiredRow = makeRow({ expiresAt: new Date('2020-01-01T00:00:00.000Z') })
173
176
  repoGetByIdMock.mockResolvedValueOnce(expiredRow)
174
177
 
175
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
178
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
176
179
  expect(response.status).toBe(409)
177
180
  const body = await response.json()
178
181
  expect(body.code).toBe('expired')
@@ -187,7 +190,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
187
190
  it('409 invalid_status: already confirmed', async () => {
188
191
  repoGetByIdMock.mockResolvedValueOnce(makeRow({ status: 'confirmed' }))
189
192
 
190
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
193
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
191
194
  expect(response.status).toBe(409)
192
195
  const body = await response.json()
193
196
  expect(body.code).toBe('invalid_status')
@@ -198,7 +201,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
198
201
  it('409 invalid_status: already executing', async () => {
199
202
  repoGetByIdMock.mockResolvedValueOnce(makeRow({ status: 'executing' }))
200
203
 
201
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
204
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
202
205
  expect(response.status).toBe(409)
203
206
  const body = await response.json()
204
207
  expect(body.code).toBe('invalid_status')
@@ -207,16 +210,30 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
207
210
  it('409 invalid_status: already failed', async () => {
208
211
  repoGetByIdMock.mockResolvedValueOnce(makeRow({ status: 'failed' }))
209
212
 
210
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
213
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
211
214
  expect(response.status).toBe(409)
212
215
  const body = await response.json()
213
216
  expect(body.code).toBe('invalid_status')
214
217
  })
215
218
 
219
+ it.each([
220
+ ['a non-UUID string', 'not-a-uuid'],
221
+ ['a 128-character string', 'a'.repeat(128)],
222
+ ])('returns 400 validation_error without querying the database for %s', async (_label, id) => {
223
+ const response = await POST(buildRequest() as any, buildContext(id))
224
+
225
+ expect(response.status).toBe(400)
226
+ const body = await response.json()
227
+ expect(body.code).toBe('validation_error')
228
+ expect(JSON.stringify(body)).not.toContain('invalid input syntax')
229
+ expect(repoGetByIdMock).not.toHaveBeenCalled()
230
+ expect(repoSetStatusMock).not.toHaveBeenCalled()
231
+ })
232
+
216
233
  it('404 pending_action_not_found for cross-tenant / unknown id', async () => {
217
234
  repoGetByIdMock.mockResolvedValueOnce(null)
218
235
 
219
- const response = await POST(buildRequest() as any, buildContext('pa_missing'))
236
+ const response = await POST(buildRequest() as any, buildContext(MISSING_PENDING_ACTION_ID))
220
237
  expect(response.status).toBe(404)
221
238
  const body = await response.json()
222
239
  expect(body.code).toBe('pending_action_not_found')
@@ -226,7 +243,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
226
243
  it('403 forbidden when caller lacks ai_assistant.view', async () => {
227
244
  loadAclMock.mockResolvedValueOnce({ features: ['catalog.view'], isSuperAdmin: false })
228
245
 
229
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
246
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
230
247
  expect(response.status).toBe(403)
231
248
  const body = await response.json()
232
249
  expect(body.code).toBe('forbidden')
@@ -236,7 +253,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
236
253
  it('whitespace-only reason becomes empty → default "Cancelled by user" message', async () => {
237
254
  repoGetByIdMock.mockResolvedValueOnce(makeRow())
238
255
 
239
- const response = await POST(buildRequest({ reason: ' \t\n ' }) as any, buildContext('pa_123'))
256
+ const response = await POST(buildRequest({ reason: ' \t\n ' }) as any, buildContext(PENDING_ACTION_ID))
240
257
  expect(response.status).toBe(200)
241
258
  const body = await response.json()
242
259
  expect(body.pendingAction.executionResult).toEqual({
@@ -246,7 +263,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
246
263
 
247
264
  it('400 validation_error when reason exceeds 500 characters', async () => {
248
265
  const longReason = 'x'.repeat(501)
249
- const response = await POST(buildRequest({ reason: longReason }) as any, buildContext('pa_123'))
266
+ const response = await POST(buildRequest({ reason: longReason }) as any, buildContext(PENDING_ACTION_ID))
250
267
  expect(response.status).toBe(400)
251
268
  const body = await response.json()
252
269
  expect(body.code).toBe('validation_error')
@@ -256,7 +273,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
256
273
  it('400 validation_error when body contains unknown fields', async () => {
257
274
  const response = await POST(
258
275
  buildRequest({ reason: 'ok', evil: 'payload' }) as any,
259
- buildContext('pa_123'),
276
+ buildContext(PENDING_ACTION_ID),
260
277
  )
261
278
  expect(response.status).toBe(400)
262
279
  const body = await response.json()
@@ -266,7 +283,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
266
283
  it('accepts an empty body (no reason)', async () => {
267
284
  repoGetByIdMock.mockResolvedValueOnce(makeRow())
268
285
 
269
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
286
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
270
287
  expect(response.status).toBe(200)
271
288
  const body = await response.json()
272
289
  expect(body.ok).toBe(true)
@@ -278,7 +295,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
278
295
  it('500 cancel_internal_error when the repo throws unexpectedly', async () => {
279
296
  repoGetByIdMock.mockRejectedValueOnce(new Error('db down'))
280
297
 
281
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
298
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
282
299
  expect(response.status).toBe(500)
283
300
  const body = await response.json()
284
301
  expect(body.code).toBe('cancel_internal_error')
@@ -33,11 +33,7 @@ const logger = createLogger('ai_assistant')
33
33
  const REQUIRED_FEATURE = 'ai_assistant.view'
34
34
 
35
35
  const idParamSchema = z.object({
36
- id: z
37
- .string()
38
- .trim()
39
- .min(1, 'id must be a non-empty string')
40
- .max(128, 'id exceeds the maximum length of 128 characters'),
36
+ id: z.string().trim().pipe(z.guid('id must be a UUID')),
41
37
  })
42
38
 
43
39
  const bodySchema = z
@@ -72,7 +68,7 @@ export const openApi: OpenApiRouteDoc = {
72
68
  },
73
69
  ],
74
70
  errors: [
75
- { status: 400, description: 'Invalid cancel request body (unknown field, reason exceeds 500 chars, wrong type).' },
71
+ { status: 400, description: 'The `:id` path parameter is not a UUID, or the cancel request body is invalid (unknown field, reason exceeds 500 chars, wrong type).' },
76
72
  { status: 401, description: 'Unauthenticated caller.' },
77
73
  { status: 403, description: 'Caller lacks `ai_assistant.view`.' },
78
74
  { status: 404, description: 'Pending action not found in the caller scope.' },
@@ -60,8 +60,11 @@ jest.mock('../../../../../../lib/tool-loader', () => ({
60
60
 
61
61
  import { POST } from '../route'
62
62
 
63
+ const PENDING_ACTION_ID = '3b7e1f2a-6c4d-4a9e-8f1b-2c3d4e5f6a7b'
64
+ const MISSING_PENDING_ACTION_ID = '5e2b8c71-9d4a-4f3e-8b6c-7a1d2e3f4b5c'
65
+
63
66
  function buildRequest(): Request {
64
- return new Request('http://localhost/api/ai_assistant/ai/actions/pa_123/confirm', {
67
+ return new Request(`http://localhost/api/ai_assistant/ai/actions/${PENDING_ACTION_ID}/confirm`, {
65
68
  method: 'POST',
66
69
  headers: { 'content-type': 'application/json' },
67
70
  })
@@ -73,7 +76,7 @@ function buildContext(id: string) {
73
76
 
74
77
  function makeRow(overrides: Record<string, unknown> = {}) {
75
78
  return {
76
- id: 'pa_123',
79
+ id: PENDING_ACTION_ID,
77
80
  tenantId: 'tenant-1',
78
81
  organizationId: 'org-1',
79
82
  agentId: 'catalog.merchandising_assistant',
@@ -183,14 +186,14 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
183
186
 
184
187
  it('returns 401 when unauthenticated', async () => {
185
188
  authMock.mockResolvedValueOnce(null)
186
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
189
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
187
190
  expect(response.status).toBe(401)
188
191
  })
189
192
 
190
193
  it('happy path: returns 200 with pendingAction.status === confirmed', async () => {
191
194
  repoGetByIdMock.mockResolvedValueOnce(makeRow())
192
195
 
193
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
196
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
194
197
  expect(response.status).toBe(200)
195
198
  const body = await response.json()
196
199
  expect(body.ok).toBe(true)
@@ -201,7 +204,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
201
204
  it('409 invalid_status: already cancelled', async () => {
202
205
  repoGetByIdMock.mockResolvedValueOnce(makeRow({ status: 'cancelled' }))
203
206
 
204
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
207
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
205
208
  expect(response.status).toBe(409)
206
209
  const body = await response.json()
207
210
  expect(body.code).toBe('invalid_status')
@@ -212,7 +215,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
212
215
  makeRow({ expiresAt: new Date('2020-01-01T00:00:00.000Z') }),
213
216
  )
214
217
 
215
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
218
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
216
219
  expect(response.status).toBe(409)
217
220
  const body = await response.json()
218
221
  expect(body.code).toBe('expired')
@@ -231,7 +234,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
231
234
  }),
232
235
  )
233
236
 
234
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
237
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
235
238
  expect(response.status).toBe(412)
236
239
  const body = await response.json()
237
240
  expect(body.code).toBe('stale_version')
@@ -241,7 +244,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
241
244
  repoGetByIdMock.mockResolvedValueOnce(makeRow())
242
245
  policyOverrideGetMock.mockResolvedValueOnce({ mutationPolicy: 'read-only' })
243
246
 
244
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
247
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
245
248
  expect(response.status).toBe(403)
246
249
  const body = await response.json()
247
250
  expect(body.code).toBe('read_only_agent')
@@ -251,7 +254,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
251
254
  repoGetByIdMock.mockResolvedValueOnce(makeRow())
252
255
  getAgentMock.mockReturnValueOnce(makeAgent({ allowedTools: [] }))
253
256
 
254
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
257
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
255
258
  expect(response.status).toBe(403)
256
259
  const body = await response.json()
257
260
  expect(body.code).toBe('tool_not_whitelisted')
@@ -261,7 +264,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
261
264
  repoGetByIdMock.mockResolvedValueOnce(makeRow())
262
265
  getAgentMock.mockReturnValueOnce(makeAgent({ requiredFeatures: ['catalog.restricted.manage'] }))
263
266
 
264
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
267
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
265
268
  expect(response.status).toBe(403)
266
269
  const body = await response.json()
267
270
  expect(body.code).toBe('agent_features_denied')
@@ -273,7 +276,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
273
276
  { id: 'a-1', tenantId: 'tenant-OTHER', organizationId: 'org-OTHER' },
274
277
  ])
275
278
 
276
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
279
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
277
280
  expect(response.status).toBe(403)
278
281
  const body = await response.json()
279
282
  expect(body.code).toBe('attachment_cross_tenant')
@@ -283,16 +286,29 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
283
286
  repoGetByIdMock.mockResolvedValueOnce(makeRow())
284
287
  getAgentMock.mockReturnValueOnce(undefined)
285
288
 
286
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
289
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
287
290
  expect(response.status).toBe(404)
288
291
  const body = await response.json()
289
292
  expect(body.code).toBe('agent_unknown')
290
293
  })
291
294
 
295
+ it.each([
296
+ ['a non-UUID string', 'not-a-uuid'],
297
+ ['a 128-character string', 'a'.repeat(128)],
298
+ ])('returns 400 validation_error without querying the database for %s', async (_label, id) => {
299
+ const response = await POST(buildRequest() as any, buildContext(id))
300
+
301
+ expect(response.status).toBe(400)
302
+ const body = await response.json()
303
+ expect(body.code).toBe('validation_error')
304
+ expect(JSON.stringify(body)).not.toContain('invalid input syntax')
305
+ expect(repoGetByIdMock).not.toHaveBeenCalled()
306
+ })
307
+
292
308
  it('404 pending_action_not_found when the row does not exist for the caller', async () => {
293
309
  repoGetByIdMock.mockResolvedValueOnce(null)
294
310
 
295
- const response = await POST(buildRequest() as any, buildContext('pa_missing'))
311
+ const response = await POST(buildRequest() as any, buildContext(MISSING_PENDING_ACTION_ID))
296
312
  expect(response.status).toBe(404)
297
313
  const body = await response.json()
298
314
  expect(body.code).toBe('pending_action_not_found')
@@ -301,7 +317,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
301
317
  it('403 forbidden when caller lacks ai_assistant.view', async () => {
302
318
  loadAclMock.mockResolvedValueOnce({ features: ['catalog.view'], isSuperAdmin: false })
303
319
 
304
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
320
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
305
321
  expect(response.status).toBe(403)
306
322
  const body = await response.json()
307
323
  expect(body.code).toBe('forbidden')
@@ -319,7 +335,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
319
335
  const handlerSpy = jest.fn()
320
336
  getToolMock.mockReturnValueOnce(makeTool({ handler: handlerSpy }))
321
337
 
322
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
338
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
323
339
  expect(response.status).toBe(200)
324
340
  const body = await response.json()
325
341
  expect(body.ok).toBe(true)
@@ -331,7 +347,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
331
347
  it('500 confirm_internal_error when the repo throws unexpectedly', async () => {
332
348
  repoGetByIdMock.mockRejectedValueOnce(new Error('db down'))
333
349
 
334
- const response = await POST(buildRequest() as any, buildContext('pa_123'))
350
+ const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
335
351
  expect(response.status).toBe(500)
336
352
  const body = await response.json()
337
353
  expect(body.code).toBe('confirm_internal_error')
@@ -44,11 +44,7 @@ const logger = createLogger('ai_assistant')
44
44
  const REQUIRED_FEATURE = 'ai_assistant.view'
45
45
 
46
46
  const idParamSchema = z.object({
47
- id: z
48
- .string()
49
- .trim()
50
- .min(1, 'id must be a non-empty string')
51
- .max(128, 'id exceeds the maximum length of 128 characters'),
47
+ id: z.string().trim().pipe(z.guid('id must be a UUID')),
52
48
  })
53
49
 
54
50
  export const openApi: OpenApiRouteDoc = {
@@ -73,6 +69,7 @@ export const openApi: OpenApiRouteDoc = {
73
69
  },
74
70
  ],
75
71
  errors: [
72
+ { status: 400, description: 'The `:id` path parameter is not a UUID.' },
76
73
  { status: 401, description: 'Unauthenticated caller.' },
77
74
  { status: 403, description: 'Caller lacks `ai_assistant.view`, a required agent feature, tool whitelist, or accesses attachments outside their tenant.' },
78
75
  { status: 404, description: 'Pending action or agent not found in the caller scope.' },
@@ -26,11 +26,7 @@ const logger = createLogger('ai_assistant')
26
26
  const REQUIRED_FEATURE = 'ai_assistant.view'
27
27
 
28
28
  const idParamSchema = z.object({
29
- id: z
30
- .string()
31
- .trim()
32
- .min(1, 'id must be a non-empty string')
33
- .max(128, 'id exceeds the maximum length of 128 characters'),
29
+ id: z.string().trim().pipe(z.guid('id must be a UUID')),
34
30
  })
35
31
 
36
32
  export const openApi: OpenApiRouteDoc = {
@@ -56,6 +52,7 @@ export const openApi: OpenApiRouteDoc = {
56
52
  },
57
53
  ],
58
54
  errors: [
55
+ { status: 400, description: 'The `:id` path parameter is not a UUID.' },
59
56
  { status: 401, description: 'Unauthenticated caller.' },
60
57
  { status: 403, description: 'Caller lacks the `ai_assistant.view` feature.' },
61
58
  { status: 404, description: 'No pending action with the given id is accessible to the caller.' },