fix(prod): map SiaxError in the 7 legacy money routes — DEPENDENCY_UNAVAILABLE/503 instead of opaque 500 #81

Merged
admin merged 1 commits from fix/legacy-routes-siax-error-mapping into main 2026-09-22 02:41:55 +00:00
9 changed files with 188 additions and 14 deletions
+9 -1
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { listOrders } from "@/lib/orders-store";
import { requireRouteAuth } from "@/src/auth/require-route-auth";
import { isSiaxError, siaxErrorResponseForLegacy } from "../../siax-error-mapping";
// GET /api/admin/metrics — order queue + financial aggregates for the admin
// dashboard. Outside middleware.ts's matcher (/api/0pt/:path* only) and
@@ -9,7 +10,14 @@ import { requireRouteAuth } from "@/src/auth/require-route-auth";
export async function GET(req: NextRequest) {
const auth = await requireRouteAuth(req);
if (auth.response) return auth.response;
const orders = await listOrders();
let orders;
try {
orders = await listOrders();
} catch (err: unknown) {
// 0pt#53: durable-store failure -> documented DEPENDENCY_UNAVAILABLE/503.
if (isSiaxError(err)) return siaxErrorResponseForLegacy(err);
throw err;
}
const byStatus: Record<string, number> = {};
let depositPaidOre = 0;
let pipelineOre = 0;
+23 -3
View File
@@ -1,13 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { getOwnedOrder, setOwnedStatusIfCurrent } from "@/lib/orders-store";
import { requireOrderOwner } from "@/src/auth/require-order-owner";
import { isSiaxError, siaxErrorResponseForLegacy } from "../../siax-error-mapping";
// 0pt#53: durable-store failures arrive as SiaxError(DEPENDENCY_UNAVAILABLE, 503);
// map them instead of letting App Router render an opaque 500.
async function readOwnedOrder(id: string, tenantId: string) {
try {
return await getOwnedOrder(id, tenantId);
} catch (err: unknown) {
if (isSiaxError(err)) return siaxErrorResponseForLegacy(err);
throw err;
}
}
// GET /api/orders/{id}
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 getOwnedOrder(id, auth.owner.tenantId);
const order = await readOwnedOrder(id, auth.owner.tenantId);
if (order instanceof NextResponse) return order;
if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
return NextResponse.json(order);
}
@@ -35,7 +48,8 @@ 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 getOwnedOrder(id, auth.owner.tenantId);
const order = await readOwnedOrder(id, auth.owner.tenantId);
if (order instanceof NextResponse) return order;
if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
const previous: Record<string, string[]> = {
quoted: ["draft"],
@@ -47,7 +61,13 @@ 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 setOwnedStatusIfCurrent(id, auth.owner.tenantId, order.status, body.status);
let updated;
try {
updated = await setOwnedStatusIfCurrent(id, auth.owner.tenantId, order.status, body.status);
} catch (err: unknown) {
if (isSiaxError(err)) return siaxErrorResponseForLegacy(err);
throw err;
}
if (!updated) return NextResponse.json({ error: { code: "stale_transition" } }, { status: 409 });
return NextResponse.json(updated);
}
+11 -2
View File
@@ -1,6 +1,7 @@
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.
@@ -38,6 +39,9 @@ export async function POST(req: NextRequest) {
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;
}
}
@@ -50,6 +54,11 @@ export async function POST(req: NextRequest) {
export async function GET(req: NextRequest) {
const auth = await requireOrderOwner(req);
if (auth.response) return auth.response;
const orders = await listOwnedOrders(auth.owner.tenantId);
return NextResponse.json(orders);
try {
const orders = await listOwnedOrders(auth.owner.tenantId);
return NextResponse.json(orders);
} catch (err: unknown) {
if (isSiaxError(err)) return siaxErrorResponseForLegacy(err);
throw err;
}
}
+9 -1
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getOwnedOrder } from "@/lib/orders-store";
import { requireOrderOwner } from "@/src/auth/require-order-owner";
import { isSiaxError, siaxErrorResponseForLegacy } from "../../../siax-error-mapping";
// POST /api/payments/[order_id]/deposit
// Returns { url } for Stripe Checkout. Returns 501 when STRIPE_SECRET_KEY is absent.
@@ -21,7 +22,14 @@ export async function POST(
// request.nextUrl.origin reflects the actual request URL, not attacker-controlled input.
const origin = request.nextUrl.origin;
const order = await getOwnedOrder(order_id, auth.owner.tenantId);
let order;
try {
order = await getOwnedOrder(order_id, auth.owner.tenantId);
} catch (err: unknown) {
// 0pt#53: durable-store failure -> documented DEPENDENCY_UNAVAILABLE/503.
if (isSiaxError(err)) return siaxErrorResponseForLegacy(err);
throw err;
}
if (!order) {
return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
}
+9 -1
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getStripe } from "@/lib/stripe";
import { getOwnedOrder } from "@/lib/orders-store";
import { requireOrderOwner } from "@/src/auth/require-order-owner";
import { isSiaxError, siaxErrorResponseForLegacy } from "../../../siax-error-mapping";
// POST /api/payments/{order_id}/release (docs/04) — charge the held 50% balance.
// Called on ACCEPT only; escrow balance is never charged before approval.
@@ -9,7 +10,14 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ order_id:
const auth = await requireOrderOwner(req);
if (auth.response) return auth.response;
const { order_id } = await ctx.params;
const order = await getOwnedOrder(order_id, auth.owner.tenantId);
let order;
try {
order = await getOwnedOrder(order_id, auth.owner.tenantId);
} catch (err: unknown) {
// 0pt#53: durable-store failure -> documented DEPENDENCY_UNAVAILABLE/503.
if (isSiaxError(err)) return siaxErrorResponseForLegacy(err);
throw err;
}
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 });
+28
View File
@@ -0,0 +1,28 @@
/**
* app/api/siax-error-mapping.ts — shared error mapper for the LEGACY routes
* outside the /api/0pt matcher (`app/api/orders*`, `/subscriptions`,
* `/payments/*`, `/webhooks/stripe`, `/admin/metrics`).
*
* 0pt#53: `getSqlForDurableStore` (lib/db.ts) throws
* `SiaxError("DEPENDENCY_UNAVAILABLE")` (httpStatus 503, retryable) when a
* money-bearing store would otherwise fall back to an in-memory Map in a
* production runtime. The /api/0pt/* surface already maps that error via
* src/http/siax-error-response.ts; these legacy routes did not, so the
* documented 503 DEPENDENCY_UNAVAILABLE leaked as Next's opaque 500.
*
* The helpers preserve each route's OWN typed error contract
* (IdempotencyKeyConflictError -> 409, etc.) and only ADD the SiaxError
* branch — a plain rethrow is deliberately avoided because App Router turns
* any uncaught thrown SiaxError into a generic 500 with no error body.
*/
import { NextResponse } from "next/server";
import { SiaxError } from "@/src/platform/errors";
export function isSiaxError(error: unknown): error is SiaxError {
return error instanceof SiaxError;
}
/** Map a caught error to a NextResponse: SiaxError -> its pinned status/body. */
export function siaxErrorResponseForLegacy(error: SiaxError): NextResponse {
return NextResponse.json(error.toJSON(), { status: error.httpStatus });
}
+15 -3
View File
@@ -1,9 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { createSubscription, listSubscriptions } from "@/lib/subscriptions-store";
import { isSiaxError, siaxErrorResponseForLegacy } from "../siax-error-mapping";
// GET /api/subscriptions — feeds list. POST — convert an order to a recurring feed.
export async function GET() {
return NextResponse.json(await listSubscriptions());
try {
return NextResponse.json(await listSubscriptions());
} catch (err: unknown) {
// 0pt#53: durable-store failure -> documented DEPENDENCY_UNAVAILABLE/503.
if (isSiaxError(err)) return siaxErrorResponseForLegacy(err);
throw err;
}
}
export async function POST(req: NextRequest) {
@@ -12,6 +19,11 @@ export async function POST(req: NextRequest) {
if (!body.order_id) {
return NextResponse.json({ error: { code: "bad_request", message: "order_id krävs" } }, { status: 400 });
}
const sub = await createSubscription(body.order_id, body.cadence ?? "weekly", body.webhook_url ?? null);
return NextResponse.json(sub, { status: 201 });
try {
const sub = await createSubscription(body.order_id, body.cadence ?? "weekly", body.webhook_url ?? null);
return NextResponse.json(sub, { status: 201 });
} catch (err: unknown) {
if (isSiaxError(err)) return siaxErrorResponseForLegacy(err);
throw err;
}
}
+27 -3
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import type Stripe from "stripe";
import { getStripe } from "@/lib/stripe";
import { getOrder, setStatusIfCurrent } from "@/lib/orders-store";
import { isSiaxError } from "../../siax-error-mapping";
// POST /api/webhooks/stripe (docs/04) — verify signature, advance order status.
export async function POST(req: NextRequest) {
@@ -33,7 +34,16 @@ export async function POST(req: NextRequest) {
if (s.payment_status !== "paid") {
return NextResponse.json({ error: "payment_not_confirmed" }, { status: 503 });
}
const order = await getOrder(oid);
// 0pt#53: durable-store failure -> documented non-2xx (never an opaque 500,
// and never a silent received:true for a paid event that was NOT processed —
// the non-2xx keeps Stripe's delivery retry path open).
let order;
try {
order = await getOrder(oid);
} catch (err: unknown) {
if (isSiaxError(err)) return NextResponse.json({ received: false, reason: "store_unavailable" }, { status: 503 });
throw err;
}
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
@@ -48,7 +58,14 @@ export async function POST(req: NextRequest) {
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"))) {
let delivered = false;
try {
delivered = order.status === "accepted" && !!(await setStatusIfCurrent(oid, "accepted", "delivered"));
} catch (err: unknown) {
if (isSiaxError(err)) return NextResponse.json({ received: false, reason: "store_unavailable" }, { status: 503 });
throw err;
}
if (order.status === "accepted" && !delivered) {
return NextResponse.json({ error: "order_update_failed" }, { status: 503 });
}
} else {
@@ -56,7 +73,14 @@ export async function POST(req: NextRequest) {
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"))) {
let advanced = true;
try {
advanced = alreadyAdvanced || !!(await setStatusIfCurrent(oid, order.status, "in_production"));
} catch (err: unknown) {
if (isSiaxError(err)) return NextResponse.json({ received: false, reason: "store_unavailable" }, { status: 503 });
throw err;
}
if (!advanced) {
return NextResponse.json({ error: "order_update_failed" }, { status: 503 });
}
}
@@ -0,0 +1,57 @@
/**
* 0pt#53: `getSqlForDurableStore` (lib/db.ts) throws
* `SiaxError("DEPENDENCY_UNAVAILABLE")` (httpStatus 503, retryable) when a
* money-bearing store would silently fall back to an in-memory Map in a
* production runtime. The /api/0pt/* surface maps that error via
* src/http/siax-error-response.ts; the SEVEN legacy routes outside the
* middleware matcher (orders, orders/[id], subscriptions, payments/deposit,
* payments/release, webhooks/stripe, admin/metrics) did NOT, so the documented
* 503 leaked as Next's opaque 500 with no error body.
*
* These tests pin the mapping itself: `isSiaxError` accepts real SiaxErrors,
* `siaxErrorResponseForLegacy` renders the pinned `.httpStatus` and the
* structured `toJSON()` body (code + source + retryable, no internals), and
* — most importantly — the DEPENDENCY_UNAVAILABLE code maps to 503 with
* `retryable: true` so callers (Stripe retries, the UI) can distinguish a
* transient store outage from a hard failure.
*/
import { describe, it, expect } from "vitest";
import { NextResponse } from "next/server";
import { SiaxError } from "@/src/platform/errors";
import { isSiaxError, siaxErrorResponseForLegacy } from "@/app/api/siax-error-mapping";
describe("0pt#53 legacy-route SiaxError mapping (app/api/siax-error-mapping.ts)", () => {
it("isSiaxError: true for a real SiaxError, false for plain errors", () => {
expect(isSiaxError(new SiaxError("DEPENDENCY_UNAVAILABLE", "0pt.orders", "x"))).toBe(true);
expect(isSiaxError(new Error("plain"))).toBe(false);
expect(isSiaxError(null)).toBe(false);
expect(isSiaxError("nope")).toBe(false);
});
it("DEPENDENCY_UNAVAILABLE maps to 503 with the structured, non-internal body", () => {
const err = new SiaxError(
"DEPENDENCY_UNAVAILABLE",
"0pt.orders",
'no database configured for persistent store "0pt.orders" in a production runtime',
);
expect(err.httpStatus).toBe(503);
expect(err.retryable).toBe(true);
const res = siaxErrorResponseForLegacy(err) as NextResponse;
expect(res.status).toBe(503);
void res.json().then((body: Record<string, unknown>) => {
expect(body.error).toBe("DEPENDENCY_UNAVAILABLE");
expect(body.source).toBe("0pt.orders");
expect(body.retryable).toBe(true);
});
});
it("the store name travels in the body so the operator knows WHICH store failed", () => {
const res = siaxErrorResponseForLegacy(
new SiaxError("DEPENDENCY_UNAVAILABLE", "0pt.subscriptions", "no database"),
);
void res.json().then((body: Record<string, unknown>) => {
expect(body.source).toBe("0pt.subscriptions");
});
});
});