fix(prod): map SiaxError in the 7 legacy money routes — DEPENDENCY_UNAVAILABLE/503 instead of opaque 500 (0pt#53)
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
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
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
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user