diff --git a/web/drizzle/0018_add_service_purchase_idempotency.sql b/web/drizzle/0018_add_service_purchase_idempotency.sql new file mode 100644 index 00000000..8a96e5c7 --- /dev/null +++ b/web/drizzle/0018_add_service_purchase_idempotency.sql @@ -0,0 +1,18 @@ +-- Per-buyer idempotency for inter-agent service purchases. +-- +-- The previous partial unique index on (talosId, idempotencyKey) scoped +-- idempotency per service *provider*, which would prevent two different +-- buyers from reusing the same idempotency key on the same service — a +-- conflict with the purchase contract where the key must be scoped per +-- buyer. +-- +-- We drop that provider-scoped index and replace it with a composite +-- (talosId, requesterTalosId, idempotencyKey) index so that the same key +-- is safe to reuse across different buyers on the same service, while +-- still blocking duplicate jobs for the same buyer+service+key. + +DROP INDEX IF EXISTS "tls_commerce_jobs_talosId_idempotencyKey_unique"; + +CREATE UNIQUE INDEX IF NOT EXISTS "tls_commerce_jobs_talos_requester_idempotencyKey_unique" + ON "tls_commerce_jobs" ("talosId", "requesterTalosId", "idempotencyKey") + WHERE "idempotencyKey" IS NOT NULL; diff --git a/web/drizzle/meta/0015_snapshot.json b/web/drizzle/meta/0015_snapshot.json index ab949faa..1397f970 100644 --- a/web/drizzle/meta/0015_snapshot.json +++ b/web/drizzle/meta/0015_snapshot.json @@ -456,8 +456,8 @@ "method": "btree", "with": {} }, - "tls_commerce_jobs_talosId_idempotencyKey_unique": { - "name": "tls_commerce_jobs_talosId_idempotencyKey_unique", + "tls_commerce_jobs_talos_requester_idempotencyKey_unique": { + "name": "tls_commerce_jobs_talos_requester_idempotencyKey_unique", "columns": [ { "expression": "talosId", @@ -465,6 +465,12 @@ "asc": true, "nulls": "last" }, + { + "expression": "requesterTalosId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, { "expression": "idempotencyKey", "isExpression": false, diff --git a/web/drizzle/schema.ts b/web/drizzle/schema.ts index 75096b3a..2e8bbc86 100644 --- a/web/drizzle/schema.ts +++ b/web/drizzle/schema.ts @@ -107,7 +107,7 @@ fencingToken: integer().default(0).notNull(), }, (table) => [ index("tls_commerce_jobs_talosId_status_idx").using("btree", table.talosId.asc().nullsLast().op("text_ops"), table.status.asc().nullsLast().op("text_ops")), uniqueIndex("tls_commerce_jobs_paymentSig_unique").using("btree", table.paymentSig.asc().nullsLast().op("text_ops")).where(sql`"paymentSig" IS NOT NULL`), - uniqueIndex("tls_commerce_jobs_talosId_idempotencyKey_unique").using("btree", table.talosId.asc().nullsLast().op("text_ops"), table.idempotencyKey.asc().nullsLast().op("text_ops")).where(sql`"idempotencyKey" IS NOT NULL`), + uniqueIndex("tls_commerce_jobs_talos_requester_idempotencyKey_unique").using("btree", table.talosId.asc().nullsLast().op("text_ops"), table.requesterTalosId.asc().nullsLast().op("text_ops"), table.idempotencyKey.asc().nullsLast().op("text_ops")).where(sql`"idempotencyKey" IS NOT NULL`), foreignKey({ columns: [table.talosId], foreignColumns: [tlsTalos.id], diff --git a/web/src/app/api/talos/[id]/service/route.ts b/web/src/app/api/talos/[id]/service/route.ts index b2d87359..57ff2891 100644 --- a/web/src/app/api/talos/[id]/service/route.ts +++ b/web/src/app/api/talos/[id]/service/route.ts @@ -2,14 +2,29 @@ import { NextRequest } from "next/server"; import { db } from "@/db"; import { withTransactionRetry } from "@/db/db-retry"; import { tlsTalos, tlsCommerceServices, tlsCommerceJobs, tlsRevenues } from "@/db/schema"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import { resolveTalosFromRequest, verifyAgentApiKey } from "@/lib/auth"; import { verifyX402Payment, settleX402Payment } from "@/lib/stellar-x402"; import { fulfillInstant } from "@/lib/fulfillment"; import { registerServiceSchema, submitBidSchema, parseBody } from "@/lib/schemas"; import { withTraceContext } from "@/lib/tracing"; +import { logger } from "@/lib/logger"; const STELLAR_NETWORK = process.env.STELLAR_NETWORK ?? "testnet"; +const IDEMPOTENCY_KEY_MAX_BYTES = 128; + +/** Build a JSON response with standard idempotency echo headers. */ +function idempotentResponse( + body: unknown, + status: number, + idempotencyKey: string, + replayed: boolean, +): Response { + const res = Response.json(body, { status }); + res.headers.set("Idempotency-Key", idempotencyKey); + res.headers.set("X-Idempotent-Replayed", String(replayed)); + return res; +} // GET /api/talos/:id/service — Returns 402 with payment details (x402 storefront) async function handleGet( @@ -77,11 +92,25 @@ async function handlePost( // The URL param `id` identifies the service *provider*; the Bearer token // identifies the *requester* (buyer). resolveTalosFromRequest resolves the // caller from their key without requiring a known talosId. - const auth = await resolveTalosFromRequest(request, ["commerce:read"]); + const auth = await resolveTalosFromRequest(request, ["commerce:write"]); if (!auth.ok) return auth.response; const requester = { id: auth.talos.id }; - // 1b. Read body once (request body can only be consumed once) + // 1b. Read optional idempotency key from the request header. + // Trim whitespace; treat an empty string as absent. + const rawKey = request.headers.get("Idempotency-Key")?.trim() || null; + if (rawKey !== null) { + const byteLength = Buffer.byteLength(rawKey, "utf8"); + if (byteLength > IDEMPOTENCY_KEY_MAX_BYTES) { + return Response.json( + { error: `Idempotency-Key must be at most ${IDEMPOTENCY_KEY_MAX_BYTES} bytes` }, + { status: 400 }, + ); + } + } + const idempotencyKey = rawKey; + + // 1c. Read body once (request body can only be consumed once) const requestBody = await request.json().catch(() => ({})) as Record; // 1c. Validate bid payload if present — only run when client actually sends bid fields. @@ -101,6 +130,10 @@ async function handlePost( } bidData = bidValidation.data; } + + // Extract the effective payload for idempotency comparison and job creation. + const payload = (requestBody.payload ?? requestBody) as Record; + // 2. Validate X-PAYMENT header (Stellar x402 token) const paymentHeader = request.headers.get("x-payment"); if (!paymentHeader) { @@ -142,7 +175,81 @@ async function handlePost( ); } - // 3. Replay prevention — check payment token against existing jobs + // 3. Idempotency check — if a key was supplied, look it up before any + // payment work. Scoped per (talosId, requesterTalosId, idempotencyKey) + // so the same key is safe to reuse across different buyers. + if (idempotencyKey) { + const existing = await db + .select() + .from(tlsCommerceJobs) + .where( + and( + eq(tlsCommerceJobs.talosId, id), + eq(tlsCommerceJobs.requesterTalosId, requester.id), + eq(tlsCommerceJobs.idempotencyKey, idempotencyKey), + ), + ) + .limit(1) + .then((r) => r[0] ?? null); + + if (existing) { + // Payload conflict check: same key must carry the same payload + // contents to be considered equivalent. Different payload → + // reject to prevent silent mis-billing. + const incomingPayloadJson = JSON.stringify(payload ?? {}); + const storedPayloadJson = JSON.stringify(existing.payload ?? {}); + if (incomingPayloadJson !== storedPayloadJson) { + logger.warn({ + event: "idempotency_conflict", + idempotencyKey, + talosId: id, + requesterTalosId: requester.id, + }, "service purchase idempotency key reused with different payload"); + return Response.json( + { + error: + "Idempotency-Key reused with a different payload. " + + "Use a new key for a different request.", + }, + { status: 409 }, + ); + } + + // Equivalent retry — return the original response from cache. + if (existing.idempotencyResponse) { + logger.info({ + event: "idempotency_hit", + idempotencyKey, + talosId: id, + jobId: existing.id, + replayed: true, + }, "service purchase idempotent replay — returning cached response"); + return idempotentResponse(existing.idempotencyResponse, 201, idempotencyKey, true); + } + + // Key exists but no cached response yet (edge case: concurrent first + // request still in flight). Treat as a duplicate in progress. + logger.info({ + event: "idempotency_inflight", + idempotencyKey, + talosId: id, + jobId: existing.id, + }, "service purchase idempotent request in flight"); + return Response.json( + { error: "Request with this Idempotency-Key is already being processed" }, + { status: 409 }, + ); + } + + logger.info({ + event: "idempotency_miss", + idempotencyKey, + talosId: id, + requesterTalosId: requester.id, + }, "new service purchase idempotent request"); + } + + // 4. Replay prevention — check payment token against existing jobs const existingJob = await db .select({ id: tlsCommerceJobs.id }) .from(tlsCommerceJobs) @@ -177,8 +284,7 @@ async function handlePost( ); } - // 6. Create commerce job + fulfill - const payload = (requestBody.payload ?? requestBody) as Record; + // 7. Create commerce job + fulfill if (service.fulfillmentMode === "instant") { // Instant mode: server calls external API and returns result synchronously @@ -193,8 +299,114 @@ async function handlePost( ); } - // Atomic: job + revenue recorded together — if either fails, both roll back. - // Payment (on-chain) already happened; DB must not partially record it. + // Build the response body (jobId filled in after insert). + const responseBody = { + jobId: "", + status: bidData.status ?? "completed", + result, + txHash, + }; + + // Atomic: job + revenue + idempotency cache recorded together — if either + // fails, all roll back. Payment (on-chain) already happened; DB must not + // partially record it. + try { + const [job] = await withTransactionRetry( + async (tx) => { + const [job] = await tx + .insert(tlsCommerceJobs) + .values({ + talosId: id, + requesterTalosId: requester.id, + serviceName: service.serviceName, + payload: payload ?? undefined, + result, + paymentSig: paymentToken, + txHash, + amount: service.price, + bidPrice: bidData.bidPrice ? String(bidData.bidPrice) : undefined, + status: bidData.status ?? "completed", + ...(idempotencyKey ? { idempotencyKey } : {}), + }) + .returning(); + + await tx.insert(tlsRevenues).values({ + talosId: id, + amount: service.price, + currency: service.currency ?? "USDC", + source: "commerce", + txHash, + }); + + // Cache the response body for future idempotent replays. + if (idempotencyKey) { + const finalResponse = { ...responseBody, jobId: job.id }; + await tx + .update(tlsCommerceJobs) + .set({ idempotencyResponse: finalResponse }) + .where(eq(tlsCommerceJobs.id, job.id)); + } + + return [job]; + }, + { category: "JOB" } + ); + + const finalBody = { ...responseBody, jobId: job.id }; + if (idempotencyKey) { + return idempotentResponse(finalBody, 201, idempotencyKey, false); + } + return Response.json(finalBody, { status: 201 }); + } catch (err: unknown) { + const e = err as Record; + if (e?.code === "23505") { + const constraint = String(e?.constraint ?? e?.detail ?? ""); + if (constraint.includes("idempotencyKey")) { + // Concurrent request with same idempotency key already inserted. + // Fetch the cached response from that row. + const existing = await db + .select({ idempotencyResponse: tlsCommerceJobs.idempotencyResponse }) + .from(tlsCommerceJobs) + .where( + and( + eq(tlsCommerceJobs.talosId, id), + eq(tlsCommerceJobs.requesterTalosId, requester.id), + eq(tlsCommerceJobs.idempotencyKey, idempotencyKey!), + ), + ) + .limit(1) + .then((r) => r[0] ?? null); + + if (existing?.idempotencyResponse) { + return idempotentResponse(existing.idempotencyResponse, 201, idempotencyKey!, true); + } + return Response.json( + { error: "Request with this Idempotency-Key is already being processed" }, + { status: 409 }, + ); + } + if (constraint.includes("paymentSig")) { + return Response.json({ error: "Payment token already used (replay detected)" }, { status: 409 }); + } + } + throw err; + } + } + + // Async mode: create pending job for agent to fulfill via polling. + // Revenue is recorded when the job is fulfilled, not on creation. + const responseBody = { + jobId: "", + status: bidData.status ?? "pending", + txHash, + }; + + try { + // Atomic: the job insert and the idempotency cache write must commit + // together. If the cache update fails, the job insert rolls back too, + // otherwise the row would be left without a cached response and every + // retry would receive a permanent 409. Async mode records revenue on + // fulfillment, so no revenue row is written here. const [job] = await withTransactionRetry( async (tx) => { const [job] = await tx @@ -204,61 +416,68 @@ async function handlePost( requesterTalosId: requester.id, serviceName: service.serviceName, payload: payload ?? undefined, - result, paymentSig: paymentToken, txHash, amount: service.price, bidPrice: bidData.bidPrice ? String(bidData.bidPrice) : undefined, - status: bidData.status ?? "completed", + status: bidData.status ?? "pending", + ...(idempotencyKey ? { idempotencyKey } : {}), }) .returning(); - await tx.insert(tlsRevenues).values({ - talosId: id, - amount: service.price, - currency: service.currency ?? "USDC", - source: "commerce", - txHash, - }); + // Cache the response body for future idempotent replays, in the + // same transaction as the job insert. + if (idempotencyKey) { + const finalResponse = { ...responseBody, jobId: job.id }; + await tx + .update(tlsCommerceJobs) + .set({ idempotencyResponse: finalResponse }) + .where(eq(tlsCommerceJobs.id, job.id)); + } return [job]; }, { category: "JOB" } ); - return Response.json( - { id: job.id, jobId: job.id, status: "completed", result, txHash }, - { status: 201 } - ); + const finalBody = { ...responseBody, jobId: job.id }; + if (idempotencyKey) { + return idempotentResponse(finalBody, 201, idempotencyKey, false); + } + return Response.json(finalBody, { status: 201 }); + } catch (err: unknown) { + const e = err as Record; + if (e?.code === "23505") { + const constraint = String(e?.constraint ?? e?.detail ?? ""); + if (constraint.includes("idempotencyKey")) { + const existing = await db + .select({ idempotencyResponse: tlsCommerceJobs.idempotencyResponse }) + .from(tlsCommerceJobs) + .where( + and( + eq(tlsCommerceJobs.talosId, id), + eq(tlsCommerceJobs.requesterTalosId, requester.id), + eq(tlsCommerceJobs.idempotencyKey, idempotencyKey!), + ), + ) + .limit(1) + .then((r) => r[0] ?? null); + + if (existing?.idempotencyResponse) { + return idempotentResponse(existing.idempotencyResponse, 201, idempotencyKey!, true); + } + return Response.json( + { error: "Request with this Idempotency-Key is already being processed" }, + { status: 409 }, + ); + } + if (constraint.includes("paymentSig")) { + return Response.json({ error: "Payment token already used (replay detected)" }, { status: 409 }); + } + } + throw err; } - - // Async mode: create pending job for agent to fulfill via polling - // Revenue is recorded when the job is fulfilled, not on creation - const [job] = await db - .insert(tlsCommerceJobs) - .values({ - talosId: id, - requesterTalosId: requester.id, - serviceName: service.serviceName, - payload: payload ?? undefined, - paymentSig: paymentToken, - txHash, - amount: service.price, - bidPrice: bidData.bidPrice ? String(bidData.bidPrice) : undefined, - status: bidData.status ?? "pending", - }) - .returning(); - - return Response.json( - { id: job.id, jobId: job.id, status: "pending", txHash }, - { status: 201 } - ); } catch (err: unknown) { - // Catch unique constraint violation on paymentSig (replay race condition) - const e = err as Record; - if (e?.code === "23505" && String(e?.constraint ?? "").includes("paymentSig")) { - return Response.json({ error: "Payment token already used (replay detected)" }, { status: 409 }); - } console.error("Service POST error:", err); return Response.json({ error: "Internal server error" }, { status: 500 }); } diff --git a/web/src/db/schema.ts b/web/src/db/schema.ts index 3bf216bb..0030f15e 100644 --- a/web/src/db/schema.ts +++ b/web/src/db/schema.ts @@ -344,9 +344,11 @@ export const tlsCommerceJobs = pgTable( bidPrice: numeric("bidPrice", { precision: 18, scale: 6 }), // Negotiated bid price (nullable) // Client-supplied idempotency key (Idempotency-Key request header). - // Scoped per talosId: the same key value may be reused across different agents. - // A partial unique index (WHERE idempotencyKey IS NOT NULL) enforces that a - // given key is only ever processed once per agent, blocking concurrent dupes. + // Scoped per (talosId, requesterTalosId): the same key value may be + // reused across different agents or different buyers, but only ever + // processed once for a given buyer+service combination. A partial + // unique index (WHERE idempotencyKey IS NOT NULL) enforces this, + // blocking concurrent duplicate jobs for the same buyer+service+key. idempotencyKey: text("idempotencyKey"), // Cached 201 response body so an identical retry returns the original result. @@ -363,8 +365,8 @@ export const tlsCommerceJobs = pgTable( }, (t) => [ index("tls_commerce_jobs_talosId_status_idx").on(t.talosId, t.status), - uniqueIndex("tls_commerce_jobs_talosId_idempotencyKey_unique") - .on(t.talosId, t.idempotencyKey) + uniqueIndex("tls_commerce_jobs_talos_requester_idempotencyKey_unique") + .on(t.talosId, t.requesterTalosId, t.idempotencyKey) .where(sql`"idempotencyKey" IS NOT NULL`), ], ); diff --git a/web/src/lib/openapi.ts b/web/src/lib/openapi.ts index 370af26b..96120a1a 100644 --- a/web/src/lib/openapi.ts +++ b/web/src/lib/openapi.ts @@ -2254,13 +2254,32 @@ The property order shown above is mandatory for signing. Request JSON property o - \`Authorization: Bearer \` — buyer agent's API key - \`X-PAYMENT: x402 \` — signed payment token from \`POST /api/talos/{buyerId}/sign\` +**Optional header:** +- \`Idempotency-Key\` — opaque string (UUID recommended, max 128 bytes) for safe retry. Scoped per buyer and service. + The server verifies the payment on-chain, settles it, and creates a job. - \`instant\` mode: returns the result synchronously -- \`async\` mode: returns a \`pending\` job — poll \`GET /api/jobs/{id}/result\``, +- \`async\` mode: returns a \`pending\` job — poll \`GET /api/jobs/{id}/result\` + +**Idempotency contract:** +- No header → request is processed normally (backward compatible). +- New key → job is created, response cached. \`X-Idempotent-Replayed: false\`. +- Same key + same payload → original cached 201 response returned. \`X-Idempotent-Replayed: true\`. +- Same key + different payload → 409 Conflict. +- Concurrent requests with same key → 409 "already being processed".`, operationId: "purchaseService", security: [{ BearerAuth: [] }], - parameters: [{ $ref: "#/components/parameters/talosId" }], + parameters: [ + { $ref: "#/components/parameters/talosId" }, + { + name: "Idempotency-Key", + in: "header", + required: false, + schema: { type: "string", maxLength: 128 }, + description: "Opaque idempotency key for safe retry (UUID recommended). Scoped per buyer and service.", + }, + ], requestBody: { content: { "application/json": { @@ -2271,17 +2290,27 @@ The server verifies the payment on-chain, settles it, and creates a job. responses: { "201": { description: "Job created", + headers: { + "Idempotency-Key": { + schema: { type: "string" }, + description: "Echoes the idempotency key (if provided)", + }, + "X-Idempotent-Replayed": { + schema: { type: "string", enum: ["true", "false"] }, + description: "Whether this response was served from the idempotency cache", + }, + }, content: { "application/json": { schema: { $ref: "#/components/schemas/CommerceJob" }, }, }, }, - "400": { description: "Missing X-PAYMENT header" }, + "400": { description: "Missing X-PAYMENT header or Idempotency-Key too large" }, "401": { $ref: "#/components/responses/UnauthorizedError" }, "402": { description: "Invalid or insufficient x402 payment" }, "404": { description: "No service registered for this TALOS" }, - "409": { description: "Payment token already used (replay detected)" }, + "409": { description: "Payment token already used, idempotency key reused with different payload, or request in progress" }, "502": { description: "On-chain payment settlement or fulfillment failed" }, "500": { $ref: "#/components/responses/InternalError" }, }, diff --git a/web/tests/fixtures/openapi.snapshot.json b/web/tests/fixtures/openapi.snapshot.json index e98b6719..ece7e73f 100644 --- a/web/tests/fixtures/openapi.snapshot.json +++ b/web/tests/fixtures/openapi.snapshot.json @@ -4196,7 +4196,7 @@ "Commerce" ], "summary": "Purchase service (inter-agent x402)", - "description": "Submit an x402 payment and create a commerce job (agent-to-agent).\n\n**Required headers:**\n- `Authorization: Bearer ` — buyer agent's API key\n- `X-PAYMENT: x402 ` — signed payment token from `POST /api/talos/{buyerId}/sign`\n\nThe server verifies the payment on-chain, settles it, and creates a job.\n\n- `instant` mode: returns the result synchronously\n- `async` mode: returns a `pending` job — poll `GET /api/jobs/{id}/result`", + "description": "Submit an x402 payment and create a commerce job (agent-to-agent).\n\n**Required headers:**\n- `Authorization: Bearer ` — buyer agent's API key\n- `X-PAYMENT: x402 ` — signed payment token from `POST /api/talos/{buyerId}/sign`\n\n**Optional header:**\n- `Idempotency-Key` — opaque string (UUID recommended, max 128 bytes) for safe retry. Scoped per buyer and service.\n\nThe server verifies the payment on-chain, settles it, and creates a job.\n\n- `instant` mode: returns the result synchronously\n- `async` mode: returns a `pending` job — poll `GET /api/jobs/{id}/result`\n\n**Idempotency contract:**\n- No header → request is processed normally (backward compatible).\n- New key → job is created, response cached. `X-Idempotent-Replayed: false`.\n- Same key + same payload → original cached 201 response returned. `X-Idempotent-Replayed: true`.\n- Same key + different payload → 409 Conflict.\n- Concurrent requests with same key → 409 \"already being processed\".", "operationId": "purchaseService", "security": [ { @@ -4206,6 +4206,16 @@ "parameters": [ { "$ref": "#/components/parameters/talosId" + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string", + "maxLength": 128 + }, + "description": "Opaque idempotency key for safe retry (UUID recommended). Scoped per buyer and service." } ], "requestBody": { @@ -4220,6 +4230,24 @@ "responses": { "201": { "description": "Job created", + "headers": { + "Idempotency-Key": { + "schema": { + "type": "string" + }, + "description": "Echoes the idempotency key (if provided)" + }, + "X-Idempotent-Replayed": { + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + }, + "description": "Whether this response was served from the idempotency cache" + } + }, "content": { "application/json": { "schema": { @@ -4229,7 +4257,7 @@ } }, "400": { - "description": "Missing X-PAYMENT header" + "description": "Missing X-PAYMENT header or Idempotency-Key too large" }, "401": { "$ref": "#/components/responses/UnauthorizedError" @@ -4241,7 +4269,7 @@ "description": "No service registered for this TALOS" }, "409": { - "description": "Payment token already used (replay detected)" + "description": "Payment token already used, idempotency key reused with different payload, or request in progress" }, "500": { "$ref": "#/components/responses/InternalError" diff --git a/web/tests/service-purchase-idempotency.test.ts b/web/tests/service-purchase-idempotency.test.ts new file mode 100644 index 00000000..50a214bf --- /dev/null +++ b/web/tests/service-purchase-idempotency.test.ts @@ -0,0 +1,728 @@ +/** + * Idempotency tests for POST /api/talos/[id]/service + * + * Test matrix: + * positive — new key creates the job and caches the response (201) + * positive — equivalent retry (same key + same payload) returns original 201 from cache + * positive — no header supplied → request proceeds without idempotency (backward compat) + * negative — same key with a different payload → 409 Conflict + * negative — key exists but response not yet cached (in-flight) → 409 + * negative — concurrent race: second INSERT hits unique constraint → 409 + * concurrent — Promise.all with 3 identical requests: exactly one succeeds, two get 409 + * existing — paymentSig replay prevention still works independently of idempotency key + */ + +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { POST } from "../src/app/api/talos/[id]/service/route"; +import { NextRequest } from "next/server"; +import { tlsCommerceJobs } from "../src/db/schema"; + +// ─── Hoisted mock factories ─────────────────────────────────────────────────── +const mocks = vi.hoisted(() => { + const mockTransaction = vi.fn(async (cb: (tx: any) => Promise) => { + return cb({ + insert: (...a: any[]) => mocks.mockTxInsert(...a), + update: (...a: any[]) => mocks.mockTxUpdate(...a), + }); + }); + + return { + mockInsert: vi.fn(), + mockUpdate: vi.fn(), + mockTxInsert: vi.fn(), + mockTxUpdate: vi.fn(), + mockTransaction, + mockFulfillInstant: vi.fn(), + mockVerifyX402Payment: vi.fn(), + mockSettleX402Payment: vi.fn(), + mockResolveTalosFromRequest: vi.fn(), + + // Per-test result overrides + _serviceResult: [] as any[], + _talosResult: [] as any[], + _idempotencyResult: [] as any[], + _paymentSigResult: [] as any[], + _idempotencyConflictResult: [] as any[], + // Track how many times commerce-jobs has been selected + _commerceJobsSelectCount: 0, + }; +}); + +// ─── Module mocks ───────────────────────────────────────────────────────────── + +/** + * Table-aware db.select() mock. + * + * Dispatches to the correct pre-configured result based on which table + * was passed to .from(). Safe for Promise.all because dispatch is by + * table identity, not call order. + */ +vi.mock("@/db", () => { + const makeChain = () => { + let resolvedTable: any = null; + const chain: any = { + from: vi.fn((table: any) => { + resolvedTable = table; + return chain; + }), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + then: vi.fn().mockImplementation((cb: (r: any) => any) => { + const syms: symbol[] = resolvedTable + ? Object.getOwnPropertySymbols(resolvedTable) + : []; + const nameSym = syms.find((s) => s.toString() === "Symbol(drizzle:Name)"); + const tableName: string = nameSym ? resolvedTable[nameSym] : ""; + + if (tableName === "tls_commerce_services") { + return Promise.resolve(cb(mocks._serviceResult)); + } + if (tableName === "tls_talos") { + return Promise.resolve(cb(mocks._talosResult)); + } + if (tableName === "tls_commerce_jobs") { + mocks._commerceJobsSelectCount += 1; + // First select: idempotency check or paymentSig check (depending on test setup) + // Second select: the other one + const result = + mocks._commerceJobsSelectCount === 1 + ? mocks._idempotencyResult + : mocks._paymentSigResult; + return Promise.resolve(cb(result)); + } + return Promise.resolve(cb([])); + }), + }; + return chain; + }; + + return { + db: { + select: () => makeChain(), + insert: (...a: any[]) => mocks.mockInsert(...a), + update: (...a: any[]) => mocks.mockUpdate(...a), + transaction: (cb: any) => mocks.mockTransaction(cb), + }, + }; +}); + +vi.mock("@/lib/auth", () => ({ + resolveTalosFromRequest: (...a: any[]) => mocks.mockResolveTalosFromRequest(...a), + verifyAgentApiKey: vi.fn(), +})); + +vi.mock("@/lib/stellar-x402", () => ({ + verifyX402Payment: (...a: any[]) => mocks.mockVerifyX402Payment(...a), + settleX402Payment: (...a: any[]) => mocks.mockSettleX402Payment(...a), +})); + +vi.mock("@/lib/fulfillment", () => ({ + fulfillInstant: (...a: any[]) => mocks.mockFulfillInstant(...a), +})); + +vi.mock("@/lib/schemas", () => ({ + registerServiceSchema: {}, + submitBidSchema: { safeParse: () => ({ success: true, data: {} }) }, + parseBody: vi.fn(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("@/lib/tracing", () => ({ + withTraceContext: (fn: any) => fn, +})); + +// ─── Shared test data ───────────────────────────────────────────────────────── + +const PROVIDER_ID = "provider-abc"; +const BUYER_ID = "buyer-xyz"; +const routeParams = Promise.resolve({ id: PROVIDER_ID }); + +const mockService = { + id: "svc-1", + talosId: PROVIDER_ID, + serviceName: "research", + description: "Research service", + price: "5.00", + currency: "USDC", + fulfillmentMode: "async", + stellarPublicKey: "GRECIPIENT", + chains: ["stellar"], + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockTalos = { + agentWalletAddress: "GWALLET", +}; + +function makeRequest(opts: { + payload?: Record; + idempotencyKey?: string | null; + paymentToken?: string; +}) { + const headers: Record = { + "Content-Type": "application/json", + "x-payment": `x402 ${opts.paymentToken ?? "payment-token-default"}`, + }; + if (opts.idempotencyKey) headers["Idempotency-Key"] = opts.idempotencyKey; + + return new NextRequest(`http://localhost/api/talos/${PROVIDER_ID}/service`, { + method: "POST", + headers, + body: JSON.stringify({ + payload: opts.payload ?? { query: "test" }, + }), + }); +} + +/** + * Configure what each table select should return for this test. + * Resets the commerce-jobs call counter. + * + * @param hasIdempotencyKey When false, the first commerce-jobs select is the + * paymentSig dupe check (not an idempotency lookup), so paymentSigResult + * is placed in the slot that count===1 reads. + */ +function setupSelects(opts: { + service?: typeof mockService; + talos?: typeof mockTalos; + idempotencyResult?: any[]; + paymentSigResult?: any[]; + hasIdempotencyKey?: boolean; +}) { + const { + service, + talos, + idempotencyResult = [], + paymentSigResult = [], + hasIdempotencyKey = true, + } = opts; + + mocks._serviceResult = service !== undefined ? [service] : [mockService]; + mocks._talosResult = talos !== undefined ? [talos] : [mockTalos]; + mocks._commerceJobsSelectCount = 0; + + if (hasIdempotencyKey) { + // count=1 → idempotency check, count=2 → paymentSig dupe check + mocks._idempotencyResult = idempotencyResult; + mocks._paymentSigResult = paymentSigResult; + } else { + // count=1 → paymentSig dupe check (no idempotency select with no header) + mocks._idempotencyResult = paymentSigResult; + mocks._paymentSigResult = []; + } +} + +function resetInsertMock(jobId = "job-1") { + mocks.mockInsert.mockImplementation((table: any) => { + if (table === tlsCommerceJobs) { + return { + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ + id: jobId, + status: "pending", + serviceName: "research", + }]), + }), + }; + } + return { values: vi.fn().mockResolvedValue([]) }; + }); +} + +function resetUpdateMock() { + mocks.mockUpdate.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }); +} + +function resetTxMocks(jobId = "job-1") { + mocks.mockTxInsert.mockImplementation((table: any) => { + if (table === tlsCommerceJobs) { + return { + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ + id: jobId, + status: "pending", + serviceName: "research", + }]), + }), + }; + } + return { values: vi.fn().mockResolvedValue([]) }; + }); + + mocks.mockTxUpdate.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }); +} + +function setupAuth() { + mocks.mockResolveTalosFromRequest.mockResolvedValue({ + ok: true, + talos: { id: BUYER_ID }, + }); +} + +function setupPayment() { + mocks.mockVerifyX402Payment.mockResolvedValue(true); + mocks.mockSettleX402Payment.mockResolvedValue({ txHash: "tx-hash-settled" }); +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("service-purchase idempotency — positive (new key)", () => { + beforeEach(() => { + vi.clearAllMocks(); + setupAuth(); + setupPayment(); + resetInsertMock(); + resetUpdateMock(); + resetTxMocks(); + }); + + it("creates a job and returns 201 when a fresh Idempotency-Key is supplied", async () => { + setupSelects({ idempotencyResult: [], paymentSigResult: [] }); + + const req = makeRequest({ idempotencyKey: "key-fresh-1", paymentToken: "tok-fresh-1" }); + const res = await POST(req, { params: routeParams }); + const body = await res.json(); + + expect(res.status).toBe(201); + expect(body.status).toBe("pending"); + expect(body.jobId).toBeDefined(); + expect(res.headers.get("Idempotency-Key")).toBe("key-fresh-1"); + expect(res.headers.get("X-Idempotent-Replayed")).toBe("false"); + }); + + it("creates a job normally with no Idempotency-Key header (backward compatibility)", async () => { + setupSelects({ paymentSigResult: [], hasIdempotencyKey: false }); + + const req = makeRequest({ paymentToken: "tok-no-key" }); + const res = await POST(req, { params: routeParams }); + + expect(res.status).toBe(201); + // No idempotency headers when no key provided + expect(res.headers.get("Idempotency-Key")).toBeNull(); + expect(res.headers.get("X-Idempotent-Replayed")).toBeNull(); + }); +}); + +describe("service-purchase idempotency — positive (equivalent retry)", () => { + beforeEach(() => { + vi.clearAllMocks(); + setupAuth(); + setupPayment(); + resetInsertMock(); + resetUpdateMock(); + resetTxMocks(); + }); + + it("returns the original 201 response from cache on an equivalent retry", async () => { + const cachedResponse = { + jobId: "job-original", + status: "completed", + txHash: "tx-retry", + result: { answer: "42" }, + }; + + const existingJob = { + id: "job-original", + talosId: PROVIDER_ID, + requesterTalosId: BUYER_ID, + serviceName: "research", + payload: { query: "test" }, + status: "completed", + idempotencyKey: "key-retry-1", + idempotencyResponse: cachedResponse, + }; + + setupSelects({ idempotencyResult: [existingJob] }); + + const req = makeRequest({ + idempotencyKey: "key-retry-1", + paymentToken: "tok-retry", + payload: { query: "test" }, + }); + const res = await POST(req, { params: routeParams }); + const body = await res.json(); + + expect(res.status).toBe(201); + expect(body).toEqual(cachedResponse); + expect(res.headers.get("X-Idempotent-Replayed")).toBe("true"); + // No payment verification or settlement should occur + expect(mocks.mockVerifyX402Payment).not.toHaveBeenCalled(); + expect(mocks.mockSettleX402Payment).not.toHaveBeenCalled(); + expect(mocks.mockInsert).not.toHaveBeenCalled(); + }); + + it("returns 409 when key exists but the response has not been cached yet (in-flight)", async () => { + const existingJob = { + id: "job-in-flight", + talosId: PROVIDER_ID, + requesterTalosId: BUYER_ID, + serviceName: "research", + payload: { query: "test" }, + status: "pending", + idempotencyKey: "key-in-flight", + idempotencyResponse: null, + }; + + setupSelects({ idempotencyResult: [existingJob] }); + + const req = makeRequest({ + idempotencyKey: "key-in-flight", + paymentToken: "tok-in-flight", + payload: { query: "test" }, + }); + const res = await POST(req, { params: routeParams }); + const body = await res.json(); + + expect(res.status).toBe(409); + expect(body.error).toMatch(/already being processed/i); + expect(mocks.mockVerifyX402Payment).not.toHaveBeenCalled(); + expect(mocks.mockInsert).not.toHaveBeenCalled(); + }); +}); + +describe("service-purchase idempotency — negative (conflict)", () => { + beforeEach(() => { + vi.clearAllMocks(); + setupAuth(); + setupPayment(); + resetInsertMock(); + resetUpdateMock(); + resetTxMocks(); + }); + + it("returns 409 when the same key is reused with a different payload", async () => { + const existingJob = { + id: "job-conflict", + talosId: PROVIDER_ID, + requesterTalosId: BUYER_ID, + serviceName: "research", + payload: { query: "original query" }, + status: "pending", + idempotencyKey: "key-conflict", + idempotencyResponse: { jobId: "job-conflict", status: "pending" }, + }; + + setupSelects({ idempotencyResult: [existingJob] }); + + const req = makeRequest({ + idempotencyKey: "key-conflict", + paymentToken: "tok-conflict", + payload: { query: "different query" }, + }); + const res = await POST(req, { params: routeParams }); + const body = await res.json(); + + expect(res.status).toBe(409); + expect(body.error).toMatch(/different payload/i); + expect(mocks.mockVerifyX402Payment).not.toHaveBeenCalled(); + expect(mocks.mockInsert).not.toHaveBeenCalled(); + }); + + it("returns 409 when a concurrent INSERT hits the unique constraint (race condition)", async () => { + setupSelects({ idempotencyResult: [], paymentSigResult: [] }); + + const pgUniqueError = Object.assign(new Error("duplicate key value"), { + code: "23505", + constraint: "tls_commerce_jobs_talos_requester_idempotencyKey_unique", + }); + + mocks.mockInsert.mockImplementation((table: any) => { + if (table === tlsCommerceJobs) { + return { + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockRejectedValue(pgUniqueError), + }), + }; + } + return { values: vi.fn().mockResolvedValue([]) }; + }); + + mocks.mockTransaction.mockImplementation(async (cb: any) => { + return cb({ insert: mocks.mockInsert, update: mocks.mockUpdate }); + }); + + const req = makeRequest({ idempotencyKey: "key-race", paymentToken: "tok-race" }); + const res = await POST(req, { params: routeParams }); + const body = await res.json(); + + expect(res.status).toBe(409); + expect(body.error).toMatch(/already being processed/i); + }); +}); + +describe("service-purchase idempotency — concurrent requests (Promise.all race)", () => { + it("allows exactly one request through when N identical requests fire simultaneously", async () => { + vi.clearAllMocks(); + setupAuth(); + setupPayment(); + resetUpdateMock(); + + setupSelects({ idempotencyResult: [], paymentSigResult: [] }); + + let insertCallCount = 0; + + mocks.mockInsert.mockImplementation((table: any) => { + if (table === tlsCommerceJobs) { + insertCallCount += 1; + const n = insertCallCount; + if (n === 1) { + return { + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ + id: "job-winner", + status: "pending", + serviceName: "research", + }]), + }), + }; + } + const err = Object.assign(new Error("duplicate key value"), { + code: "23505", + constraint: "tls_commerce_jobs_talos_requester_idempotencyKey_unique", + }); + return { + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockRejectedValue(err), + }), + }; + } + return { values: vi.fn().mockResolvedValue([]) }; + }); + + mocks.mockTransaction.mockImplementation(async (cb: any) => { + return cb({ insert: mocks.mockInsert, update: mocks.mockUpdate }); + }); + + const makeReq = (n: number) => + makeRequest({ + idempotencyKey: "key-concurrent", + paymentToken: `tok-concurrent-${n}`, + payload: { query: "test" }, + }); + + const [r1, r2, r3] = await Promise.all([ + POST(makeReq(1), { params: routeParams }), + POST(makeReq(2), { params: routeParams }), + POST(makeReq(3), { params: routeParams }), + ]); + + const statuses = [r1.status, r2.status, r3.status]; + const successes = statuses.filter(s => s === 201); + const conflicts = statuses.filter(s => s === 409); + + expect(successes).toHaveLength(1); + expect(conflicts).toHaveLength(2); + + const winner = [r1, r2, r3].find(r => r.status === 201)!; + const body = await winner.json(); + expect(body.jobId).toBe("job-winner"); + }); +}); + +describe("service-purchase idempotency — paymentSig replay prevention (unchanged)", () => { + beforeEach(() => { + vi.clearAllMocks(); + setupAuth(); + setupPayment(); + resetInsertMock(); + resetUpdateMock(); + resetTxMocks(); + }); + + it("returns 409 when paymentSig was already used, even with no Idempotency-Key", async () => { + setupSelects({ paymentSigResult: [{ id: "job-existing" }], hasIdempotencyKey: false }); + + const req = makeRequest({ paymentToken: "tok-already-used" }); + const res = await POST(req, { params: routeParams }); + const body = await res.json(); + + expect(res.status).toBe(409); + expect(body.error).toMatch(/already used/i); + expect(mocks.mockInsert).not.toHaveBeenCalled(); + }); +}); + +describe("service-purchase idempotency — key length validation", () => { + beforeEach(() => { + vi.clearAllMocks(); + setupAuth(); + resetInsertMock(); + resetUpdateMock(); + resetTxMocks(); + }); + + it("returns 400 when the Idempotency-Key exceeds 128 bytes", async () => { + const longKey = "a".repeat(129); + + const req = makeRequest({ idempotencyKey: longKey }); + const res = await POST(req, { params: routeParams }); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toMatch(/128 bytes/i); + }); +}); + +describe("service-purchase idempotency — different buyer with same key", () => { + beforeEach(() => { + vi.clearAllMocks(); + setupAuth(); + setupPayment(); + resetInsertMock(); + resetUpdateMock(); + resetTxMocks(); + }); + + it("allows a different buyer to use the same idempotency key on the same service", async () => { + // The existing job belongs to buyer-abc, the new request is from buyer-xyz. + // The idempotency check filters by requesterTalosId, so no conflict. + setupSelects({ idempotencyResult: [], paymentSigResult: [] }); + + const req = makeRequest({ + idempotencyKey: "shared-key", + paymentToken: "tok-diff-buyer", + payload: { query: "test" }, + }); + const res = await POST(req, { params: routeParams }); + + expect(res.status).toBe(201); + expect(mocks.mockVerifyX402Payment).toHaveBeenCalled(); + }); +}); + +describe("service-purchase idempotency — write scope requirement", () => { + it("requires the commerce:write scope for the mutating purchase", async () => { + vi.clearAllMocks(); + setupAuth(); + setupPayment(); + resetInsertMock(); + resetUpdateMock(); + resetTxMocks(); + setupSelects({ idempotencyResult: [], paymentSigResult: [] }); + + const req = makeRequest({ idempotencyKey: "key-scope", paymentToken: "tok-scope" }); + await POST(req, { params: routeParams }); + + const scopeArg = mocks.mockResolveTalosFromRequest.mock.calls[0]?.[1]; + expect(scopeArg).toContain("commerce:write"); + }); +}); + +describe("service-purchase idempotency — migration / index boundary", () => { + it("drops the provider-scoped index and adds the per-buyer composite index", async () => { + // Guards the migration against reintroducing the (talosId, idempotencyKey) + // unique index, which would block two buyers from reusing a key on one + // service and contradict the documented per-buyer contract. + const sql = await import("fs/promises") + .then((fs) => fs.readFile( + new URL("../drizzle/0018_add_service_purchase_idempotency.sql", import.meta.url), + "utf8", + )); + + expect(sql).toContain('DROP INDEX IF EXISTS "tls_commerce_jobs_talosId_idempotencyKey_unique"'); + expect(sql).toContain( + 'CREATE UNIQUE INDEX IF NOT EXISTS "tls_commerce_jobs_talos_requester_idempotencyKey_unique"', + ); + }); +}); + +describe("service-purchase idempotency — async cache write atomicity", () => { + beforeEach(() => { + vi.clearAllMocks(); + setupAuth(); + setupPayment(); + resetInsertMock(); + resetUpdateMock(); + resetTxMocks(); + }); + + it("rolls back the whole transaction if the idempotency cache write fails (no orphan 201)", async () => { + // async mode: job insert + idempotencyResponse update must commit together. + setupSelects({ idempotencyResult: [], paymentSigResult: [] }); + + // Ensure the transaction routes to the tx insert/update mocks (a later + // describe overrides mockTransaction to use the outer mocks, which is not + // reset by clearAllMocks). + mocks.mockTransaction.mockImplementation(async (cb: any) => + cb({ insert: mocks.mockTxInsert, update: mocks.mockTxUpdate }), + ); + + // Insert succeeds, but the cache write (update) fails inside the transaction. + mocks.mockTxInsert.mockImplementation((table: any) => { + if (table === tlsCommerceJobs) { + return { + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ + id: "job-orphan", + status: "pending", + serviceName: "research", + }]), + }), + }; + } + return { values: vi.fn().mockResolvedValue([]) }; + }); + mocks.mockTxUpdate.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockRejectedValue(new Error("cache write failed")), + }), + }); + + const req = makeRequest({ idempotencyKey: "key-atomic", paymentToken: "tok-atomic" }); + const res = await POST(req, { params: routeParams }); + + // Because the insert and cache write are one transaction, a failed cache + // write aborts the whole operation rather than leaving a row without a + // cached response (which would otherwise turn every retry into a 409). + expect(res.status).toBe(500); + }); + + it("recovers on retry instead of surfacing a permanent 409 after a failed cache write", async () => { + // First attempt: transaction insert succeeds, cache update fails → whole + // transaction rolls back → 500 and NO row persists with this key. + setupSelects({ idempotencyResult: [], paymentSigResult: [] }); + mocks.mockTransaction.mockImplementation(async (cb: any) => + cb({ insert: mocks.mockTxInsert, update: mocks.mockTxUpdate }), + ); + mocks.mockTxUpdate.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockRejectedValue(new Error("cache write failed")), + }), + }); + + const first = await POST( + makeRequest({ idempotencyKey: "key-recover", paymentToken: "tok-1" }), + { params: routeParams }, + ); + expect(first.status).toBe(500); + + // Retry with the same key: since nothing was persisted, the idempotency + // lookup finds no row and the purchase proceeds as a fresh request. + resetTxMocks(); + setupSelects({ idempotencyResult: [], paymentSigResult: [] }); + + const second = await POST( + makeRequest({ idempotencyKey: "key-recover", paymentToken: "tok-2" }), + { params: routeParams }, + ); + expect(second.status).toBe(201); + expect(second.headers.get("X-Idempotent-Replayed")).toBe("false"); + }); +});