From a1557b2a7d2fec697508b567575d2e0fb8b63007 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Tue, 15 Sep 2026 19:53:58 +0200 Subject: [PATCH] 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); + }); +}); -- 2.54.0