From c7fd9d45a2ed9183f2baba630253edb42b10bc85 Mon Sep 17 00:00:00 2001 From: sax3l Date: Tue, 22 Sep 2026 04:33:57 +0200 Subject: [PATCH] =?UTF-8?q?fix(prod):=20map=20SiaxError=20in=20the=207=20l?= =?UTF-8?q?egacy=20money=20routes=20=E2=80=94=20DEPENDENCY=5FUNAVAILABLE/5?= =?UTF-8?q?03=20instead=20of=20opaque=20500=20(0pt#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/admin/metrics/route.ts | 10 +++- app/api/orders/[id]/route.ts | 26 ++++++++- app/api/orders/route.ts | 13 ++++- app/api/payments/[order_id]/deposit/route.ts | 10 +++- app/api/payments/[order_id]/release/route.ts | 10 +++- app/api/siax-error-mapping.ts | 28 +++++++++ app/api/subscriptions/route.ts | 18 +++++- app/api/webhooks/stripe/route.ts | 30 +++++++++- .../legacy-routes-siax-error-mapping.test.ts | 57 +++++++++++++++++++ 9 files changed, 188 insertions(+), 14 deletions(-) create mode 100644 app/api/siax-error-mapping.ts create mode 100644 tests/api/legacy-routes-siax-error-mapping.test.ts diff --git a/app/api/admin/metrics/route.ts b/app/api/admin/metrics/route.ts index 5d64deb..42420dd 100644 --- a/app/api/admin/metrics/route.ts +++ b/app/api/admin/metrics/route.ts @@ -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 = {}; let depositPaidOre = 0; let pipelineOre = 0; diff --git a/app/api/orders/[id]/route.ts b/app/api/orders/[id]/route.ts index ce317d6..c05b3ba 100644 --- a/app/api/orders/[id]/route.ts +++ b/app/api/orders/[id]/route.ts @@ -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 = { 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); } diff --git a/app/api/orders/route.ts b/app/api/orders/route.ts index 3c2941b..4321809 100644 --- a/app/api/orders/route.ts +++ b/app/api/orders/route.ts @@ -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; + } } diff --git a/app/api/payments/[order_id]/deposit/route.ts b/app/api/payments/[order_id]/deposit/route.ts index 89d96cb..661de0b 100644 --- a/app/api/payments/[order_id]/deposit/route.ts +++ b/app/api/payments/[order_id]/deposit/route.ts @@ -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 }); } diff --git a/app/api/payments/[order_id]/release/route.ts b/app/api/payments/[order_id]/release/route.ts index 9d3bc71..84acf88 100644 --- a/app/api/payments/[order_id]/release/route.ts +++ b/app/api/payments/[order_id]/release/route.ts @@ -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 }); diff --git a/app/api/siax-error-mapping.ts b/app/api/siax-error-mapping.ts new file mode 100644 index 0000000..0f4e402 --- /dev/null +++ b/app/api/siax-error-mapping.ts @@ -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 }); +} diff --git a/app/api/subscriptions/route.ts b/app/api/subscriptions/route.ts index 2846da1..c9a5494 100644 --- a/app/api/subscriptions/route.ts +++ b/app/api/subscriptions/route.ts @@ -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; + } } diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index 7f456ee..ea531a1 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -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 }); } } diff --git a/tests/api/legacy-routes-siax-error-mapping.test.ts b/tests/api/legacy-routes-siax-error-mapping.test.ts new file mode 100644 index 0000000..9585ce3 --- /dev/null +++ b/tests/api/legacy-routes-siax-error-mapping.test.ts @@ -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) => { + 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) => { + expect(body.source).toBe("0pt.subscriptions"); + }); + }); +}); \ No newline at end of file -- 2.54.0