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
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:
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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,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();
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user