From 8911709931794e47b1bca7588ebdab2916a18e51 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Tue, 15 Sep 2026 18:08:13 +0200 Subject: [PATCH 1/7] fix: do not acknowledge unverified Stripe webhooks --- app/api/webhooks/stripe/route.ts | 9 ++++-- docs/architecture/ARCHITECTURE.md | 2 ++ tests/api/stripe-webhook-config.test.ts | 40 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 tests/api/stripe-webhook-config.test.ts diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index 21808c7..784f705 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -7,8 +7,13 @@ export async function POST(req: NextRequest) { const stripe = getStripe(); const whSecret = process.env.STRIPE_WEBHOOK_SECRET; const sig = req.headers.get("stripe-signature"); - if (!stripe || !whSecret || !sig) { - return NextResponse.json({ received: false, reason: "not_configured" }, { status: 200 }); + if (!stripe || !whSecret) { + // Never acknowledge an event that was not signature-verified and processed. + // A non-2xx response keeps Stripe's delivery retry/reconciliation path open. + return NextResponse.json({ received: false, reason: "not_configured" }, { status: 503 }); + } + if (!sig) { + return NextResponse.json({ error: "missing_signature" }, { status: 400 }); } const body = await req.text(); let event; diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index ba2c256..6ff8aeb 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -343,6 +343,8 @@ Om/när Postgres provisioneras måste detta lösas separat — jfr estate-blocke utom literalen `"false"`, och `assertAuthConfigSafe()` kastar vid `NODE_ENV=production` + auth av. Designen är korrekt. - **Stripe-webhooken** verifierar signatur mot `STRIPE_WEBHOOK_SECRET`. + Om Stripe-konfiguration saknas svarar den nu `503` i stället för att + kvittera ett obearbetat event med `200`; saknad signatur ger `400`. - `SECURITY.md` finns med rapporteringsväg (`security@siax.io`). ### KÄNDA LUCKOR diff --git a/tests/api/stripe-webhook-config.test.ts b/tests/api/stripe-webhook-config.test.ts new file mode 100644 index 0000000..467c114 --- /dev/null +++ b/tests/api/stripe-webhook-config.test.ts @@ -0,0 +1,40 @@ +import { afterEach, expect, it } from "vitest"; +import { NextRequest } from "next/server"; +import { POST } from "@/app/api/webhooks/stripe/route"; + +const savedSecretKey = process.env.STRIPE_SECRET_KEY; +const savedWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + +afterEach(() => { + if (savedSecretKey === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = savedSecretKey; + if (savedWebhookSecret === undefined) delete process.env.STRIPE_WEBHOOK_SECRET; + else process.env.STRIPE_WEBHOOK_SECRET = savedWebhookSecret; +}); + +it("does not acknowledge an unverified Stripe event when credentials are absent", async () => { + delete process.env.STRIPE_SECRET_KEY; + delete process.env.STRIPE_WEBHOOK_SECRET; + const req = new NextRequest("https://0pt.app/api/webhooks/stripe", { + method: "POST", + headers: { "stripe-signature": "unverified" }, + body: "{}", + }); + + const res = await POST(req); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ received: false, reason: "not_configured" }); +}); + +it("rejects a delivery with no Stripe signature even when configured", async () => { + process.env.STRIPE_SECRET_KEY = "sk_test_fake_local_regression"; + process.env.STRIPE_WEBHOOK_SECRET = "whsec_fake_local_regression"; + const req = new NextRequest("https://0pt.app/api/webhooks/stripe", { + method: "POST", + body: "{}", + }); + + const res = await POST(req); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "missing_signature" }); +}); From ae190fa60de14df1e31320ec9449fde6b72b3a59 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Tue, 15 Sep 2026 18:15:42 +0200 Subject: [PATCH 2/7] fix: advance orders only after matching paid Stripe events --- app/api/payments/[order_id]/deposit/route.ts | 4 +- app/api/payments/[order_id]/release/route.ts | 5 +- app/api/webhooks/stripe/route.ts | 34 +++++++- docs/architecture/ARCHITECTURE.md | 4 + ...ripe-balance-no-premature-delivery.test.ts | 25 ++++++ tests/api/stripe-deposit-metadata.test.ts | 34 ++++++++ tests/api/stripe-webhook-payment.test.ts | 78 +++++++++++++++++++ 7 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 tests/api/stripe-balance-no-premature-delivery.test.ts create mode 100644 tests/api/stripe-deposit-metadata.test.ts create mode 100644 tests/api/stripe-webhook-payment.test.ts diff --git a/app/api/payments/[order_id]/deposit/route.ts b/app/api/payments/[order_id]/deposit/route.ts index b732790..33715ec 100644 --- a/app/api/payments/[order_id]/deposit/route.ts +++ b/app/api/payments/[order_id]/deposit/route.ts @@ -31,6 +31,8 @@ export async function POST( "line_items[0][price_data][unit_amount]": String(depositOre), "line_items[0][quantity]": "1", mode: "payment", + "metadata[order_id]": order_id, + "metadata[kind]": "deposit", success_url: `${origin}/order/${order_id}?deposit=success`, cancel_url: `${origin}/bestall/forskott?order=${order_id}`, }); @@ -51,4 +53,4 @@ export async function POST( const session = (await stripeRes.json()) as { url: string }; return NextResponse.json({ url: session.url }); -} \ No newline at end of file +} diff --git a/app/api/payments/[order_id]/release/route.ts b/app/api/payments/[order_id]/release/route.ts index 888a2d0..d781e57 100644 --- a/app/api/payments/[order_id]/release/route.ts +++ b/app/api/payments/[order_id]/release/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getStripe } from "@/lib/stripe"; -import { getOrder, setStatus } from "@/lib/orders-store"; +import { getOrder } from "@/lib/orders-store"; // POST /api/payments/{order_id}/release (docs/04) — charge the held 50% balance. // Called on ACCEPT only; escrow balance is never charged before approval. @@ -35,6 +35,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ order_id: cancel_url: `${origin}/order/${order_id}/validera`, metadata: { order_id, kind: "balance" }, }); - await setStatus(order_id, "delivered"); + // Creating a Checkout Session is not payment. Only a verified paid webhook + // may advance the order to delivered. return NextResponse.json({ checkout_url: session.url }); } diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index 784f705..6638702 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; +import type Stripe from "stripe"; import { getStripe } from "@/lib/stripe"; -import { setStatus } from "@/lib/orders-store"; +import { getOrder, setStatus } from "@/lib/orders-store"; // POST /api/webhooks/stripe (docs/04) — verify signature, advance order status. export async function POST(req: NextRequest) { @@ -23,10 +24,37 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "bad_signature" }, { status: 400 }); } if (event.type === "checkout.session.completed") { - const s = event.data.object as { metadata?: { order_id?: string; kind?: string } }; + const s = event.data.object as Stripe.Checkout.Session; const oid = s.metadata?.order_id; const kind = s.metadata?.kind; - if (oid) await setStatus(oid, kind === "balance" ? "delivered" : "in_production"); + if (!oid || (kind !== "deposit" && kind !== "balance")) { + return NextResponse.json({ error: "unmatched_session" }, { status: 422 }); + } + if (s.payment_status !== "paid") { + return NextResponse.json({ error: "payment_not_confirmed" }, { status: 503 }); + } + const order = await getOrder(oid); + if (!order) return NextResponse.json({ error: "order_not_found" }, { status: 503 }); + const expectedAmount = kind === "balance" ? order.balance_ore : order.deposit_ore; + if (s.amount_total !== expectedAmount || s.currency?.toLowerCase() !== "sek") { + return NextResponse.json({ error: "payment_mismatch" }, { status: 422 }); + } + if (kind === "balance") { + if (order.status !== "accepted" && order.status !== "delivered") { + return NextResponse.json({ error: "invalid_order_state" }, { status: 409 }); + } + if (order.status === "accepted" && !(await setStatus(oid, "delivered"))) { + return NextResponse.json({ error: "order_update_failed" }, { status: 503 }); + } + } else { + const alreadyAdvanced = ["in_production", "review", "revision", "accepted", "delivered"].includes(order.status); + if (order.status !== "draft" && order.status !== "deposit_pending" && !alreadyAdvanced) { + return NextResponse.json({ error: "invalid_order_state" }, { status: 409 }); + } + if (!alreadyAdvanced && !(await setStatus(oid, "in_production"))) { + return NextResponse.json({ error: "order_update_failed" }, { status: 503 }); + } + } } return NextResponse.json({ received: true }); } diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 6ff8aeb..f4fe72f 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -345,6 +345,10 @@ Om/när Postgres provisioneras måste detta lösas separat — jfr estate-blocke - **Stripe-webhooken** verifierar signatur mot `STRIPE_WEBHOOK_SECRET`. Om Stripe-konfiguration saknas svarar den nu `503` i stället för att kvittera ett obearbetat event med `200`; saknad signatur ger `400`. + Deposit-checkout bär nu order-id/kind i sessionsmetadata. Webhooken + flyttar bara en order efter verifierad signatur, `payment_status=paid`, + matchande belopp och valuta. En skapad slutbetalnings-Checkout markerar + inte längre ordern `delivered` innan betalt event har verifierats. - `SECURITY.md` finns med rapporteringsväg (`security@siax.io`). ### KÄNDA LUCKOR diff --git a/tests/api/stripe-balance-no-premature-delivery.test.ts b/tests/api/stripe-balance-no-premature-delivery.test.ts new file mode 100644 index 0000000..6ae1bad --- /dev/null +++ b/tests/api/stripe-balance-no-premature-delivery.test.ts @@ -0,0 +1,25 @@ +import { expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ create: vi.fn(), setStatus: vi.fn() })); +vi.mock("@/lib/stripe", () => ({ + getStripe: () => ({ checkout: { sessions: { create: mocks.create } } }), +})); +vi.mock("@/lib/orders-store", () => ({ + getOrder: vi.fn().mockResolvedValue({ order_id: "order-1", status: "accepted", balance_ore: 5000 }), + setStatus: mocks.setStatus, +})); + +import { POST } from "@/app/api/payments/[order_id]/release/route"; + +it("does not mark delivery complete merely by creating a balance Checkout Session", async () => { + mocks.create.mockResolvedValue({ url: "https://checkout.stripe.com/test" }); + const req = new NextRequest("https://0pt.app/api/payments/order-1/release", { method: "POST" }); + + const res = await POST(req, { params: Promise.resolve({ order_id: "order-1" }) }); + expect(res.status).toBe(200); + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ + metadata: { order_id: "order-1", kind: "balance" }, + })); + expect(mocks.setStatus).not.toHaveBeenCalled(); +}); diff --git a/tests/api/stripe-deposit-metadata.test.ts b/tests/api/stripe-deposit-metadata.test.ts new file mode 100644 index 0000000..aa40223 --- /dev/null +++ b/tests/api/stripe-deposit-metadata.test.ts @@ -0,0 +1,34 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/orders-store", () => ({ + getOrder: vi.fn().mockResolvedValue({ order_id: "order-1", deposit_ore: 5000 }), +})); + +import { POST } from "@/app/api/payments/[order_id]/deposit/route"; + +const savedKey = process.env.STRIPE_SECRET_KEY; +const savedFetch = globalThis.fetch; + +afterEach(() => { + if (savedKey === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = savedKey; + globalThis.fetch = savedFetch; +}); + +it("binds a deposit Checkout Session to its order and payment kind", async () => { + process.env.STRIPE_SECRET_KEY = "sk_test_fake_local_regression"; + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ url: "https://checkout.stripe.com/test" }), + }); + globalThis.fetch = mockFetch; + + const req = new NextRequest("https://0pt.app/api/payments/order-1/deposit", { method: "POST" }); + const res = await POST(req, { params: Promise.resolve({ order_id: "order-1" }) }); + expect(res.status).toBe(200); + const init = mockFetch.mock.calls[0]?.[1] as RequestInit; + const body = init.body as URLSearchParams; + expect(body.get("metadata[order_id]")).toBe("order-1"); + expect(body.get("metadata[kind]")).toBe("deposit"); +}); diff --git a/tests/api/stripe-webhook-payment.test.ts b/tests/api/stripe-webhook-payment.test.ts new file mode 100644 index 0000000..146eac2 --- /dev/null +++ b/tests/api/stripe-webhook-payment.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + constructEvent: vi.fn(), + getOrder: vi.fn(), + setStatus: vi.fn(), +})); + +vi.mock("@/lib/stripe", () => ({ + getStripe: () => ({ webhooks: { constructEvent: mocks.constructEvent } }), +})); +vi.mock("@/lib/orders-store", () => ({ + getOrder: mocks.getOrder, + setStatus: mocks.setStatus, +})); + +import { POST } from "@/app/api/webhooks/stripe/route"; + +const savedWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + +beforeEach(() => { + process.env.STRIPE_WEBHOOK_SECRET = "whsec_fake_local_regression"; + mocks.constructEvent.mockReset(); + mocks.getOrder.mockReset(); + mocks.setStatus.mockReset(); + mocks.setStatus.mockResolvedValue({ order_id: "order-1" }); +}); + +afterEach(() => { + if (savedWebhookSecret === undefined) delete process.env.STRIPE_WEBHOOK_SECRET; + else process.env.STRIPE_WEBHOOK_SECRET = savedWebhookSecret; +}); + +function delivery() { + return new NextRequest("https://0pt.app/api/webhooks/stripe", { + method: "POST", + headers: { "stripe-signature": "signed-in-test" }, + body: "{}", + }); +} + +function session(kind: "deposit" | "balance", paymentStatus: string, amount: number) { + mocks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { object: { + metadata: { order_id: "order-1", kind }, + payment_status: paymentStatus, + amount_total: amount, + currency: "sek", + } }, + }); +} + +it("advances a matching paid deposit only after a verified event", async () => { + session("deposit", "paid", 5000); + mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "draft", deposit_ore: 5000, balance_ore: 5000 }); + + expect((await POST(delivery())).status).toBe(200); + expect(mocks.setStatus).toHaveBeenCalledWith("order-1", "in_production"); +}); + +it("never advances an unpaid or amount-mismatched deposit", async () => { + mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "draft", deposit_ore: 5000, balance_ore: 5000 }); + session("deposit", "unpaid", 5000); + expect((await POST(delivery())).status).toBe(503); + session("deposit", "paid", 4999); + expect((await POST(delivery())).status).toBe(422); + expect(mocks.setStatus).not.toHaveBeenCalled(); +}); + +it("marks a matching paid balance delivered only after acceptance", async () => { + session("balance", "paid", 5000); + mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "accepted", deposit_ore: 5000, balance_ore: 5000 }); + + expect((await POST(delivery())).status).toBe(200); + expect(mocks.setStatus).toHaveBeenCalledWith("order-1", "delivered"); +}); From 7ce92827c06a4bfbe6cb9a9ad952a3efc4cc7e75 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Tue, 15 Sep 2026 18:52:23 +0200 Subject: [PATCH 3/7] fix: close demo payment bypass and guard order transitions --- app/api/orders/[id]/route.ts | 27 ++++++++-- app/api/payments/[order_id]/release/route.ts | 3 ++ app/api/webhooks/stripe/route.ts | 6 +-- app/bestall/forskott/page.tsx | 24 ++++----- docs/architecture/ARCHITECTURE.md | 7 +++ lib/orders-store.ts | 17 ++++++ tests/api/order-status-transitions.test.ts | 54 +++++++++++++++++++ ...ripe-balance-no-premature-delivery.test.ts | 17 +++++- tests/api/stripe-webhook-payment.test.ts | 14 ++--- tests/lib/orders-status-cas.test.ts | 9 ++++ 10 files changed, 149 insertions(+), 29 deletions(-) create mode 100644 tests/api/order-status-transitions.test.ts create mode 100644 tests/lib/orders-status-cas.test.ts diff --git a/app/api/orders/[id]/route.ts b/app/api/orders/[id]/route.ts index 2d6a248..4daf1c8 100644 --- a/app/api/orders/[id]/route.ts +++ b/app/api/orders/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { getOrder, setStatus } from "@/lib/orders-store"; +import { getOrder, setStatusIfCurrent } from "@/lib/orders-store"; +import { requireRouteAuth } from "@/src/auth/require-route-auth"; // GET /api/orders/{id} export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) { @@ -12,6 +13,8 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string // PATCH /api/orders/{id} — status transition (deposit → in_production → review → accepted ...). // Escrow rule (docs/01): balance never charged before 'accepted'. Stripe wiring follows. export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const auth = await requireRouteAuth(req); + if (auth.response) return auth.response; const { id } = await ctx.params; let body: { status?: string }; try { @@ -22,11 +25,27 @@ export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: strin if (!body || typeof body !== "object") { return NextResponse.json({ error: { code: "bad_json", message: "Invalid JSON body" } }, { status: 400 }); } - const allowed = ["draft", "quoted", "deposit_pending", "in_production", "review", "revision", "accepted", "delivered"]; + // Paid states are owned exclusively by the signed, matching Stripe webhook. + if (body.status === "in_production" || body.status === "delivered") { + return NextResponse.json({ error: { code: "payment_event_required" } }, { status: 403 }); + } + const allowed = ["quoted", "deposit_pending", "review", "revision", "accepted"]; if (!body.status || !allowed.includes(body.status)) { return NextResponse.json({ error: { code: "bad_status", message: "Ogiltig status" } }, { status: 400 }); } - const order = await setStatus(id, body.status); + const order = await getOrder(id); if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 }); - return NextResponse.json(order); + const previous: Record = { + quoted: ["draft"], + deposit_pending: ["draft", "quoted"], + review: ["in_production"], + revision: ["review"], + accepted: ["review", "revision"], + }; + if (!previous[body.status]?.includes(order.status)) { + return NextResponse.json({ error: { code: "invalid_transition" } }, { status: 409 }); + } + const updated = await setStatusIfCurrent(id, order.status, body.status); + if (!updated) return NextResponse.json({ error: { code: "stale_transition" } }, { status: 409 }); + return NextResponse.json(updated); } diff --git a/app/api/payments/[order_id]/release/route.ts b/app/api/payments/[order_id]/release/route.ts index d781e57..1915d8e 100644 --- a/app/api/payments/[order_id]/release/route.ts +++ b/app/api/payments/[order_id]/release/route.ts @@ -1,10 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; import { getStripe } from "@/lib/stripe"; import { getOrder } from "@/lib/orders-store"; +import { requireRouteAuth } from "@/src/auth/require-route-auth"; // POST /api/payments/{order_id}/release (docs/04) — charge the held 50% balance. // Called on ACCEPT only; escrow balance is never charged before approval. export async function POST(req: NextRequest, ctx: { params: Promise<{ order_id: string }> }) { + const auth = await requireRouteAuth(req); + if (auth.response) return auth.response; const { order_id } = await ctx.params; const order = await getOrder(order_id); if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 }); diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index 6638702..acfba19 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import type Stripe from "stripe"; import { getStripe } from "@/lib/stripe"; -import { getOrder, setStatus } from "@/lib/orders-store"; +import { getOrder, setStatusIfCurrent } from "@/lib/orders-store"; // POST /api/webhooks/stripe (docs/04) — verify signature, advance order status. export async function POST(req: NextRequest) { @@ -43,7 +43,7 @@ export async function POST(req: NextRequest) { if (order.status !== "accepted" && order.status !== "delivered") { return NextResponse.json({ error: "invalid_order_state" }, { status: 409 }); } - if (order.status === "accepted" && !(await setStatus(oid, "delivered"))) { + if (order.status === "accepted" && !(await setStatusIfCurrent(oid, "accepted", "delivered"))) { return NextResponse.json({ error: "order_update_failed" }, { status: 503 }); } } else { @@ -51,7 +51,7 @@ export async function POST(req: NextRequest) { if (order.status !== "draft" && order.status !== "deposit_pending" && !alreadyAdvanced) { return NextResponse.json({ error: "invalid_order_state" }, { status: 409 }); } - if (!alreadyAdvanced && !(await setStatus(oid, "in_production"))) { + if (!alreadyAdvanced && !(await setStatusIfCurrent(oid, order.status, "in_production"))) { return NextResponse.json({ error: "order_update_failed" }, { status: 503 }); } } diff --git a/app/bestall/forskott/page.tsx b/app/bestall/forskott/page.tsx index db93b98..c6f514a 100644 --- a/app/bestall/forskott/page.tsx +++ b/app/bestall/forskott/page.tsx @@ -1,7 +1,6 @@ "use client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { OrderStepper } from "@/components/order-stepper"; import { useT } from "@/components/locale-provider"; @@ -58,8 +57,8 @@ export default function DepositPage() { }); const [orderId, setOrderId] = useState(null); const [paying, setPaying] = useState(false); + const [paymentError, setPaymentError] = useState(false); const reveal = useReveal(); - const router = useRouter(); useEffect(() => { try { @@ -103,6 +102,7 @@ export default function DepositPage() { async function pay() { if (paying) return; setPaying(true); + setPaymentError(false); if (orderId) { try { const r = await fetch(`/api/payments/${orderId}/deposit`, { method: "POST" }); @@ -111,18 +111,11 @@ export default function DepositPage() { if (body.url) { window.location.href = body.url; return; } } } catch {} - // Stripe not configured — advance status directly (demo mode) - try { - await fetch(`/api/orders/${orderId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: "in_production" }), - }); - } catch {} - router.push(`/order/${orderId}`); - return; } - router.push("/order/SX-4471"); + // No Checkout URL means no payment. Keep the draft and let the customer + // retry; never simulate a successful payment or redirect to a demo order. + setPaymentError(true); + setPaying(false); } return ( @@ -283,6 +276,11 @@ export default function DepositPage() { > {paying ? t({ en: "Starting…", sv: "Startar…" }) : t({ en: "Pay. Start. →", sv: "Betala. Starta. →" })} + {paymentError && ( +

+ {t({ en: "Payment could not be started. Your order is still a draft; please try again.", sv: "Betalningen kunde inte startas. Din order är fortfarande ett utkast; försök igen." })} +

+ )} { + const sql = getSqlForDurableStore("0pt.orders"); + if (sql) { + const rows = await sql` + UPDATE orders SET status = ${next}, updated_at = now() + WHERE order_id::text = ${id} AND status = ${current} + RETURNING order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at`; + return rows[0] ? coerceOrder(rows[0]) : null; + } + const order = mem.get(id); + if (!order || order.status !== current) return null; + order.status = next; + mem.set(id, order); + return order; +} diff --git a/tests/api/order-status-transitions.test.ts b/tests/api/order-status-transitions.test.ts new file mode 100644 index 0000000..7ae7104 --- /dev/null +++ b/tests/api/order-status-transitions.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), + getOrder: vi.fn(), + setStatusIfCurrent: vi.fn(), +})); +vi.mock("@/src/auth/require-route-auth", () => ({ requireRouteAuth: mocks.auth })); +vi.mock("@/lib/orders-store", () => ({ + getOrder: mocks.getOrder, + setStatusIfCurrent: mocks.setStatusIfCurrent, +})); + +import { PATCH } from "@/app/api/orders/[id]/route"; + +beforeEach(() => { + mocks.auth.mockReset().mockResolvedValue({ ctx: { tenantId: "verified-tenant" } }); + mocks.getOrder.mockReset().mockResolvedValue({ order_id: "order-1", status: "draft" }); + mocks.setStatusIfCurrent.mockReset().mockResolvedValue({ order_id: "order-1", status: "accepted" }); +}); + +function request(status: string) { + return new NextRequest("https://0pt.app/api/orders/order-1", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); +} + +const ctx = { params: Promise.resolve({ id: "order-1" }) }; + +it("requires verified auth before changing an order", async () => { + mocks.auth.mockResolvedValue({ response: NextResponse.json({ error: "unauthenticated" }, { status: 401 }) }); + expect((await PATCH(request("accepted"), ctx)).status).toBe(401); + expect(mocks.getOrder).not.toHaveBeenCalled(); +}); + +it("rejects client writes to payment-confirmed states", async () => { + expect((await PATCH(request("in_production"), ctx)).status).toBe(403); + expect((await PATCH(request("delivered"), ctx)).status).toBe(403); + expect(mocks.setStatusIfCurrent).not.toHaveBeenCalled(); +}); + +it("cannot jump from draft directly to accepted", async () => { + expect((await PATCH(request("accepted"), ctx)).status).toBe(409); + expect(mocks.setStatusIfCurrent).not.toHaveBeenCalled(); +}); + +it("uses an atomic compare-and-set transition after review", async () => { + mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "review" }); + expect((await PATCH(request("accepted"), ctx)).status).toBe(200); + expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "review", "accepted"); +}); diff --git a/tests/api/stripe-balance-no-premature-delivery.test.ts b/tests/api/stripe-balance-no-premature-delivery.test.ts index 6ae1bad..be1b636 100644 --- a/tests/api/stripe-balance-no-premature-delivery.test.ts +++ b/tests/api/stripe-balance-no-premature-delivery.test.ts @@ -1,7 +1,7 @@ import { expect, it, vi } from "vitest"; -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; -const mocks = vi.hoisted(() => ({ create: vi.fn(), setStatus: vi.fn() })); +const mocks = vi.hoisted(() => ({ create: vi.fn(), setStatus: vi.fn(), auth: vi.fn() })); vi.mock("@/lib/stripe", () => ({ getStripe: () => ({ checkout: { sessions: { create: mocks.create } } }), })); @@ -9,10 +9,14 @@ vi.mock("@/lib/orders-store", () => ({ getOrder: vi.fn().mockResolvedValue({ order_id: "order-1", status: "accepted", balance_ore: 5000 }), setStatus: mocks.setStatus, })); +vi.mock("@/src/auth/require-route-auth", () => ({ + requireRouteAuth: mocks.auth, +})); import { POST } from "@/app/api/payments/[order_id]/release/route"; it("does not mark delivery complete merely by creating a balance Checkout Session", async () => { + mocks.auth.mockResolvedValue({ ctx: { tenantId: "verified-tenant" } }); mocks.create.mockResolvedValue({ url: "https://checkout.stripe.com/test" }); const req = new NextRequest("https://0pt.app/api/payments/order-1/release", { method: "POST" }); @@ -23,3 +27,12 @@ it("does not mark delivery complete merely by creating a balance Checkout Sessio })); expect(mocks.setStatus).not.toHaveBeenCalled(); }); + +it("rejects an unauthenticated balance Checkout request", async () => { + mocks.create.mockClear(); + mocks.auth.mockResolvedValue({ response: NextResponse.json({ error: "unauthenticated" }, { status: 401 }) }); + const req = new NextRequest("https://0pt.app/api/payments/order-1/release", { method: "POST" }); + + expect((await POST(req, { params: Promise.resolve({ order_id: "order-1" }) })).status).toBe(401); + expect(mocks.create).not.toHaveBeenCalled(); +}); diff --git a/tests/api/stripe-webhook-payment.test.ts b/tests/api/stripe-webhook-payment.test.ts index 146eac2..5a4170f 100644 --- a/tests/api/stripe-webhook-payment.test.ts +++ b/tests/api/stripe-webhook-payment.test.ts @@ -4,7 +4,7 @@ import { NextRequest } from "next/server"; const mocks = vi.hoisted(() => ({ constructEvent: vi.fn(), getOrder: vi.fn(), - setStatus: vi.fn(), + setStatusIfCurrent: vi.fn(), })); vi.mock("@/lib/stripe", () => ({ @@ -12,7 +12,7 @@ vi.mock("@/lib/stripe", () => ({ })); vi.mock("@/lib/orders-store", () => ({ getOrder: mocks.getOrder, - setStatus: mocks.setStatus, + setStatusIfCurrent: mocks.setStatusIfCurrent, })); import { POST } from "@/app/api/webhooks/stripe/route"; @@ -23,8 +23,8 @@ beforeEach(() => { process.env.STRIPE_WEBHOOK_SECRET = "whsec_fake_local_regression"; mocks.constructEvent.mockReset(); mocks.getOrder.mockReset(); - mocks.setStatus.mockReset(); - mocks.setStatus.mockResolvedValue({ order_id: "order-1" }); + mocks.setStatusIfCurrent.mockReset(); + mocks.setStatusIfCurrent.mockResolvedValue({ order_id: "order-1" }); }); afterEach(() => { @@ -57,7 +57,7 @@ it("advances a matching paid deposit only after a verified event", async () => { mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "draft", deposit_ore: 5000, balance_ore: 5000 }); expect((await POST(delivery())).status).toBe(200); - expect(mocks.setStatus).toHaveBeenCalledWith("order-1", "in_production"); + expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "draft", "in_production"); }); it("never advances an unpaid or amount-mismatched deposit", async () => { @@ -66,7 +66,7 @@ it("never advances an unpaid or amount-mismatched deposit", async () => { expect((await POST(delivery())).status).toBe(503); session("deposit", "paid", 4999); expect((await POST(delivery())).status).toBe(422); - expect(mocks.setStatus).not.toHaveBeenCalled(); + expect(mocks.setStatusIfCurrent).not.toHaveBeenCalled(); }); it("marks a matching paid balance delivered only after acceptance", async () => { @@ -74,5 +74,5 @@ it("marks a matching paid balance delivered only after acceptance", async () => mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "accepted", deposit_ore: 5000, balance_ore: 5000 }); expect((await POST(delivery())).status).toBe(200); - expect(mocks.setStatus).toHaveBeenCalledWith("order-1", "delivered"); + expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "accepted", "delivered"); }); diff --git a/tests/lib/orders-status-cas.test.ts b/tests/lib/orders-status-cas.test.ts new file mode 100644 index 0000000..47625fe --- /dev/null +++ b/tests/lib/orders-status-cas.test.ts @@ -0,0 +1,9 @@ +import { expect, it } from "vitest"; +import { createOrder, getOrder, setStatusIfCurrent } from "@/lib/orders-store"; + +it("rejects a stale status writer in the in-memory fallback", async () => { + const { order } = await createOrder("custom", { markets: ["SE"], type: "Bolag" }); + expect((await setStatusIfCurrent(order.order_id, "draft", "quoted"))?.status).toBe("quoted"); + expect(await setStatusIfCurrent(order.order_id, "draft", "accepted")).toBeNull(); + expect((await getOrder(order.order_id))?.status).toBe("quoted"); +}); From c7f8b3f651877c0d3973444508e3f34d67b19ce6 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Tue, 15 Sep 2026 18:53:35 +0200 Subject: [PATCH 4/7] docs: gate 0pt cutover on buyer-bound live payments --- prompts/004-deploy-cutover.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/prompts/004-deploy-cutover.md b/prompts/004-deploy-cutover.md index f5629f2..cd16a2b 100644 --- a/prompts/004-deploy-cutover.md +++ b/prompts/004-deploy-cutover.md @@ -36,6 +36,15 @@ Infra-provisionering + DNS + en skyddad-sökväg-PR (`cd.yml`). Rör aldrig - `COOLIFY_APP_ID` ifylld i `.gitea/workflows/cd.yml`? (skyddad sökväg → PR) - `COOLIFY_TOKEN`-secret satt? `cd.yml` är röd medvetet tills dess. - Secrets i Infisical-projektet 0pt (ej Vercel-env)? +- Live payments: PR #66 reviewed/merged and issue #67 resolved with + verified buyer/tenant ownership, production Zitadel UI session, and + staging paid/unpaid/cancelled webhook E2E? If not, keep live Stripe + credentials absent and payment routes disabled. A green unit suite alone + does not authorize real customer charges. +- Stripe live API key and endpoint-specific live webhook signing secret must + be acquired from the verified SIAX Technology AB account and injected only + into the intended server-side 0pt environment. Never reuse sandbox keys, + expose values in logs/PRs, or create empty vault placeholders. ## 2. Sekvens From cecc2bc637c3822af88fce0fc032b4bd04a045ae Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Tue, 15 Sep 2026 19:09:53 +0200 Subject: [PATCH 5/7] fix(payments): authenticate order detail and deposit checkout --- app/api/orders/[id]/route.ts | 4 +- app/api/payments/[order_id]/deposit/route.ts | 3 + docs/architecture/ADR-001-order-ownership.md | 58 ++++++++++++++++++++ docs/architecture/ARCHITECTURE.md | 37 +++++++------ tests/api/anonymous-routes-auth.test.ts | 16 ++++++ tests/api/stripe-deposit-metadata.test.ts | 3 + 6 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 docs/architecture/ADR-001-order-ownership.md diff --git a/app/api/orders/[id]/route.ts b/app/api/orders/[id]/route.ts index 4daf1c8..f029c03 100644 --- a/app/api/orders/[id]/route.ts +++ b/app/api/orders/[id]/route.ts @@ -3,7 +3,9 @@ import { getOrder, setStatusIfCurrent } from "@/lib/orders-store"; import { requireRouteAuth } from "@/src/auth/require-route-auth"; // GET /api/orders/{id} -export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) { +export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const auth = await requireRouteAuth(req); + if (auth.response) return auth.response; const { id } = await ctx.params; const order = await getOrder(id); if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 }); diff --git a/app/api/payments/[order_id]/deposit/route.ts b/app/api/payments/[order_id]/deposit/route.ts index 33715ec..2e1e5d8 100644 --- a/app/api/payments/[order_id]/deposit/route.ts +++ b/app/api/payments/[order_id]/deposit/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getOrder } from "@/lib/orders-store"; +import { requireRouteAuth } from "@/src/auth/require-route-auth"; // POST /api/payments/[order_id]/deposit // Returns { url } for Stripe Checkout. Returns 501 when STRIPE_SECRET_KEY is absent. @@ -7,6 +8,8 @@ export async function POST( request: NextRequest, { params }: { params: Promise<{ order_id: string }> }, ) { + const auth = await requireRouteAuth(request); + if (auth.response) return auth.response; const { order_id } = await params; const secretKey = process.env.STRIPE_SECRET_KEY; diff --git a/docs/architecture/ADR-001-order-ownership.md b/docs/architecture/ADR-001-order-ownership.md new file mode 100644 index 0000000..f41b873 --- /dev/null +++ b/docs/architecture/ADR-001-order-ownership.md @@ -0,0 +1,58 @@ +# ADR-001: Verified tenant ownership for payment orders + +**Status:** Proposed +**Date:** 2026-09-15 +**Deciders:** SIAX 0pt product and platform owners + +## Context + +The existing order configurator creates anonymous drafts, stores no verified +buyer or tenant on them, and later refers to an order by its ID. A valid +Zitadel principal proves who is calling but does not prove that this caller +owns that particular order. An order ID is an identifier, not an authorization +secret. The nullable `orders.org_id` UUID cannot directly hold the verified +tenant ID, which is a string. The repository has no automated SQL migration +runner, and legacy rows may remain unowned. + +The payment flow must remain API-first and work consistently in UI, CLI and +MCP. It must not make anonymous drafts payable by treating a guessed or leaked +ID as ownership. + +## Decision + +Require a verified Zitadel principal before creating a payable order. Persist +`tenant_id` and buyer subject on each new order in a database migration. +Every read, status mutation and Checkout creation must use a tenant-scoped +query predicate; a cross-tenant order returns 404. The Stripe webhook uses +its verified signature plus recorded order ID and payment facts to advance +status, never a browser-supplied tenant claim. Legacy null-owned orders remain +unpayable until an audited, explicit migration or support workflow binds +them to a verified buyer. Keep the anonymous configurator as an estimate +without creating a payable order until sign-in. + +## Options considered + +| Option | Complexity | Scale | Consequence | +|---|---|---|---| +| Authenticate only, lookup by order ID | Low | High | Rejected: any signed-in user can access another order. | +| Bearer claim token for anonymous drafts | Medium | High | Requires capability issuance, hashing, secure browser storage and recovery. Not selected for initial production. | +| Verified tenant + buyer subject at order creation | Medium | High | Chosen: explicit ownership at source, shared API boundary. | + +## Consequences + +- UI must obtain a Zitadel session before order creation and send credentials + to the order and payment APIs; estimates stay anonymous. +- SQL migration must be applied and verified before the scoped code is deployed. +- Existing unowned rows are not silently assigned to a tenant. +- Idempotency keys must be scoped to the verified tenant. +- Admin operations require an explicit, audited role-bound path rather than + bypassing the buyer predicate. + +## Action items + +1. [ ] Build and verify the migration and deploy runner. +2. [ ] Bind create-order to verified tenant and buyer subject; scope idempotency. +3. [ ] Scope GET/PATCH/deposit/release to tenant at the SQL boundary. +4. [ ] Wire UI Zitadel login/session and CLI/MCP bearer auth; keep anonymous estimates. +5. [ ] Test cross-tenant 404, legacy unowned rejection, webhook, and staging + purchase/refund end-to-end before enabling live Stripe. diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index f528720..48cbec2 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -138,11 +138,11 @@ Risknivå = SIAX L0–L5-taxonomin, bedömd av mig utifrån vad handlern gör. | Sanktions-/PEP-screening | `GET /api/screen?q=` | L2 | Nej — proxar mot OpenSanctions | | AI-parsning av fritext | `POST /api/ai/parse` | L2 | Nej | | Skapa order | `POST /api/orders` | L2 | Nej | -| **Lista alla ordrar** | `GET /api/orders` | **L3** | **Nej** — ingen org-scoping *(live: 200, `[]`)* | -| Läs/ändra order | `GET,PATCH /api/orders/{id}` | L3 | Nej | -| **Adminmetrik (orderpipeline, belopp)** | `GET /api/admin/metrics` | **L3** | **Nej** *(live-verifierat: 200 anonymt)* | -| Deposition (Stripe checkout) | `POST /api/payments/{order_id}/deposit` | **L4** | Nej | -| **Frigör escrow-slutbetalning** | `POST /api/payments/{order_id}/release` | **L4** | **Nej** — sätter dessutom status `delivered` (§8 S4) | +| **Lista alla ordrar** | `GET /api/orders` | **L3** | Route-auth i source; org-scoping saknas | +| Läs/ändra order | `GET,PATCH /api/orders/{id}` | L3 | Route-auth i source; orderägande saknas | +| **Adminmetrik (orderpipeline, belopp)** | `GET /api/admin/metrics` | **L3** | Route-auth i source; äldre liveobservation ej omverifierad | +| Deposition (Stripe checkout) | `POST /api/payments/{order_id}/deposit` | **L4** | Route-auth i source; orderägande saknas | +| **Frigör escrow-slutbetalning** | `POST /api/payments/{order_id}/release` | **L4** | Route-auth i source; `delivered` först efter betald webhook; orderägande saknas | | Stripe-webhook | `POST /api/webhooks/stripe` | L3 | Signaturkontroll via `STRIPE_WEBHOOK_SECRET` | | Prenumerationer | `GET,POST /api/subscriptions` | L2 | Nej | | Filuppladdning | `GET,POST /api/uploads` | L3 | Nej (`501` utan R2) | @@ -356,6 +356,9 @@ Om/när Postgres provisioneras måste detta lösas separat — jfr estate-blocke också verifierad auth. **Fortfarande öppet före live-läge:** orderägarskap och tenant-bindning vid checkout/acceptance behöver implementeras och verifieras; en verifierad identitet ensam är inte per-order auktorisation. + Enskild order-GET och deposit-checkout kräver nu också route-auth; dessa + två vägar är inte längre anonyma i source. Ägarbindningens beslut och + beroenden dokumenteras i `ADR-001-order-ownership.md`. - `SECURITY.md` finns med rapporteringsväg (`security@siax.io`). ### KÄNDA LUCKOR @@ -387,18 +390,20 @@ alltså en innehavare av ett giltigt token för tenant A skicka `X-Tenant-Id`-konflikt hjälper inte — den kontrollerar bara *headern*, inte *bodyn*. **Tenantisolering är inte implementerad.** -**S3 — Hela det icke-`/api/0pt`-prefixade API:t saknar auth.** -Matchern täcker bara `/api/0pt/:path*`. Ordrar, adminmetrik, betalningar, -prenumerationer, uppladdningar, AI-parsning och DB-grants är oskyddade i koden. -*(live-verifierat: `GET /api/admin/metrics` och `GET /api/orders` svarar 200 -anonymt.)* De är tomma idag enbart för att produktionsinstansen saknar databas. +**S3 — Historiskt anonymt icke-`/api/0pt`-API; delvis åtgärdat i source.** +Matchern täcker bara `/api/0pt/:path*`. Orderkö/adminmetrik, enskild +order-GET, deposit- och release-checkout samt order-PATCH har nu explicita +route-auth-grindar i source. De äldre live-observationerna av anonymt 200 +beskriver tidigare deploy, inte verifierat nuläge. Övriga icke-prefixade +API:er kräver egen inventering, och per-order tenantägande är fortfarande +öppet enligt ADR-001. -**S4 — Anonym anropare kan trigga escrow-slutbetalning och flagga leverans.** -`POST /api/payments/{order_id}/release` kräver ingen identitet. Den skapar en -Stripe-checkout på `order.balance_ore` och kör därefter -`setStatus(order_id, "delivered")` **oavsett om betalningen genomförs**. -Statusövergången sker alltså på begäran, inte på betalning. (Skyddas i praktiken -idag bara av att `getOrder` inte hittar något utan databas.) +**S4 — Historisk escrow-bugg åtgärdad i source; tenantägande återstår.** +`POST /api/payments/{order_id}/release` kräver nu route-auth och skapar +Checkout först efter `accepted`. En skapad session sätter inte `delivered`; +endast en signerad, betald och matchande Stripe-webhook får göra det. +Cross-tenant-skydd av den enskilda ordern saknas fortfarande, så livebetalning +får inte aktiveras innan ADR-001 är genomförd och verifierad. **S5 — CL0UD-policyklienten är fail-open utan produktionsspärr.** `src/clients/cl0ud-client.ts:45` — saknas `CL0UD_URL` returneras alltid diff --git a/tests/api/anonymous-routes-auth.test.ts b/tests/api/anonymous-routes-auth.test.ts index 0ef2fd3..712ea48 100644 --- a/tests/api/anonymous-routes-auth.test.ts +++ b/tests/api/anonymous-routes-auth.test.ts @@ -15,7 +15,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { NextRequest } from 'next/server'; import { GET as ordersGET } from '@/app/api/orders/route'; +import { GET as orderGET } from '@/app/api/orders/[id]/route'; import { GET as adminMetricsGET } from '@/app/api/admin/metrics/route'; +import { POST as depositPOST } from '@/app/api/payments/[order_id]/deposit/route'; function req(url: string, headers: Record = {}): NextRequest { return new NextRequest(new URL(url, 'http://localhost'), { headers }); @@ -49,6 +51,20 @@ describe('anonymous-access regression: /api/orders (GET) and /api/admin/metrics expect(res.status).toBe(401); }); + it('GET /api/orders/{id} rejects anonymous and spoofed-tenant reads before loading an order', async () => { + const params = { params: Promise.resolve({ id: 'order-unknown' }) }; + expect((await orderGET(req('/api/orders/order-unknown'), params)).status).toBe(401); + expect((await orderGET(req('/api/orders/order-unknown', { + 'x-0pt-verified-tenant': 'tenant_attacker', + }), params)).status).toBe(401); + }); + + it('deposit checkout rejects anonymous callers before checking Stripe credentials or order id', async () => { + const request = req('/api/payments/order-unknown/deposit'); + const result = await depositPOST(request, { params: Promise.resolve({ order_id: 'order-unknown' }) }); + expect(result.status).toBe(401); + }); + it('REGRESSION: GET /api/admin/metrics 401s with no Authorization header (was: 200 + financial aggregates)', async () => { const res = await adminMetricsGET(req('/api/admin/metrics')); expect(res.status).toBe(401); diff --git a/tests/api/stripe-deposit-metadata.test.ts b/tests/api/stripe-deposit-metadata.test.ts index aa40223..fea1cfd 100644 --- a/tests/api/stripe-deposit-metadata.test.ts +++ b/tests/api/stripe-deposit-metadata.test.ts @@ -4,6 +4,9 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/orders-store", () => ({ getOrder: vi.fn().mockResolvedValue({ order_id: "order-1", deposit_ore: 5000 }), })); +vi.mock("@/src/auth/require-route-auth", () => ({ + requireRouteAuth: vi.fn().mockResolvedValue({ ctx: { tenantId: "tenant-test" } }), +})); import { POST } from "@/app/api/payments/[order_id]/deposit/route"; From f976f3653046c0680423cf139e2496979811cbf9 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Tue, 15 Sep 2026 19:35:25 +0200 Subject: [PATCH 6/7] fix(ci): avoid duplicate live integration jobs on branch push --- .gitea/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2f30de9..05ed9df 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -61,6 +61,13 @@ jobs: # that proves the run identity, freeze it into the URL env var, # * no match = hard failure, never a silent fallback or skip. integration: + # Gitea currently ignores workflow concurrency. A branch push followed by + # its PR synchronize event otherwise starts TWO 512 MiB OpenSearch JVMs + # and ClickHouse suites on the same runner fleet at once; both timed out + # creating their first index (runs 28904/28906). Keep the blocking LIVE + # integration proof on every PR, on main push, and on manual dispatch; + # ordinary branch pushes still run blocking quality/security. + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest services: opt-ci-opensearch: From a1557b2a7d2fec697508b567575d2e0fb8b63007 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Tue, 15 Sep 2026 19:53:58 +0200 Subject: [PATCH 7/7] feat(payments): bind orders to verified tenant and buyer --- app/api/orders/[id]/route.ts | 14 +-- app/api/orders/route.ts | 17 ++-- app/api/payments/[order_id]/deposit/route.ts | 8 +- app/api/payments/[order_id]/release/route.ts | 8 +- app/api/webhooks/stripe/route.ts | 5 + docs/architecture/ADR-001-order-ownership.md | 12 ++- docs/architecture/ARCHITECTURE.md | 36 +++---- docs/architecture/ORDER-OWNERSHIP-CUTOVER.md | 43 +++++++++ lib/orders-store.ts | 96 +++++++++++++++---- sql/011_orders_tenant_ownership.sql | 17 ++++ src/auth/require-order-owner.ts | 20 ++++ tests/api/anonymous-routes-auth.test.ts | 4 +- tests/api/order-status-transitions.test.ts | 11 ++- tests/api/orders-idempotency.test.ts | 5 +- ...ripe-balance-no-premature-delivery.test.ts | 10 +- tests/api/stripe-deposit-metadata.test.ts | 8 +- tests/api/stripe-webhook-payment.test.ts | 14 ++- tests/auth/order-owner.test.ts | 29 ++++++ tests/lib/orders-ownership.test.ts | 45 +++++++++ 19 files changed, 326 insertions(+), 76 deletions(-) create mode 100644 docs/architecture/ORDER-OWNERSHIP-CUTOVER.md create mode 100644 sql/011_orders_tenant_ownership.sql create mode 100644 src/auth/require-order-owner.ts create mode 100644 tests/auth/order-owner.test.ts create mode 100644 tests/lib/orders-ownership.test.ts diff --git a/app/api/orders/[id]/route.ts b/app/api/orders/[id]/route.ts index f029c03..ce317d6 100644 --- a/app/api/orders/[id]/route.ts +++ b/app/api/orders/[id]/route.ts @@ -1,13 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; -import { getOrder, setStatusIfCurrent } from "@/lib/orders-store"; -import { requireRouteAuth } from "@/src/auth/require-route-auth"; +import { getOwnedOrder, setOwnedStatusIfCurrent } from "@/lib/orders-store"; +import { requireOrderOwner } from "@/src/auth/require-order-owner"; // GET /api/orders/{id} export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { - const auth = await requireRouteAuth(req); + const auth = await requireOrderOwner(req); if (auth.response) return auth.response; const { id } = await ctx.params; - const order = await getOrder(id); + const order = await getOwnedOrder(id, auth.owner.tenantId); if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 }); return NextResponse.json(order); } @@ -15,7 +15,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string // PATCH /api/orders/{id} — status transition (deposit → in_production → review → accepted ...). // Escrow rule (docs/01): balance never charged before 'accepted'. Stripe wiring follows. export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { - const auth = await requireRouteAuth(req); + const auth = await requireOrderOwner(req); if (auth.response) return auth.response; const { id } = await ctx.params; let body: { status?: string }; @@ -35,7 +35,7 @@ export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: strin if (!body.status || !allowed.includes(body.status)) { return NextResponse.json({ error: { code: "bad_status", message: "Ogiltig status" } }, { status: 400 }); } - const order = await getOrder(id); + const order = await getOwnedOrder(id, auth.owner.tenantId); if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 }); const previous: Record = { quoted: ["draft"], @@ -47,7 +47,7 @@ export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: strin if (!previous[body.status]?.includes(order.status)) { return NextResponse.json({ error: { code: "invalid_transition" } }, { status: 409 }); } - const updated = await setStatusIfCurrent(id, order.status, body.status); + const updated = await setOwnedStatusIfCurrent(id, auth.owner.tenantId, order.status, body.status); if (!updated) return NextResponse.json({ error: { code: "stale_transition" } }, { status: 409 }); return NextResponse.json(updated); } diff --git a/app/api/orders/route.ts b/app/api/orders/route.ts index 7263cdc..3c2941b 100644 --- a/app/api/orders/route.ts +++ b/app/api/orders/route.ts @@ -1,11 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { createOrder, listOrders, IdempotencyKeyConflictError, type OrderConfig } from "@/lib/orders-store"; -import { requireRouteAuth } from "@/src/auth/require-route-auth"; +import { createOrder, listOwnedOrders, IdempotencyKeyConflictError, type OrderConfig } from "@/lib/orders-store"; +import { requireOrderOwner } from "@/src/auth/require-order-owner"; // POST /api/orders (docs/04) — create a draft order from a configurator config. -// Intentionally anonymous: a prospect drafts/estimates a job before signing in -// to pay the deposit (see docs/01, the configurator flow). Only reading back -// the order queue (GET, below) is a data-exposure risk. +// A payable order is created only for a verified Zitadel tenant/buyer. +// Anonymous prospects may obtain estimates but cannot mint payment orders. // // P014: honors an optional `Idempotency-Key` header (the convention this // skill's own docs already name -- "Idempotency-Key on order/payment POSTs") @@ -15,6 +14,8 @@ import { requireRouteAuth } from "@/src/auth/require-route-auth"; // recon "Duplicate delivery idempotent: NOT MET"). Omitting the header keeps // the pre-P014 behavior exactly: every call creates a fresh order. export async function POST(req: NextRequest) { + const auth = await requireOrderOwner(req); + if (auth.response) return auth.response; let body: { flow?: string; config?: OrderConfig }; try { body = await req.json(); @@ -28,7 +29,7 @@ export async function POST(req: NextRequest) { const config = body.config || {}; const idempotencyKey = req.headers.get("idempotency-key") ?? undefined; try { - const { order, replayed } = await createOrder(flow, config, idempotencyKey); + const { order, replayed } = await createOrder(flow, config, idempotencyKey, auth.owner); return NextResponse.json( { order_id: order.order_id, status: order.status, deposit_ore: order.deposit_ore, total_ore: order.total_ore }, { status: replayed ? 200 : 201 }, @@ -47,8 +48,8 @@ export async function POST(req: NextRequest) { // src/auth/require-route-auth.ts for why the request is re-verified rather // than trusting a forwarded header. export async function GET(req: NextRequest) { - const auth = await requireRouteAuth(req); + const auth = await requireOrderOwner(req); if (auth.response) return auth.response; - const orders = await listOrders(); + const orders = await listOwnedOrders(auth.owner.tenantId); return NextResponse.json(orders); } diff --git a/app/api/payments/[order_id]/deposit/route.ts b/app/api/payments/[order_id]/deposit/route.ts index 2e1e5d8..89d96cb 100644 --- a/app/api/payments/[order_id]/deposit/route.ts +++ b/app/api/payments/[order_id]/deposit/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { getOrder } from "@/lib/orders-store"; -import { requireRouteAuth } from "@/src/auth/require-route-auth"; +import { getOwnedOrder } from "@/lib/orders-store"; +import { requireOrderOwner } from "@/src/auth/require-order-owner"; // POST /api/payments/[order_id]/deposit // Returns { url } for Stripe Checkout. Returns 501 when STRIPE_SECRET_KEY is absent. @@ -8,7 +8,7 @@ export async function POST( request: NextRequest, { params }: { params: Promise<{ order_id: string }> }, ) { - const auth = await requireRouteAuth(request); + const auth = await requireOrderOwner(request); if (auth.response) return auth.response; const { order_id } = await params; @@ -21,7 +21,7 @@ export async function POST( // request.nextUrl.origin reflects the actual request URL, not attacker-controlled input. const origin = request.nextUrl.origin; - const order = await getOrder(order_id); + const order = await getOwnedOrder(order_id, auth.owner.tenantId); if (!order) { return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 }); } diff --git a/app/api/payments/[order_id]/release/route.ts b/app/api/payments/[order_id]/release/route.ts index 1915d8e..9d3bc71 100644 --- a/app/api/payments/[order_id]/release/route.ts +++ b/app/api/payments/[order_id]/release/route.ts @@ -1,15 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; import { getStripe } from "@/lib/stripe"; -import { getOrder } from "@/lib/orders-store"; -import { requireRouteAuth } from "@/src/auth/require-route-auth"; +import { getOwnedOrder } from "@/lib/orders-store"; +import { requireOrderOwner } from "@/src/auth/require-order-owner"; // POST /api/payments/{order_id}/release (docs/04) — charge the held 50% balance. // Called on ACCEPT only; escrow balance is never charged before approval. export async function POST(req: NextRequest, ctx: { params: Promise<{ order_id: string }> }) { - const auth = await requireRouteAuth(req); + const auth = await requireOrderOwner(req); if (auth.response) return auth.response; const { order_id } = await ctx.params; - const order = await getOrder(order_id); + const order = await getOwnedOrder(order_id, auth.owner.tenantId); if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 }); if (order.status !== "accepted") { return NextResponse.json({ error: { code: "not_accepted", message: "Balans frigörs först vid godkänd leverans" } }, { status: 409 }); diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index acfba19..7f456ee 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -35,6 +35,11 @@ export async function POST(req: NextRequest) { } const order = await getOrder(oid); if (!order) return NextResponse.json({ error: "order_not_found" }, { status: 503 }); + if (!order.tenant_id || !order.buyer_subject) { + // A legacy anonymous draft is not a payable order. Do not silently + // acknowledge a paid event until an audited owner-remediation path exists. + return NextResponse.json({ error: "order_owner_missing" }, { status: 503 }); + } const expectedAmount = kind === "balance" ? order.balance_ore : order.deposit_ore; if (s.amount_total !== expectedAmount || s.currency?.toLowerCase() !== "sek") { return NextResponse.json({ error: "payment_mismatch" }, { status: 422 }); diff --git a/docs/architecture/ADR-001-order-ownership.md b/docs/architecture/ADR-001-order-ownership.md index f41b873..a2b33cc 100644 --- a/docs/architecture/ADR-001-order-ownership.md +++ b/docs/architecture/ADR-001-order-ownership.md @@ -50,9 +50,15 @@ without creating a payable order until sign-in. ## Action items -1. [ ] Build and verify the migration and deploy runner. -2. [ ] Bind create-order to verified tenant and buyer subject; scope idempotency. -3. [ ] Scope GET/PATCH/deposit/release to tenant at the SQL boundary. +1. [x] Build and smoke-verify `sql/011_orders_tenant_ownership.sql` on + throwaway pgvector/Postgres 16, including a second idempotent application. + A deploy migration runner and production application remain open. +2. [x] Bind create-order to verified tenant and buyer subject; scope idempotency. +3. [x] Scope buyer GET/PATCH/deposit/release to tenant at the SQL boundary. 4. [ ] Wire UI Zitadel login/session and CLI/MCP bearer auth; keep anonymous estimates. 5. [ ] Test cross-tenant 404, legacy unowned rejection, webhook, and staging purchase/refund end-to-end before enabling live Stripe. + +Implementation is pending PR review and merge. It is not evidence that the +production database has been migrated or that the browser purchase path is +ready: the current UI does not send a verified Zitadel credential. diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 48cbec2..a6d0147 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -137,12 +137,12 @@ Risknivå = SIAX L0–L5-taxonomin, bedömd av mig utifrån vad handlern gör. | Katalog / källor / fordon | `GET /api/{catalog,sources,vehicles}` | L1 | Nej | | Sanktions-/PEP-screening | `GET /api/screen?q=` | L2 | Nej — proxar mot OpenSanctions | | AI-parsning av fritext | `POST /api/ai/parse` | L2 | Nej | -| Skapa order | `POST /api/orders` | L2 | Nej | -| **Lista alla ordrar** | `GET /api/orders` | **L3** | Route-auth i source; org-scoping saknas | -| Läs/ändra order | `GET,PATCH /api/orders/{id}` | L3 | Route-auth i source; orderägande saknas | +| Skapa order | `POST /api/orders` | L2 | Verifierad buyer-principal i source; UI-SSO återstår | +| **Lista buyer-ordrar** | `GET /api/orders` | **L3** | Verifierad tenant och tenant-scoped SQL i source | +| Läs/ändra buyer-order | `GET,PATCH /api/orders/{id}` | L3 | Verifierad tenant och tenant-scoped SQL i source | | **Adminmetrik (orderpipeline, belopp)** | `GET /api/admin/metrics` | **L3** | Route-auth i source; äldre liveobservation ej omverifierad | -| Deposition (Stripe checkout) | `POST /api/payments/{order_id}/deposit` | **L4** | Route-auth i source; orderägande saknas | -| **Frigör escrow-slutbetalning** | `POST /api/payments/{order_id}/release` | **L4** | Route-auth i source; `delivered` först efter betald webhook; orderägande saknas | +| Deposition (Stripe checkout) | `POST /api/payments/{order_id}/deposit` | **L4** | Verifierad tenant + ägd order i source; live-UI återstår | +| **Frigör escrow-slutbetalning** | `POST /api/payments/{order_id}/release` | **L4** | Ägd order; `delivered` först efter betald webhook; live-UI återstår | | Stripe-webhook | `POST /api/webhooks/stripe` | L3 | Signaturkontroll via `STRIPE_WEBHOOK_SECRET` | | Prenumerationer | `GET,POST /api/subscriptions` | L2 | Nej | | Filuppladdning | `GET,POST /api/uploads` | L3 | Nej (`501` utan R2) | @@ -353,12 +353,15 @@ Om/när Postgres provisioneras måste detta lösas separat — jfr estate-blocke betalning som lyckad. Klient-PATCH kräver nu verifierad auth, följer tillåtna statusövergångar och kan aldrig skriva `in_production`/`delivered`; concurrent writes använder jämför-och-sätt. Slutbetalningsrouten kräver - också verifierad auth. **Fortfarande öppet före live-läge:** orderägarskap - och tenant-bindning vid checkout/acceptance behöver implementeras och - verifieras; en verifierad identitet ensam är inte per-order auktorisation. - Enskild order-GET och deposit-checkout kräver nu också route-auth; dessa - två vägar är inte längre anonyma i source. Ägarbindningens beslut och - beroenden dokumenteras i `ADR-001-order-ownership.md`. + också verifierad auth. Ny order-POST kräver nu en verifierad + Zitadel-principal; dennes tenant och subject lagras i + `orders.tenant_id`/`buyer_subject` via `sql/011`. Buyer GET/list/PATCH + och deposit/release använder tenant-predikat i SQL; legacy-ordrar utan + owner är osynliga och obetalbara. **Fortfarande öppet före live-läge:** + migrationen har bara smoke-testats på engångs-Postgres och måste appliceras + i målmiljön före deploy. Browser-UI saknar produktions-SSO/Bearer och + stagingköp/refund är ännu inte verifierat. Beslut och nästa steg finns i + `ADR-001-order-ownership.md`. - `SECURITY.md` finns med rapporteringsväg (`security@siax.io`). ### KÄNDA LUCKOR @@ -395,15 +398,16 @@ Matchern täcker bara `/api/0pt/:path*`. Orderkö/adminmetrik, enskild order-GET, deposit- och release-checkout samt order-PATCH har nu explicita route-auth-grindar i source. De äldre live-observationerna av anonymt 200 beskriver tidigare deploy, inte verifierat nuläge. Övriga icke-prefixade -API:er kräver egen inventering, och per-order tenantägande är fortfarande -öppet enligt ADR-001. +API:er kräver egen inventering. Buyer-order-API:t har nu tenant-predikat i +source, men migration, UI-SSO och liveverifiering återstår enligt ADR-001. -**S4 — Historisk escrow-bugg åtgärdad i source; tenantägande återstår.** +**S4 — Historisk escrow-bugg åtgärdad i source; liveköp återstår.** `POST /api/payments/{order_id}/release` kräver nu route-auth och skapar Checkout först efter `accepted`. En skapad session sätter inte `delivered`; endast en signerad, betald och matchande Stripe-webhook får göra det. -Cross-tenant-skydd av den enskilda ordern saknas fortfarande, så livebetalning -får inte aktiveras innan ADR-001 är genomförd och verifierad. +Cross-tenant-skydd av buyer-ordern finns nu i SQL-predikat, men är inte +produktionsbevisat innan `sql/011` har applicerats, UI-SSO är kopplat och +stagingköp/refund är verifierat. Livebetalning får därför inte aktiveras. **S5 — CL0UD-policyklienten är fail-open utan produktionsspärr.** `src/clients/cl0ud-client.ts:45` — saknas `CL0UD_URL` returneras alltid diff --git a/docs/architecture/ORDER-OWNERSHIP-CUTOVER.md b/docs/architecture/ORDER-OWNERSHIP-CUTOVER.md new file mode 100644 index 0000000..785948c --- /dev/null +++ b/docs/architecture/ORDER-OWNERSHIP-CUTOVER.md @@ -0,0 +1,43 @@ +# 0pt order-ownership cutover + +Status: source implementation in PR #66; **not applied to production**. +Canonical design: [ADR-001](./ADR-001-order-ownership.md). + +## Dependency order + +1. Confirm the target Postgres is the canonical 0pt order store. Capture a + recoverable database snapshot and identify current migration state. + `sql/001_init.sql` and `sql/006_orders_idempotency.sql` must already be + present; this repository has no general automated migration runner yet. +2. Apply `sql/011_orders_tenant_ownership.sql` transactionally to staging, + then production only after staging verification. The file is idempotent + and leaves legacy NULL-owned rows untouched. For an environment where + `DATABASE_URL` is securely injected, a manual controlled step is: + + ```bash + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 --single-transaction -f sql/011_orders_tenant_ownership.sql + ``` + +3. Verify `orders.tenant_id`, `orders.buyer_subject`, the ownership-pair + check constraint and `orders_tenant_created_idx`. Record migration + output and deployment SHA in the rollout evidence. Do not deploy the + tenant-scoped source before the schema is verified; an undefined column + will fail requests. +4. Wire a real Zitadel customer UI session and send the verified credential + to every order/payment API call. Existing dev HMAC login is not a payment + identity. Keep anonymous composition as an estimate only. +5. In staging, prove: no token -> 401; spoofed tenant header -> 401/403; + another tenant's order -> 404; legacy unowned order -> 404/unpayable; + matching signed paid Stripe event -> correct status; unpaid/mismatched + event -> no status change; repeat delivery -> idempotent. Test purchase, + cancellation and refund with real sandbox endpoints. Only then review + SIAX Technology AB live credentials and enable live Stripe. + +## Rollback and legacy handling + +Do not drop ownership columns to roll back the application. Roll back the +application deployment to its prior SHA if needed, retaining the additive +columns and snapshot. A paid Stripe event for a legacy unowned order receives +503 and requires an explicit, audited support decision; the webhook does not +guess a tenant or acknowledge it as fulfilled. Do not silently backfill +ownership from order config, browser storage, or a known order ID. diff --git a/lib/orders-store.ts b/lib/orders-store.ts index fa254e0..33a661c 100644 --- a/lib/orders-store.ts +++ b/lib/orders-store.ts @@ -16,6 +16,9 @@ export interface OrderConfig { export interface Order { order_id: string; + /** NULL for legacy anonymous drafts; never inferred from request config. */ + tenant_id?: string | null; + buyer_subject?: string | null; flow: string; status: string; config: OrderConfig; @@ -27,6 +30,16 @@ export interface Order { created_at: string; } +export interface OrderOwner { + tenantId: string; + subject: string; +} + +/** Collision-free namespace within the existing global idempotency index. */ +function ownedIdempotencyKey(owner: OrderOwner, key: string): string { + return `owned:${owner.tenantId.length}:${owner.tenantId}:${key}`; +} + // Non-durable fallback used only until a database is provisioned. const mem = new Map(); // idempotency_key -> order_id, mirroring the Postgres unique index added by @@ -53,15 +66,16 @@ function isUniqueViolation(err: unknown): boolean { return typeof err === "object" && err !== null && (err as { code?: unknown }).code === "23505"; } -function sameSubmission(a: Order, flow: string, config: OrderConfig): boolean { - return a.flow === flow && JSON.stringify(a.config) === JSON.stringify(config); +function sameSubmission(a: Order, flow: string, config: OrderConfig, owner?: OrderOwner): boolean { + return a.flow === flow && JSON.stringify(a.config) === JSON.stringify(config) + && (!owner || (a.tenant_id === owner.tenantId && a.buyer_subject === owner.subject)); } export async function findOrderByIdempotencyKey(key: string): Promise { const sql = getSqlForDurableStore("0pt.orders"); if (sql) { const rows = await sql` - SELECT order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at + SELECT order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at FROM orders WHERE idempotency_key = ${key} LIMIT 1`; return rows[0] ? coerceOrder(rows[0]) : null; } @@ -118,12 +132,14 @@ export interface CreateOrderResult { * unique-violation on INSERT is caught and re-resolved the same way a * pre-existing row would be, rather than surfacing a raw DB error. */ -export async function createOrder(flow: string, config: OrderConfig, idempotencyKey?: string): Promise { - const key = idempotencyKey?.trim() || undefined; +export async function createOrder(flow: string, config: OrderConfig, idempotencyKey?: string, owner?: OrderOwner): Promise { + if (owner && (!owner.tenantId.trim() || !owner.subject.trim())) throw new Error("Verified order owner is required"); + const rawKey = idempotencyKey?.trim() || undefined; + const key = rawKey && owner ? ownedIdempotencyKey(owner, rawKey) : rawKey; if (key) { const existing = await findOrderByIdempotencyKey(key); if (existing) { - if (sameSubmission(existing, flow, config)) return { order: existing, replayed: true }; + if (sameSubmission(existing, flow, config, owner)) return { order: existing, replayed: true }; throw new IdempotencyKeyConflictError(key); } } @@ -133,13 +149,15 @@ export async function createOrder(flow: string, config: OrderConfig, idempotency if (sql) { try { const [row] = await sql<{ order_id: string; created_at: string }[]>` - INSERT INTO orders (flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, idempotency_key) - VALUES (${flow}, 'draft', ${sql.json(config as Parameters[0])}, ${est.price * 100}, ${est.deposit * 100}, - ${est.remainder * 100}, ${est.timelineDays}, ${est.estConfidence / 100}, ${key ?? null}) + INSERT INTO orders (tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, idempotency_key) + VALUES (${owner?.tenantId ?? null}, ${owner?.subject ?? null}, ${flow}, 'draft', ${sql.json(config as Parameters[0])}, + ${est.price * 100}, ${est.deposit * 100}, ${est.remainder * 100}, + ${est.timelineDays}, ${est.estConfidence / 100}, ${key ?? null}) RETURNING order_id, created_at`; return { order: { - order_id: row.order_id, flow, status: "draft", config, + order_id: row.order_id, tenant_id: owner?.tenantId ?? null, buyer_subject: owner?.subject ?? null, + flow, status: "draft", config, total_ore: est.price * 100, deposit_ore: est.deposit * 100, balance_ore: est.remainder * 100, est_days: est.timelineDays, est_confidence: est.estConfidence / 100, created_at: row.created_at, }, @@ -149,7 +167,7 @@ export async function createOrder(flow: string, config: OrderConfig, idempotency if (key && isUniqueViolation(err)) { const raced = await findOrderByIdempotencyKey(key); if (raced) { - if (sameSubmission(raced, flow, config)) return { order: raced, replayed: true }; + if (sameSubmission(raced, flow, config, owner)) return { order: raced, replayed: true }; throw new IdempotencyKeyConflictError(key); } } @@ -159,7 +177,8 @@ export async function createOrder(flow: string, config: OrderConfig, idempotency const id = (globalThis.crypto?.randomUUID?.() ?? `SX-${Math.floor(1000 + Math.random() * 9000)}`); const order: Order = { - order_id: id, flow, status: "draft", config, + order_id: id, tenant_id: owner?.tenantId ?? null, buyer_subject: owner?.subject ?? null, + flow, status: "draft", config, total_ore: est.price * 100, deposit_ore: est.deposit * 100, balance_ore: est.remainder * 100, est_days: est.timelineDays, est_confidence: est.estConfidence / 100, created_at: new Date().toISOString(), }; @@ -172,18 +191,43 @@ export async function getOrder(id: string): Promise { const sql = getSqlForDurableStore("0pt.orders"); if (sql) { const rows = await sql` - SELECT order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at + SELECT order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at FROM orders WHERE order_id::text = ${id} LIMIT 1`; return rows[0] ? coerceOrder(rows[0]) : null; } return mem.get(id) ?? null; } +/** Buyer read is scoped in SQL itself; legacy NULL-owned rows are invisible. */ +export async function getOwnedOrder(id: string, tenantId: string): Promise { + const sql = getSqlForDurableStore("0pt.orders"); + if (sql) { + const rows = await sql` + SELECT order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at + FROM orders WHERE order_id::text = ${id} AND tenant_id = ${tenantId} LIMIT 1`; + return rows[0] ? coerceOrder(rows[0]) : null; + } + const order = mem.get(id); + return order?.tenant_id === tenantId ? order : null; +} + +export async function listOwnedOrders(tenantId: string): Promise { + const sql = getSqlForDurableStore("0pt.orders"); + if (sql) { + const rows = await sql` + SELECT order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at + FROM orders WHERE tenant_id = ${tenantId} ORDER BY created_at DESC LIMIT 100`; + return rows.map(coerceOrder); + } + return [...mem.values()].filter((o) => o.tenant_id === tenantId) + .sort((a, b) => b.created_at.localeCompare(a.created_at)); +} + export async function listOrders(): Promise { const sql = getSqlForDurableStore("0pt.orders"); if (sql) { const rows = await sql` - SELECT order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at + SELECT order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at FROM orders ORDER BY created_at DESC LIMIT 100`; return rows.map(coerceOrder); } @@ -195,7 +239,7 @@ export async function setStatus(id: string, status: string): Promise` UPDATE orders SET status = ${status}, updated_at = now() WHERE order_id::text = ${id} - RETURNING order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at`; + RETURNING order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at`; return rows[0] ? coerceOrder(rows[0]) : null; } const o = mem.get(id); @@ -210,7 +254,7 @@ export async function setStatusIfCurrent(id: string, current: string, next: stri const rows = await sql` UPDATE orders SET status = ${next}, updated_at = now() WHERE order_id::text = ${id} AND status = ${current} - RETURNING order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at`; + RETURNING order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at`; return rows[0] ? coerceOrder(rows[0]) : null; } const order = mem.get(id); @@ -219,3 +263,23 @@ export async function setStatusIfCurrent(id: string, current: string, next: stri mem.set(id, order); return order; } + +/** Buyer status change is both atomic and tenant-scoped at the SQL boundary. */ +export async function setOwnedStatusIfCurrent( + id: string, tenantId: string, current: string, next: string, +): Promise { + const sql = getSqlForDurableStore("0pt.orders"); + if (sql) { + const rows = await sql` + UPDATE orders SET status = ${next}, updated_at = now() + WHERE order_id::text = ${id} AND tenant_id = ${tenantId} AND status = ${current} + RETURNING order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, + balance_ore, est_days, est_confidence, created_at`; + return rows[0] ? coerceOrder(rows[0]) : null; + } + const order = mem.get(id); + if (!order || order.tenant_id !== tenantId || order.status !== current) return null; + order.status = next; + mem.set(id, order); + return order; +} diff --git a/sql/011_orders_tenant_ownership.sql b/sql/011_orders_tenant_ownership.sql new file mode 100644 index 0000000..32b9167 --- /dev/null +++ b/sql/011_orders_tenant_ownership.sql @@ -0,0 +1,17 @@ +-- 0pt payment-order ownership, ADR-001. +-- Apply after 001_init.sql and before deploying tenant-scoped order routes. +-- Legacy orders remain NULL-owned and cannot be read or paid by a buyer API. +-- Do not backfill from client-supplied config or guessed order IDs. + +ALTER TABLE orders ADD COLUMN IF NOT EXISTS tenant_id text; +ALTER TABLE orders ADD COLUMN IF NOT EXISTS buyer_subject text; + +ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_ownership_pair_ck; +ALTER TABLE orders ADD CONSTRAINT orders_ownership_pair_ck + CHECK ((tenant_id IS NULL AND buyer_subject IS NULL) + OR (tenant_id IS NOT NULL AND length(tenant_id) > 0 + AND buyer_subject IS NOT NULL AND length(buyer_subject) > 0)); + +CREATE INDEX IF NOT EXISTS orders_tenant_created_idx + ON orders (tenant_id, created_at DESC) + WHERE tenant_id IS NOT NULL; diff --git a/src/auth/require-order-owner.ts b/src/auth/require-order-owner.ts new file mode 100644 index 0000000..d5dcbb6 --- /dev/null +++ b/src/auth/require-order-owner.ts @@ -0,0 +1,20 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { requireRouteAuth } from "./require-route-auth"; +import type { OrderOwner } from "@/lib/orders-store"; + +export type OrderOwnerAuthResult = + | { response: NextResponse; owner?: never } + | { response?: never; owner: OrderOwner }; + +/** Payment/order routes require a real verified principal even in dev escape mode. */ +export async function requireOrderOwner(req: NextRequest): Promise { + const auth = await requireRouteAuth(req); + if (auth.response) return { response: auth.response }; + const principal = auth.ctx.principal; + if (!principal?.tenantId || !principal.sub) { + return { + response: NextResponse.json({ error: "OPT_UNAUTHENTICATED", message: "Verified buyer required" }, { status: 401 }), + }; + } + return { owner: { tenantId: principal.tenantId, subject: principal.sub } }; +} diff --git a/tests/api/anonymous-routes-auth.test.ts b/tests/api/anonymous-routes-auth.test.ts index 712ea48..b4cd008 100644 --- a/tests/api/anonymous-routes-auth.test.ts +++ b/tests/api/anonymous-routes-auth.test.ts @@ -90,10 +90,10 @@ describe('anonymous-access regression: /api/orders (GET) and /api/admin/metrics expect(res.status).toBe(401); }); - it('AUTH_ENFORCE=false (local-dev escape hatch) still allows both routes through, unauthenticated', async () => { + it('AUTH_ENFORCE=false cannot mint/read payable orders without a verified buyer', async () => { process.env.AUTH_ENFORCE = 'false'; const ordersRes = await ordersGET(req('/api/orders')); - expect(ordersRes.status).toBe(200); + expect(ordersRes.status).toBe(401); const metricsRes = await adminMetricsGET(req('/api/admin/metrics')); expect(metricsRes.status).toBe(200); }); diff --git a/tests/api/order-status-transitions.test.ts b/tests/api/order-status-transitions.test.ts index 7ae7104..0b5f0b6 100644 --- a/tests/api/order-status-transitions.test.ts +++ b/tests/api/order-status-transitions.test.ts @@ -6,16 +6,16 @@ const mocks = vi.hoisted(() => ({ getOrder: vi.fn(), setStatusIfCurrent: vi.fn(), })); -vi.mock("@/src/auth/require-route-auth", () => ({ requireRouteAuth: mocks.auth })); +vi.mock("@/src/auth/require-order-owner", () => ({ requireOrderOwner: mocks.auth })); vi.mock("@/lib/orders-store", () => ({ - getOrder: mocks.getOrder, - setStatusIfCurrent: mocks.setStatusIfCurrent, + getOwnedOrder: mocks.getOrder, + setOwnedStatusIfCurrent: mocks.setStatusIfCurrent, })); import { PATCH } from "@/app/api/orders/[id]/route"; beforeEach(() => { - mocks.auth.mockReset().mockResolvedValue({ ctx: { tenantId: "verified-tenant" } }); + mocks.auth.mockReset().mockResolvedValue({ owner: { tenantId: "verified-tenant", subject: "buyer-1" } }); mocks.getOrder.mockReset().mockResolvedValue({ order_id: "order-1", status: "draft" }); mocks.setStatusIfCurrent.mockReset().mockResolvedValue({ order_id: "order-1", status: "accepted" }); }); @@ -50,5 +50,6 @@ it("cannot jump from draft directly to accepted", async () => { it("uses an atomic compare-and-set transition after review", async () => { mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "review" }); expect((await PATCH(request("accepted"), ctx)).status).toBe(200); - expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "review", "accepted"); + expect(mocks.getOrder).toHaveBeenCalledWith("order-1", "verified-tenant"); + expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "verified-tenant", "review", "accepted"); }); diff --git a/tests/api/orders-idempotency.test.ts b/tests/api/orders-idempotency.test.ts index 754aefc..1dd8706 100644 --- a/tests/api/orders-idempotency.test.ts +++ b/tests/api/orders-idempotency.test.ts @@ -4,8 +4,11 @@ * used elsewhere in this suite (tests/api/anonymous-routes-auth.test.ts) of * calling the route handler directly with a hand-built NextRequest. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/src/auth/require-order-owner", () => ({ + requireOrderOwner: vi.fn().mockResolvedValue({ owner: { tenantId: "tenant-test", subject: "buyer-test" } }), +})); import { POST as ordersPOST } from "@/app/api/orders/route"; function postReq(body: unknown, headers: Record = {}): NextRequest { diff --git a/tests/api/stripe-balance-no-premature-delivery.test.ts b/tests/api/stripe-balance-no-premature-delivery.test.ts index be1b636..24b6600 100644 --- a/tests/api/stripe-balance-no-premature-delivery.test.ts +++ b/tests/api/stripe-balance-no-premature-delivery.test.ts @@ -6,22 +6,24 @@ vi.mock("@/lib/stripe", () => ({ getStripe: () => ({ checkout: { sessions: { create: mocks.create } } }), })); vi.mock("@/lib/orders-store", () => ({ - getOrder: vi.fn().mockResolvedValue({ order_id: "order-1", status: "accepted", balance_ore: 5000 }), + getOwnedOrder: vi.fn().mockResolvedValue({ order_id: "order-1", status: "accepted", balance_ore: 5000 }), setStatus: mocks.setStatus, })); -vi.mock("@/src/auth/require-route-auth", () => ({ - requireRouteAuth: mocks.auth, +vi.mock("@/src/auth/require-order-owner", () => ({ + requireOrderOwner: mocks.auth, })); import { POST } from "@/app/api/payments/[order_id]/release/route"; +import { getOwnedOrder } from "@/lib/orders-store"; it("does not mark delivery complete merely by creating a balance Checkout Session", async () => { - mocks.auth.mockResolvedValue({ ctx: { tenantId: "verified-tenant" } }); + mocks.auth.mockResolvedValue({ owner: { tenantId: "verified-tenant", subject: "buyer-1" } }); mocks.create.mockResolvedValue({ url: "https://checkout.stripe.com/test" }); const req = new NextRequest("https://0pt.app/api/payments/order-1/release", { method: "POST" }); const res = await POST(req, { params: Promise.resolve({ order_id: "order-1" }) }); expect(res.status).toBe(200); + expect(getOwnedOrder).toHaveBeenCalledWith("order-1", "verified-tenant"); expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ metadata: { order_id: "order-1", kind: "balance" }, })); diff --git a/tests/api/stripe-deposit-metadata.test.ts b/tests/api/stripe-deposit-metadata.test.ts index fea1cfd..7f9c5a9 100644 --- a/tests/api/stripe-deposit-metadata.test.ts +++ b/tests/api/stripe-deposit-metadata.test.ts @@ -2,13 +2,14 @@ import { afterEach, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; vi.mock("@/lib/orders-store", () => ({ - getOrder: vi.fn().mockResolvedValue({ order_id: "order-1", deposit_ore: 5000 }), + getOwnedOrder: vi.fn().mockResolvedValue({ order_id: "order-1", deposit_ore: 5000 }), })); -vi.mock("@/src/auth/require-route-auth", () => ({ - requireRouteAuth: vi.fn().mockResolvedValue({ ctx: { tenantId: "tenant-test" } }), +vi.mock("@/src/auth/require-order-owner", () => ({ + requireOrderOwner: vi.fn().mockResolvedValue({ owner: { tenantId: "tenant-test", subject: "buyer-test" } }), })); import { POST } from "@/app/api/payments/[order_id]/deposit/route"; +import { getOwnedOrder } from "@/lib/orders-store"; const savedKey = process.env.STRIPE_SECRET_KEY; const savedFetch = globalThis.fetch; @@ -30,6 +31,7 @@ it("binds a deposit Checkout Session to its order and payment kind", async () => const req = new NextRequest("https://0pt.app/api/payments/order-1/deposit", { method: "POST" }); const res = await POST(req, { params: Promise.resolve({ order_id: "order-1" }) }); expect(res.status).toBe(200); + expect(getOwnedOrder).toHaveBeenCalledWith("order-1", "tenant-test"); const init = mockFetch.mock.calls[0]?.[1] as RequestInit; const body = init.body as URLSearchParams; expect(body.get("metadata[order_id]")).toBe("order-1"); diff --git a/tests/api/stripe-webhook-payment.test.ts b/tests/api/stripe-webhook-payment.test.ts index 5a4170f..54f3d04 100644 --- a/tests/api/stripe-webhook-payment.test.ts +++ b/tests/api/stripe-webhook-payment.test.ts @@ -18,6 +18,7 @@ vi.mock("@/lib/orders-store", () => ({ import { POST } from "@/app/api/webhooks/stripe/route"; const savedWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET; +const owner = { tenant_id: "tenant-test", buyer_subject: "buyer-test" }; beforeEach(() => { process.env.STRIPE_WEBHOOK_SECRET = "whsec_fake_local_regression"; @@ -54,14 +55,14 @@ function session(kind: "deposit" | "balance", paymentStatus: string, amount: num it("advances a matching paid deposit only after a verified event", async () => { session("deposit", "paid", 5000); - mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "draft", deposit_ore: 5000, balance_ore: 5000 }); + mocks.getOrder.mockResolvedValue({ order_id: "order-1", ...owner, status: "draft", deposit_ore: 5000, balance_ore: 5000 }); expect((await POST(delivery())).status).toBe(200); expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "draft", "in_production"); }); it("never advances an unpaid or amount-mismatched deposit", async () => { - mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "draft", deposit_ore: 5000, balance_ore: 5000 }); + mocks.getOrder.mockResolvedValue({ order_id: "order-1", ...owner, status: "draft", deposit_ore: 5000, balance_ore: 5000 }); session("deposit", "unpaid", 5000); expect((await POST(delivery())).status).toBe(503); session("deposit", "paid", 4999); @@ -71,8 +72,15 @@ it("never advances an unpaid or amount-mismatched deposit", async () => { it("marks a matching paid balance delivered only after acceptance", async () => { session("balance", "paid", 5000); - mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "accepted", deposit_ore: 5000, balance_ore: 5000 }); + mocks.getOrder.mockResolvedValue({ order_id: "order-1", ...owner, status: "accepted", deposit_ore: 5000, balance_ore: 5000 }); expect((await POST(delivery())).status).toBe(200); expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "accepted", "delivered"); }); + +it("does not acknowledge a paid event for a legacy anonymous draft", async () => { + session("deposit", "paid", 5000); + mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "draft", deposit_ore: 5000 }); + expect((await POST(delivery())).status).toBe(503); + expect(mocks.setStatusIfCurrent).not.toHaveBeenCalled(); +}); diff --git a/tests/auth/order-owner.test.ts b/tests/auth/order-owner.test.ts new file mode 100644 index 0000000..f910e2e --- /dev/null +++ b/tests/auth/order-owner.test.ts @@ -0,0 +1,29 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +const mocks = vi.hoisted(() => ({ routeAuth: vi.fn() })); +vi.mock("@/src/auth/require-route-auth", () => ({ requireRouteAuth: mocks.routeAuth })); + +import { requireOrderOwner } from "@/src/auth/require-order-owner"; + +beforeEach(() => mocks.routeAuth.mockReset()); + +const request = () => new NextRequest("https://0pt.app/api/orders", { method: "POST" }); + +it("passes through a failed Zitadel route-auth decision", async () => { + mocks.routeAuth.mockResolvedValue({ response: NextResponse.json({ error: "unauthenticated" }, { status: 401 }) }); + expect((await requireOrderOwner(request())).response?.status).toBe(401); +}); + +it("rejects the local-dev auth bypass when no verified principal exists", async () => { + mocks.routeAuth.mockResolvedValue({ ctx: { tenantId: "spoofable-header" } }); + expect((await requireOrderOwner(request())).response?.status).toBe(401); +}); + +it("uses only the verified JWT principal, not a request-supplied tenant", async () => { + mocks.routeAuth.mockResolvedValue({ + ctx: { tenantId: "header-tenant", principal: { tenantId: "signed-tenant", sub: "signed-subject" } }, + }); + const result = await requireOrderOwner(request()); + expect(result.owner).toEqual({ tenantId: "signed-tenant", subject: "signed-subject" }); +}); diff --git a/tests/lib/orders-ownership.test.ts b/tests/lib/orders-ownership.test.ts new file mode 100644 index 0000000..47dc2b6 --- /dev/null +++ b/tests/lib/orders-ownership.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + createOrder, getOwnedOrder, listOwnedOrders, setOwnedStatusIfCurrent, + IdempotencyKeyConflictError, +} from "@/lib/orders-store"; + +const alpha = { tenantId: "tenant-alpha", subject: "buyer-alpha" }; +const beta = { tenantId: "tenant-beta", subject: "buyer-beta" }; +const config = { markets: ["SE"], volume: 1000 }; + +describe("payment-order ownership (in-memory contract)", () => { + it("binds a new order to the verified tenant and buyer, never to client config", async () => { + const result = await createOrder("custom", config, undefined, alpha); + expect(result.order.tenant_id).toBe(alpha.tenantId); + expect(result.order.buyer_subject).toBe(alpha.subject); + expect(await getOwnedOrder(result.order.order_id, alpha.tenantId)).not.toBeNull(); + expect(await getOwnedOrder(result.order.order_id, beta.tenantId)).toBeNull(); + expect((await listOwnedOrders(beta.tenantId)).some((o) => o.order_id === result.order.order_id)).toBe(false); + }); + + it("legacy anonymous rows remain invisible and cannot be mutated by a buyer", async () => { + const legacy = (await createOrder("custom", config)).order; + expect(await getOwnedOrder(legacy.order_id, alpha.tenantId)).toBeNull(); + expect(await setOwnedStatusIfCurrent(legacy.order_id, alpha.tenantId, "draft", "quoted")).toBeNull(); + }); + + it("an owner cannot change another tenant's status even at the same valid state", async () => { + const order = (await createOrder("custom", config, undefined, alpha)).order; + expect(await setOwnedStatusIfCurrent(order.order_id, beta.tenantId, "draft", "quoted")).toBeNull(); + expect((await getOwnedOrder(order.order_id, alpha.tenantId))?.status).toBe("draft"); + expect((await setOwnedStatusIfCurrent(order.order_id, alpha.tenantId, "draft", "quoted"))?.status).toBe("quoted"); + }); + + it("idempotency keys are tenant-namespaced, and another buyer in the same tenant cannot replay", async () => { + const key = `ownership-${crypto.randomUUID()}`; + const a1 = await createOrder("custom", config, key, alpha); + const a2 = await createOrder("custom", config, key, alpha); + const b1 = await createOrder("custom", config, key, beta); + expect(a2.replayed).toBe(true); + expect(a1.order.order_id).toBe(a2.order.order_id); + expect(a1.order.order_id).not.toBe(b1.order.order_id); + await expect(createOrder("custom", config, key, { ...alpha, subject: "other-buyer" })) + .rejects.toBeInstanceOf(IdempotencyKeyConflictError); + }); +});