Merge pull request #66
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / security (push) Successful in 11s
Security Scan (SIAX Cloud) / security (push) Successful in 12s
CI (SIAX Cloud) / quality (push) Successful in 42s
CI (SIAX Cloud) / integration (push) Successful in 46s
Deploy to Coolify / deploy (push) Successful in 59s
Release chain (P007 pattern) / Build, push, SBOM, sign, record evidence (push) Failing after 1m24s

This commit was merged in pull request #66.
This commit is contained in:
2026-09-17 00:00:25 +02:00
24 changed files with 765 additions and 73 deletions
+7
View File
@@ -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:
+27 -6
View File
@@ -1,10 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { getOrder, setStatus } from "@/lib/orders-store";
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 }> }) {
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
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);
}
@@ -12,6 +15,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 requireOrderOwner(req);
if (auth.response) return auth.response;
const { id } = await ctx.params;
let body: { status?: string };
try {
@@ -22,11 +27,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 getOwnedOrder(id, auth.owner.tenantId);
if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
return NextResponse.json(order);
const previous: Record<string, string[]> = {
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 setOwnedStatusIfCurrent(id, auth.owner.tenantId, order.status, body.status);
if (!updated) return NextResponse.json({ error: { code: "stale_transition" } }, { status: 409 });
return NextResponse.json(updated);
}
+9 -8
View File
@@ -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);
}
+8 -3
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getOrder } from "@/lib/orders-store";
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.
@@ -7,6 +8,8 @@ export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ order_id: string }> },
) {
const auth = await requireOrderOwner(request);
if (auth.response) return auth.response;
const { order_id } = await params;
const secretKey = process.env.STRIPE_SECRET_KEY;
@@ -18,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 });
}
@@ -31,6 +34,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 +56,4 @@ export async function POST(
const session = (await stripeRes.json()) as { url: string };
return NextResponse.json({ url: session.url });
}
}
+7 -3
View File
@@ -1,12 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { getStripe } from "@/lib/stripe";
import { getOrder, setStatus } from "@/lib/orders-store";
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 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 });
@@ -35,6 +38,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 });
}
+43 -5
View File
@@ -1,14 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import type Stripe from "stripe";
import { getStripe } from "@/lib/stripe";
import { 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) {
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;
@@ -18,10 +24,42 @@ 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 });
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 });
}
if (kind === "balance") {
if (order.status !== "accepted" && order.status !== "delivered") {
return NextResponse.json({ error: "invalid_order_state" }, { status: 409 });
}
if (order.status === "accepted" && !(await setStatusIfCurrent(oid, "accepted", "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 setStatusIfCurrent(oid, order.status, "in_production"))) {
return NextResponse.json({ error: "order_update_failed" }, { status: 503 });
}
}
}
return NextResponse.json({ received: true });
}
+11 -13
View File
@@ -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<string | null>(null);
const [paying, setPaying] = useState(false);
const [paymentError, setPaymentError] = useState(false);
const reveal = useReveal<HTMLDivElement>();
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. →" })}
</button>
{paymentError && (
<p role="alert" className="mt-3 text-sm text-[var(--accent-red)]">
{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." })}
</p>
)}
<Link
href="/bestall"
className="mt-4 block text-center font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
@@ -0,0 +1,64 @@
# 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. [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.
+39 -17
View File
@@ -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** | **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) |
| 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** | 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) |
@@ -343,6 +343,25 @@ 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`.
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.
Deposit-sidans demo-fallback kan inte längre märka en misslyckad
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. 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
@@ -374,18 +393,21 @@ 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. Buyer-order-API:t har nu tenant-predikat i
source, men migration, UI-SSO och liveverifiering återstår 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; 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 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
@@ -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.
+96 -15
View File
@@ -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<string, Order>();
// 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<Order | null> {
const sql = getSqlForDurableStore("0pt.orders");
if (sql) {
const rows = await sql<Order[]>`
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<CreateOrderResult> {
const key = idempotencyKey?.trim() || undefined;
export async function createOrder(flow: string, config: OrderConfig, idempotencyKey?: string, owner?: OrderOwner): Promise<CreateOrderResult> {
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<typeof sql.json>[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<typeof sql.json>[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<Order | null> {
const sql = getSqlForDurableStore("0pt.orders");
if (sql) {
const rows = await sql<Order[]>`
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<Order | null> {
const sql = getSqlForDurableStore("0pt.orders");
if (sql) {
const rows = await sql<Order[]>`
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<Order[]> {
const sql = getSqlForDurableStore("0pt.orders");
if (sql) {
const rows = await sql<Order[]>`
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<Order[]> {
const sql = getSqlForDurableStore("0pt.orders");
if (sql) {
const rows = await sql<Order[]>`
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,10 +239,47 @@ export async function setStatus(id: string, status: string): Promise<Order | nul
if (sql) {
const rows = await sql<Order[]>`
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);
if (o) { o.status = status; mem.set(id, o); }
return o ?? null;
}
/** Atomic status transition; rejects stale concurrent writers instead of overwriting them. */
export async function setStatusIfCurrent(id: string, current: string, next: string): Promise<Order | null> {
const sql = getSqlForDurableStore("0pt.orders");
if (sql) {
const rows = await sql<Order[]>`
UPDATE orders SET status = ${next}, updated_at = now()
WHERE order_id::text = ${id} 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.status !== current) return null;
order.status = next;
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<Order | null> {
const sql = getSqlForDurableStore("0pt.orders");
if (sql) {
const rows = await sql<Order[]>`
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;
}
+9
View File
@@ -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
+17
View File
@@ -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;
+20
View File
@@ -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<OrderOwnerAuthResult> {
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 } };
}
+18 -2
View File
@@ -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<string, string> = {}): 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);
@@ -74,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);
});
@@ -0,0 +1,55 @@
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-order-owner", () => ({ requireOrderOwner: mocks.auth }));
vi.mock("@/lib/orders-store", () => ({
getOwnedOrder: mocks.getOrder,
setOwnedStatusIfCurrent: mocks.setStatusIfCurrent,
}));
import { PATCH } from "@/app/api/orders/[id]/route";
beforeEach(() => {
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" });
});
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.getOrder).toHaveBeenCalledWith("order-1", "verified-tenant");
expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "verified-tenant", "review", "accepted");
});
+4 -1
View File
@@ -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<string, string> = {}): NextRequest {
@@ -0,0 +1,40 @@
import { expect, it, vi } from "vitest";
import { NextRequest, NextResponse } from "next/server";
const mocks = vi.hoisted(() => ({ create: vi.fn(), setStatus: vi.fn(), auth: vi.fn() }));
vi.mock("@/lib/stripe", () => ({
getStripe: () => ({ checkout: { sessions: { create: mocks.create } } }),
}));
vi.mock("@/lib/orders-store", () => ({
getOwnedOrder: vi.fn().mockResolvedValue({ order_id: "order-1", status: "accepted", balance_ore: 5000 }),
setStatus: mocks.setStatus,
}));
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({ 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" },
}));
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();
});
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/orders-store", () => ({
getOwnedOrder: vi.fn().mockResolvedValue({ order_id: "order-1", deposit_ore: 5000 }),
}));
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;
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);
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");
expect(body.get("metadata[kind]")).toBe("deposit");
});
+40
View File
@@ -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" });
});
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({
constructEvent: vi.fn(),
getOrder: vi.fn(),
setStatusIfCurrent: vi.fn(),
}));
vi.mock("@/lib/stripe", () => ({
getStripe: () => ({ webhooks: { constructEvent: mocks.constructEvent } }),
}));
vi.mock("@/lib/orders-store", () => ({
getOrder: mocks.getOrder,
setStatusIfCurrent: mocks.setStatusIfCurrent,
}));
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";
mocks.constructEvent.mockReset();
mocks.getOrder.mockReset();
mocks.setStatusIfCurrent.mockReset();
mocks.setStatusIfCurrent.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", ...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", ...owner, 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.setStatusIfCurrent).not.toHaveBeenCalled();
});
it("marks a matching paid balance delivered only after acceptance", async () => {
session("balance", "paid", 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();
});
+29
View File
@@ -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" });
});
+45
View File
@@ -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);
});
});
+9
View File
@@ -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");
});