feat(payments): verified tenant-owned 0pt orders #69

Merged
siax-bot merged 1 commits from feat/payment-order-ownership-wave49 into fix/stripe-webhook-no-ack-without-secret-wave47 2026-09-15 19:04:05 +00:00
19 changed files with 326 additions and 76 deletions
+7 -7
View File
@@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { getOrder, setStatusIfCurrent } from "@/lib/orders-store";
import { requireRouteAuth } from "@/src/auth/require-route-auth";
import { getOwnedOrder, setOwnedStatusIfCurrent } from "@/lib/orders-store";
import { requireOrderOwner } from "@/src/auth/require-order-owner";
// GET /api/orders/{id}
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const auth = await requireRouteAuth(req);
const auth = await requireOrderOwner(req);
if (auth.response) return auth.response;
const { id } = await ctx.params;
const order = await getOrder(id);
const order = await getOwnedOrder(id, auth.owner.tenantId);
if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
return NextResponse.json(order);
}
@@ -15,7 +15,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
// PATCH /api/orders/{id} — status transition (deposit → in_production → review → accepted ...).
// Escrow rule (docs/01): balance never charged before 'accepted'. Stripe wiring follows.
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const auth = await requireRouteAuth(req);
const auth = await requireOrderOwner(req);
if (auth.response) return auth.response;
const { id } = await ctx.params;
let body: { status?: string };
@@ -35,7 +35,7 @@ export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: strin
if (!body.status || !allowed.includes(body.status)) {
return NextResponse.json({ error: { code: "bad_status", message: "Ogiltig status" } }, { status: 400 });
}
const order = await getOrder(id);
const order = await getOwnedOrder(id, auth.owner.tenantId);
if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
const previous: Record<string, string[]> = {
quoted: ["draft"],
@@ -47,7 +47,7 @@ export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: strin
if (!previous[body.status]?.includes(order.status)) {
return NextResponse.json({ error: { code: "invalid_transition" } }, { status: 409 });
}
const updated = await setStatusIfCurrent(id, order.status, body.status);
const updated = await setOwnedStatusIfCurrent(id, auth.owner.tenantId, order.status, body.status);
if (!updated) return NextResponse.json({ error: { code: "stale_transition" } }, { status: 409 });
return NextResponse.json(updated);
}
+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);
}
+4 -4
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getOrder } from "@/lib/orders-store";
import { requireRouteAuth } from "@/src/auth/require-route-auth";
import { getOwnedOrder } from "@/lib/orders-store";
import { requireOrderOwner } from "@/src/auth/require-order-owner";
// POST /api/payments/[order_id]/deposit
// Returns { url } for Stripe Checkout. Returns 501 when STRIPE_SECRET_KEY is absent.
@@ -8,7 +8,7 @@ export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ order_id: string }> },
) {
const auth = await requireRouteAuth(request);
const auth = await requireOrderOwner(request);
if (auth.response) return auth.response;
const { order_id } = await params;
@@ -21,7 +21,7 @@ export async function POST(
// request.nextUrl.origin reflects the actual request URL, not attacker-controlled input.
const origin = request.nextUrl.origin;
const order = await getOrder(order_id);
const order = await getOwnedOrder(order_id, auth.owner.tenantId);
if (!order) {
return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
}
+4 -4
View File
@@ -1,15 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { getStripe } from "@/lib/stripe";
import { getOrder } from "@/lib/orders-store";
import { requireRouteAuth } from "@/src/auth/require-route-auth";
import { getOwnedOrder } from "@/lib/orders-store";
import { requireOrderOwner } from "@/src/auth/require-order-owner";
// POST /api/payments/{order_id}/release (docs/04) — charge the held 50% balance.
// Called on ACCEPT only; escrow balance is never charged before approval.
export async function POST(req: NextRequest, ctx: { params: Promise<{ order_id: string }> }) {
const auth = await requireRouteAuth(req);
const auth = await requireOrderOwner(req);
if (auth.response) return auth.response;
const { order_id } = await ctx.params;
const order = await getOrder(order_id);
const order = await getOwnedOrder(order_id, auth.owner.tenantId);
if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
if (order.status !== "accepted") {
return NextResponse.json({ error: { code: "not_accepted", message: "Balans frigörs först vid godkänd leverans" } }, { status: 409 });
+5
View File
@@ -35,6 +35,11 @@ export async function POST(req: NextRequest) {
}
const order = await getOrder(oid);
if (!order) return NextResponse.json({ error: "order_not_found" }, { status: 503 });
if (!order.tenant_id || !order.buyer_subject) {
// A legacy anonymous draft is not a payable order. Do not silently
// acknowledge a paid event until an audited owner-remediation path exists.
return NextResponse.json({ error: "order_owner_missing" }, { status: 503 });
}
const expectedAmount = kind === "balance" ? order.balance_ore : order.deposit_ore;
if (s.amount_total !== expectedAmount || s.currency?.toLowerCase() !== "sek") {
return NextResponse.json({ error: "payment_mismatch" }, { status: 422 });
+9 -3
View File
@@ -50,9 +50,15 @@ without creating a payable order until sign-in.
## Action items
1. [ ] Build and verify the migration and deploy runner.
2. [ ] Bind create-order to verified tenant and buyer subject; scope idempotency.
3. [ ] Scope GET/PATCH/deposit/release to tenant at the SQL boundary.
1. [x] Build and smoke-verify `sql/011_orders_tenant_ownership.sql` on
throwaway pgvector/Postgres 16, including a second idempotent application.
A deploy migration runner and production application remain open.
2. [x] Bind create-order to verified tenant and buyer subject; scope idempotency.
3. [x] Scope buyer GET/PATCH/deposit/release to tenant at the SQL boundary.
4. [ ] Wire UI Zitadel login/session and CLI/MCP bearer auth; keep anonymous estimates.
5. [ ] Test cross-tenant 404, legacy unowned rejection, webhook, and staging
purchase/refund end-to-end before enabling live Stripe.
Implementation is pending PR review and merge. It is not evidence that the
production database has been migrated or that the browser purchase path is
ready: the current UI does not send a verified Zitadel credential.
+20 -16
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** | Route-auth i source; org-scoping saknas |
| Läs/ändra order | `GET,PATCH /api/orders/{id}` | L3 | Route-auth i source; orderägande saknas |
| Skapa order | `POST /api/orders` | L2 | Verifierad buyer-principal i source; UI-SSO återstår |
| **Lista buyer-ordrar** | `GET /api/orders` | **L3** | Verifierad tenant och tenant-scoped SQL i source |
| Läs/ändra buyer-order | `GET,PATCH /api/orders/{id}` | L3 | Verifierad tenant och tenant-scoped SQL i source |
| **Adminmetrik (orderpipeline, belopp)** | `GET /api/admin/metrics` | **L3** | Route-auth i source; äldre liveobservation ej omverifierad |
| Deposition (Stripe checkout) | `POST /api/payments/{order_id}/deposit` | **L4** | Route-auth i source; orderägande saknas |
| **Frigör escrow-slutbetalning** | `POST /api/payments/{order_id}/release` | **L4** | Route-auth i source; `delivered` först efter betald webhook; orderägande saknas |
| Deposition (Stripe checkout) | `POST /api/payments/{order_id}/deposit` | **L4** | Verifierad tenant + ägd order i source; live-UI återstår |
| **Frigör escrow-slutbetalning** | `POST /api/payments/{order_id}/release` | **L4** | Ägd order; `delivered` först efter betald webhook; live-UI återstår |
| Stripe-webhook | `POST /api/webhooks/stripe` | L3 | Signaturkontroll via `STRIPE_WEBHOOK_SECRET` |
| Prenumerationer | `GET,POST /api/subscriptions` | L2 | Nej |
| Filuppladdning | `GET,POST /api/uploads` | L3 | Nej (`501` utan R2) |
@@ -353,12 +353,15 @@ Om/när Postgres provisioneras måste detta lösas separat — jfr estate-blocke
betalning som lyckad. Klient-PATCH kräver nu verifierad auth, följer
tillåtna statusövergångar och kan aldrig skriva `in_production`/`delivered`;
concurrent writes använder jämför-och-sätt. Slutbetalningsrouten kräver
också verifierad auth. **Fortfarande öppet före live-läge:** orderägarskap
och tenant-bindning vid checkout/acceptance behöver implementeras och
verifieras; en verifierad identitet ensam är inte per-order auktorisation.
Enskild order-GET och deposit-checkout kräver nu också route-auth; dessa
två vägar är inte längre anonyma i source. Ägarbindningens beslut och
beroenden dokumenteras i `ADR-001-order-ownership.md`.
också verifierad auth. Ny order-POST kräver nu en verifierad
Zitadel-principal; dennes tenant och subject lagras i
`orders.tenant_id`/`buyer_subject` via `sql/011`. Buyer GET/list/PATCH
och deposit/release använder tenant-predikat i SQL; legacy-ordrar utan
owner är osynliga och obetalbara. **Fortfarande öppet före live-läge:**
migrationen har bara smoke-testats på engångs-Postgres och måste appliceras
i målmiljön före deploy. Browser-UI saknar produktions-SSO/Bearer och
stagingköp/refund är ännu inte verifierat. Beslut och nästa steg finns i
`ADR-001-order-ownership.md`.
- `SECURITY.md` finns med rapporteringsväg (`security@siax.io`).
### KÄNDA LUCKOR
@@ -395,15 +398,16 @@ Matchern täcker bara `/api/0pt/:path*`. Orderkö/adminmetrik, enskild
order-GET, deposit- och release-checkout samt order-PATCH har nu explicita
route-auth-grindar i source. De äldre live-observationerna av anonymt 200
beskriver tidigare deploy, inte verifierat nuläge. Övriga icke-prefixade
API:er kräver egen inventering, och per-order tenantägande är fortfarande
öppet enligt ADR-001.
API:er kräver egen inventering. Buyer-order-API:t har nu tenant-predikat i
source, men migration, UI-SSO och liveverifiering återstår enligt ADR-001.
**S4 — Historisk escrow-bugg åtgärdad i source; tenantägande återstår.**
**S4 — Historisk escrow-bugg åtgärdad i source; liveköp återstår.**
`POST /api/payments/{order_id}/release` kräver nu route-auth och skapar
Checkout först efter `accepted`. En skapad session sätter inte `delivered`;
endast en signerad, betald och matchande Stripe-webhook får göra det.
Cross-tenant-skydd av den enskilda ordern saknas fortfarande, så livebetalning
får inte aktiveras innan ADR-001 är genomförd och verifierad.
Cross-tenant-skydd av buyer-ordern finns nu i SQL-predikat, men är inte
produktionsbevisat innan `sql/011` har applicerats, UI-SSO är kopplat och
stagingköp/refund är verifierat. Livebetalning får därför inte aktiveras.
**S5 — CL0UD-policyklienten är fail-open utan produktionsspärr.**
`src/clients/cl0ud-client.ts:45` — saknas `CL0UD_URL` returneras alltid
@@ -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.
+80 -16
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,7 +239,7 @@ 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);
@@ -210,7 +254,7 @@ export async function setStatusIfCurrent(id: string, current: string, next: stri
const rows = await sql<Order[]>`
UPDATE orders SET status = ${next}, updated_at = now()
WHERE order_id::text = ${id} AND status = ${current}
RETURNING order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at`;
RETURNING order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at`;
return rows[0] ? coerceOrder(rows[0]) : null;
}
const order = mem.get(id);
@@ -219,3 +263,23 @@ export async function setStatusIfCurrent(id: string, current: string, next: stri
mem.set(id, order);
return order;
}
/** Buyer status change is both atomic and tenant-scoped at the SQL boundary. */
export async function setOwnedStatusIfCurrent(
id: string, tenantId: string, current: string, next: string,
): Promise<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;
}
+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 } };
}
+2 -2
View File
@@ -90,10 +90,10 @@ describe('anonymous-access regression: /api/orders (GET) and /api/admin/metrics
expect(res.status).toBe(401);
});
it('AUTH_ENFORCE=false (local-dev escape hatch) still allows both routes through, unauthenticated', async () => {
it('AUTH_ENFORCE=false cannot mint/read payable orders without a verified buyer', async () => {
process.env.AUTH_ENFORCE = 'false';
const ordersRes = await ordersGET(req('/api/orders'));
expect(ordersRes.status).toBe(200);
expect(ordersRes.status).toBe(401);
const metricsRes = await adminMetricsGET(req('/api/admin/metrics'));
expect(metricsRes.status).toBe(200);
});
+6 -5
View File
@@ -6,16 +6,16 @@ const mocks = vi.hoisted(() => ({
getOrder: vi.fn(),
setStatusIfCurrent: vi.fn(),
}));
vi.mock("@/src/auth/require-route-auth", () => ({ requireRouteAuth: mocks.auth }));
vi.mock("@/src/auth/require-order-owner", () => ({ requireOrderOwner: mocks.auth }));
vi.mock("@/lib/orders-store", () => ({
getOrder: mocks.getOrder,
setStatusIfCurrent: mocks.setStatusIfCurrent,
getOwnedOrder: mocks.getOrder,
setOwnedStatusIfCurrent: mocks.setStatusIfCurrent,
}));
import { PATCH } from "@/app/api/orders/[id]/route";
beforeEach(() => {
mocks.auth.mockReset().mockResolvedValue({ ctx: { tenantId: "verified-tenant" } });
mocks.auth.mockReset().mockResolvedValue({ owner: { tenantId: "verified-tenant", subject: "buyer-1" } });
mocks.getOrder.mockReset().mockResolvedValue({ order_id: "order-1", status: "draft" });
mocks.setStatusIfCurrent.mockReset().mockResolvedValue({ order_id: "order-1", status: "accepted" });
});
@@ -50,5 +50,6 @@ it("cannot jump from draft directly to accepted", async () => {
it("uses an atomic compare-and-set transition after review", async () => {
mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "review" });
expect((await PATCH(request("accepted"), ctx)).status).toBe(200);
expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "review", "accepted");
expect(mocks.getOrder).toHaveBeenCalledWith("order-1", "verified-tenant");
expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "verified-tenant", "review", "accepted");
});
+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 {
@@ -6,22 +6,24 @@ vi.mock("@/lib/stripe", () => ({
getStripe: () => ({ checkout: { sessions: { create: mocks.create } } }),
}));
vi.mock("@/lib/orders-store", () => ({
getOrder: vi.fn().mockResolvedValue({ order_id: "order-1", status: "accepted", balance_ore: 5000 }),
getOwnedOrder: vi.fn().mockResolvedValue({ order_id: "order-1", status: "accepted", balance_ore: 5000 }),
setStatus: mocks.setStatus,
}));
vi.mock("@/src/auth/require-route-auth", () => ({
requireRouteAuth: mocks.auth,
vi.mock("@/src/auth/require-order-owner", () => ({
requireOrderOwner: mocks.auth,
}));
import { POST } from "@/app/api/payments/[order_id]/release/route";
import { getOwnedOrder } from "@/lib/orders-store";
it("does not mark delivery complete merely by creating a balance Checkout Session", async () => {
mocks.auth.mockResolvedValue({ ctx: { tenantId: "verified-tenant" } });
mocks.auth.mockResolvedValue({ owner: { tenantId: "verified-tenant", subject: "buyer-1" } });
mocks.create.mockResolvedValue({ url: "https://checkout.stripe.com/test" });
const req = new NextRequest("https://0pt.app/api/payments/order-1/release", { method: "POST" });
const res = await POST(req, { params: Promise.resolve({ order_id: "order-1" }) });
expect(res.status).toBe(200);
expect(getOwnedOrder).toHaveBeenCalledWith("order-1", "verified-tenant");
expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({
metadata: { order_id: "order-1", kind: "balance" },
}));
+5 -3
View File
@@ -2,13 +2,14 @@ import { afterEach, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/orders-store", () => ({
getOrder: vi.fn().mockResolvedValue({ order_id: "order-1", deposit_ore: 5000 }),
getOwnedOrder: vi.fn().mockResolvedValue({ order_id: "order-1", deposit_ore: 5000 }),
}));
vi.mock("@/src/auth/require-route-auth", () => ({
requireRouteAuth: vi.fn().mockResolvedValue({ ctx: { tenantId: "tenant-test" } }),
vi.mock("@/src/auth/require-order-owner", () => ({
requireOrderOwner: vi.fn().mockResolvedValue({ owner: { tenantId: "tenant-test", subject: "buyer-test" } }),
}));
import { POST } from "@/app/api/payments/[order_id]/deposit/route";
import { getOwnedOrder } from "@/lib/orders-store";
const savedKey = process.env.STRIPE_SECRET_KEY;
const savedFetch = globalThis.fetch;
@@ -30,6 +31,7 @@ it("binds a deposit Checkout Session to its order and payment kind", async () =>
const req = new NextRequest("https://0pt.app/api/payments/order-1/deposit", { method: "POST" });
const res = await POST(req, { params: Promise.resolve({ order_id: "order-1" }) });
expect(res.status).toBe(200);
expect(getOwnedOrder).toHaveBeenCalledWith("order-1", "tenant-test");
const init = mockFetch.mock.calls[0]?.[1] as RequestInit;
const body = init.body as URLSearchParams;
expect(body.get("metadata[order_id]")).toBe("order-1");
+11 -3
View File
@@ -18,6 +18,7 @@ vi.mock("@/lib/orders-store", () => ({
import { POST } from "@/app/api/webhooks/stripe/route";
const savedWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
const owner = { tenant_id: "tenant-test", buyer_subject: "buyer-test" };
beforeEach(() => {
process.env.STRIPE_WEBHOOK_SECRET = "whsec_fake_local_regression";
@@ -54,14 +55,14 @@ function session(kind: "deposit" | "balance", paymentStatus: string, amount: num
it("advances a matching paid deposit only after a verified event", async () => {
session("deposit", "paid", 5000);
mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "draft", deposit_ore: 5000, balance_ore: 5000 });
mocks.getOrder.mockResolvedValue({ order_id: "order-1", ...owner, status: "draft", deposit_ore: 5000, balance_ore: 5000 });
expect((await POST(delivery())).status).toBe(200);
expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "draft", "in_production");
});
it("never advances an unpaid or amount-mismatched deposit", async () => {
mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "draft", deposit_ore: 5000, balance_ore: 5000 });
mocks.getOrder.mockResolvedValue({ order_id: "order-1", ...owner, status: "draft", deposit_ore: 5000, balance_ore: 5000 });
session("deposit", "unpaid", 5000);
expect((await POST(delivery())).status).toBe(503);
session("deposit", "paid", 4999);
@@ -71,8 +72,15 @@ it("never advances an unpaid or amount-mismatched deposit", async () => {
it("marks a matching paid balance delivered only after acceptance", async () => {
session("balance", "paid", 5000);
mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "accepted", deposit_ore: 5000, balance_ore: 5000 });
mocks.getOrder.mockResolvedValue({ order_id: "order-1", ...owner, status: "accepted", deposit_ore: 5000, balance_ore: 5000 });
expect((await POST(delivery())).status).toBe(200);
expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "accepted", "delivered");
});
it("does not acknowledge a paid event for a legacy anonymous draft", async () => {
session("deposit", "paid", 5000);
mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "draft", deposit_ore: 5000 });
expect((await POST(delivery())).status).toBe(503);
expect(mocks.setStatusIfCurrent).not.toHaveBeenCalled();
});
+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);
});
});