Files
0pt/app/api/orders/route.ts
T
admin c7fd9d45a2
CI (SIAX Cloud) / integration (push) Has been skipped
masterplan-lock / check (pull_request) Successful in 3s
masterplan-lock / check (push) Successful in 3s
CI (SIAX Cloud) / security (push) Successful in 15s
Security Scan (SIAX Cloud) / security (push) Successful in 14s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 18s
CI (SIAX Cloud) / quality (pull_request) Successful in 31s
CI (SIAX Cloud) / quality (push) Successful in 40s
CI (SIAX Cloud) / security (pull_request) Successful in 42s
CI (SIAX Cloud) / integration (pull_request) Successful in 45s
fix(prod): map SiaxError in the 7 legacy money routes — DEPENDENCY_UNAVAILABLE/503 instead of opaque 500 (0pt#53)
getSqlForDurableStore throws SiaxError(DEPENDENCY_UNAVAILABLE, httpStatus 503,
retryable) when a money-bearing store would fall back to an in-memory Map in a
production runtime (the durable-store guard). The /api/0pt/* surface already
maps it via src/http/siax-error-response.ts; the seven legacy routes outside
the middleware matcher did not — the documented 503 leaked as Next's opaque
500 with no body.

- app/api/siax-error-mapping.ts: shared isSiaxError + siaxErrorResponseForLegacy
- orders (POST/GET), orders/[id] (GET/PATCH), subscriptions (GET/POST),
  payments/deposit, payments/release, admin/metrics: try/catch -> mapper,
  each route's own typed error contract preserved (idempotency conflict 409 etc.)
- webhooks/stripe: store outage -> 503 received:false reason:store_unavailable
  so Stripe's delivery retry path stays open (never a silent received:true)
- tests/api/legacy-routes-siax-error-mapping.test.ts: pins the 503 + body
2026-09-22 04:33:57 +02:00

65 lines
3.1 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { createOrder, listOwnedOrders, IdempotencyKeyConflictError, type OrderConfig } from "@/lib/orders-store";
import { requireOrderOwner } from "@/src/auth/require-order-owner";
import { isSiaxError, siaxErrorResponseForLegacy } from "../siax-error-mapping";
// POST /api/orders (docs/04) — create a draft order from a configurator config.
// 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")
// so a retried submission (double-click, dropped response, proxy replay)
// returns the ORIGINAL order (200) instead of creating a duplicate (was:
// no idempotency mechanism at all -- every retry created a new row, P014
// 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();
} catch {
return NextResponse.json({ error: { code: "bad_json", message: "Invalid JSON" } }, { status: 400 });
}
if (!body || typeof body !== "object") {
return NextResponse.json({ error: { code: "bad_json", message: "Invalid JSON body" } }, { status: 400 });
}
const flow = body.flow || "custom";
const config = body.config || {};
const idempotencyKey = req.headers.get("idempotency-key") ?? undefined;
try {
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 },
);
} catch (err: unknown) {
if (err instanceof IdempotencyKeyConflictError) {
return NextResponse.json({ error: { code: "idempotency_key_conflict", message: err.message } }, { status: 409 });
}
// 0pt#53: getSqlForDurableStore throws SiaxError(DEPENDENCY_UNAVAILABLE, 503) when
// production has no DATABASE_URL — surface it as documented, not opaque 500.
if (isSiaxError(err)) return siaxErrorResponseForLegacy(err);
throw err;
}
}
// GET /api/orders — list all orders (queue + financial aggregates). This is
// outside middleware.ts's matcher (/api/0pt/:path* only), so auth is enforced
// here directly via the same fail-closed Zitadel verifier — see
// 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 requireOrderOwner(req);
if (auth.response) return auth.response;
try {
const orders = await listOwnedOrders(auth.owner.tenantId);
return NextResponse.json(orders);
} catch (err: unknown) {
if (isSiaxError(err)) return siaxErrorResponseForLegacy(err);
throw err;
}
}