Skip to content

Commit 89a0286

Browse files
authored
Merge pull request #98 from UrumiAI/fix/qty-caps
[Service] Wire-level qty upper bounds on cart lines and /inventory/reserve
2 parents 6a22fe5 + c376d31 commit 89a0286

3 files changed

Lines changed: 291 additions & 3 deletions

File tree

.changeset/qty-upper-bound.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@urumi/service": minor
3+
---
4+
5+
Wire-level upper bounds on the three unbounded `qty` sites (service-hardening plan §4):
6+
`POST /carts/:cartId/lines`, `PATCH /carts/:cartId/lines/:lineId`, and
7+
`POST /inventory/reserve`. Today `qty: 1e9` (or `Number.MAX_SAFE_INTEGER`) is a "valid" wire
8+
request — only the store's arithmetic ever rejects it — so an absurd value reaches the store
9+
before anything says no. A zod `.max()` makes "how much may one request ask for" an explicit,
10+
documented, tested part of the contract instead of an accident of IEEE-754, and rejects it
11+
early and cheaply (400 at the schema boundary, before any store call and before any row is
12+
written).
13+
14+
At `0.x`, changesets map a **minor** bump to a breaking change (there is no major to take yet —
15+
semver's `0.x` carve-out). The `minor` here IS the breaking bump, not a feature bump.
16+
17+
**BREAKING (wire-visible):** previously-accepted requests now fail — `qty > 10_000`
18+
(`CART_LINE_MAX_QTY`, new exported constant) on `POST /carts/:cartId/lines` and
19+
`PATCH /carts/:cartId/lines/:lineId`, and `qty > 1_000_000_000` (`RESERVE_MAX_QTY`, new
20+
exported constant, aligned with the existing admin `stockMovementBody` cap) on
21+
`POST /inventory/reserve`, now return **400** `{error: "invalid request body", issues: [...]}`
22+
where they were previously accepted and processed. Both caps are two different numbers,
23+
deliberately: cart lines are the shopper-facing, anonymous-internet-caller surface (10k is
24+
already absurd for a storefront line); `/inventory/reserve` is the raw inventory primitive (a
25+
machine caller), whose natural peer is the admin stock-movement cap. Both are wire-only
26+
(zod, `schemas.ts`) — the domain already enforces the positive-integer bound
27+
(`domain/src/inventory/use-cases.ts`) as defense-in-depth; no domain or port change.
28+
29+
**Scope — read before assuming this closes the abuse surface:** this cap does **not** stop
30+
junk-`failed`-reservation-row amplification or general write amplification on
31+
`POST /inventory/reserve` / `POST /carts/:id/lines`. That is bound by **request count**, not
32+
qty magnitude — a caller sending 10,000 requests at `qty: 9,999` (comfortably under either cap)
33+
mints exactly as many junk rows as one request at `qty: 1e9` did before this change. The real
34+
mitigation is rate limiting / abuse control on these two unauthenticated write endpoints, which
35+
this repo does not have. Follow-up filed and tracked at
36+
[UrumiAI/otta.sh#91](https://github.com/UrumiAI/otta.sh/issues/91) — do not read this PR as a
37+
DoS fix.

packages/service/src/schemas.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,31 @@
11
import { z } from "zod";
22

3+
// Wire-level qty caps (service-hardening plan §4). Two different numbers,
4+
// deliberately: `/inventory/reserve` is the raw inventory primitive (a
5+
// machine caller, behind the write gate when configured) and is aligned with
6+
// the admin `stockMovementBody` cap below; cart lines are the shopper-facing,
7+
// anonymous-internet-caller surface and get a much tighter bound. Both are
8+
// WIRE-ONLY (zod) — the domain enforces the positive-integer bound too
9+
// (defense-in-depth; `domain/src/inventory/use-cases.ts`) — this caps the
10+
// wire value and makes "how much may one request ask for" an explicit, tested
11+
// part of the contract instead of an accident of IEEE-754 (today `qty: 1e9` /
12+
// `Number.MAX_SAFE_INTEGER` is a "valid" request that only the store's
13+
// arithmetic rejects).
14+
//
15+
// IMPORTANT — this is NOT a rate limit and does not fix junk-`failed`-
16+
// reservation-row amplification: that is bound by request COUNT, not qty
17+
// magnitude (10,000 requests at qty:9,999 each mint as many failed rows as
18+
// one request at qty:1e9). See the follow-up issue for rate-limiting
19+
// `POST /inventory/reserve` and `POST /carts/:id/lines`:
20+
// https://github.com/UrumiAI/otta.sh/issues/91
21+
export const CART_LINE_MAX_QTY = 10_000;
22+
export const RESERVE_MAX_QTY = 1_000_000_000;
23+
324
// Zod request bodies mirroring the inventory port 1:1 (§0.6). `Idempotency-Key`
425
// travels as a header, not in the body.
526
export const reserveBody = z.object({
627
sku: z.string().min(1),
7-
qty: z.number().int().positive(),
28+
qty: z.number().int().positive().max(RESERVE_MAX_QTY),
829
});
930

1031
export const commitBody = z.object({
@@ -26,7 +47,7 @@ export const createCartBody = z.object({
2647

2748
export const addLineBody = z.object({
2849
sku: z.string().min(1),
29-
qty: z.number().int().positive(),
50+
qty: z.number().int().positive().max(CART_LINE_MAX_QTY),
3051
// Phase 4: the product this line references. Optional for backward-compat with
3152
// bare Phase-3 adds; REQUIRED to later check out (an order needs a priced
3253
// product). When present, the service resolves the fulfillment kind from
@@ -35,7 +56,7 @@ export const addLineBody = z.object({
3556
});
3657

3758
export const patchLineBody = z.object({
38-
qty: z.number().int().positive(),
59+
qty: z.number().int().positive().max(CART_LINE_MAX_QTY),
3960
});
4061

4162
// Path-parameter sanity (N3): ids are opaque tokens — non-empty, bounded, and
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import {
2+
CountingIdGen,
3+
FakeEmailSender,
4+
FixedClock,
5+
InMemoryAddressStore,
6+
InMemoryCartStore,
7+
InMemoryCouponStore,
8+
InMemoryCredentialVerifier,
9+
InMemoryCustomerStore,
10+
InMemoryEntitlementStore,
11+
InMemoryInventoryStore,
12+
InMemoryOrderNotesStore,
13+
InMemoryOrderStore,
14+
InMemoryPaymentEventStore,
15+
InMemoryProductCommerceStore,
16+
InMemoryReportingStore,
17+
InMemorySessionStore,
18+
InMemorySettingsStore,
19+
InMemoryShippingRulesStore,
20+
InMemoryTaxRulesStore,
21+
} from "@urumi/domain/testing";
22+
import { StripePaymentGateway } from "@urumi/payments-stripe";
23+
import type { Hono } from "hono";
24+
import { describe, expect, test, vi } from "vitest";
25+
import { createApp } from "../src/app.js";
26+
import { CART_LINE_MAX_QTY, RESERVE_MAX_QTY } from "../src/schemas.js";
27+
28+
// PR C — wire-level qty caps (service-hardening plan §4). Zod-only bounds on
29+
// the three qty sites that previously accepted any positive safe integer
30+
// (including 1e9): `addLineBody`/`patchLineBody` get a shopper-facing
31+
// CART_LINE_MAX_QTY (10,000); `reserveBody` gets the same 1,000,000,000 cap as
32+
// the admin `stockMovementBody` precedent (the raw inventory primitive, a
33+
// machine caller). The cap is a wire bound only — an over-cap request never
34+
// reaches the store, so no reservation row (successful or failed) is minted.
35+
// This does NOT fix junk-row/request-count amplification (see the linked
36+
// follow-up issue); it only closes the "qty: 1e9 is a valid wire request" gap.
37+
interface TestApp {
38+
app: Hono;
39+
inventory: InMemoryInventoryStore;
40+
}
41+
42+
function makeApp(): TestApp {
43+
const clock = new FixedClock(new Date("2026-07-26T00:00:00.000Z"));
44+
const inventory = new InMemoryInventoryStore({
45+
idGen: new CountingIdGen("res"),
46+
clock,
47+
seed: [{ sku: "SKU-1", onHand: 20_000 }],
48+
});
49+
const cartStore = new InMemoryCartStore({
50+
idGen: new CountingIdGen("cart"),
51+
reservationState: (id) => {
52+
try {
53+
return inventory.reservationState(id);
54+
} catch {
55+
return undefined;
56+
}
57+
},
58+
releaseHold: (id) => {
59+
void inventory.release(id);
60+
},
61+
});
62+
const productCommerce = new InMemoryProductCommerceStore({
63+
clock,
64+
inventoryOnHand: (s) => inventory.onHand(s),
65+
});
66+
const idGen = new CountingIdGen("id");
67+
const customerStore = new InMemoryCustomerStore({ idGen, clock });
68+
const app = createApp({
69+
store: inventory,
70+
productCommerce,
71+
cartStore,
72+
orderStore: new InMemoryOrderStore({ idGen, clock }),
73+
orderNotesStore: new InMemoryOrderNotesStore({ idGen, clock }),
74+
entitlementStore: new InMemoryEntitlementStore({ idGen, clock }),
75+
paymentEventStore: new InMemoryPaymentEventStore(),
76+
shippingRules: new InMemoryShippingRulesStore(),
77+
taxRules: new InMemoryTaxRulesStore(),
78+
couponStore: new InMemoryCouponStore({ idGen, clock }),
79+
reportingStore: new InMemoryReportingStore(),
80+
settingsStore: new InMemorySettingsStore(),
81+
customerStore,
82+
addressStore: new InMemoryAddressStore({ idGen, clock }),
83+
sessionStore: new InMemorySessionStore({ idGen, clock }),
84+
credentialVerifier: new InMemoryCredentialVerifier({ customerStore, idGen, clock }),
85+
emailSender: new FakeEmailSender(),
86+
idGen,
87+
gateways: { stripe: new StripePaymentGateway({ webhookSecret: "whsec_gate_test", clock }) },
88+
clock,
89+
});
90+
return { app, inventory };
91+
}
92+
93+
const json = { "content-type": "application/json" };
94+
95+
async function newCart(app: Hono): Promise<string> {
96+
const res = await app.request("/carts", { method: "POST", headers: json, body: "{}" });
97+
expect(res.status).toBe(201);
98+
const body = (await res.json()) as { cartId: string };
99+
return body.cartId;
100+
}
101+
102+
describe("PR C — cart line qty cap (CART_LINE_MAX_QTY)", () => {
103+
test("POST /carts/:id/lines over cap is 400 with a structured error body", async () => {
104+
const { app } = makeApp();
105+
const cartId = await newCart(app);
106+
107+
const res = await app.request(`/carts/${cartId}/lines`, {
108+
method: "POST",
109+
headers: { ...json, "Idempotency-Key": "k1" },
110+
body: JSON.stringify({ sku: "SKU-1", qty: CART_LINE_MAX_QTY + 1 }),
111+
});
112+
113+
expect(res.status).toBe(400);
114+
const body = (await res.json()) as { error: string; issues: unknown };
115+
expect(body.error).toBe("invalid request body");
116+
expect(body.issues).toBeDefined();
117+
});
118+
119+
test("...and the store is never touched: reserve() not called, onHand unchanged", async () => {
120+
const { app, inventory } = makeApp();
121+
const cartId = await newCart(app);
122+
const reserveSpy = vi.spyOn(inventory, "reserve");
123+
const before = inventory.onHand("SKU-1");
124+
125+
const res = await app.request(`/carts/${cartId}/lines`, {
126+
method: "POST",
127+
headers: { ...json, "Idempotency-Key": "k1b" },
128+
body: JSON.stringify({ sku: "SKU-1", qty: CART_LINE_MAX_QTY + 1 }),
129+
});
130+
131+
expect(res.status).toBe(400);
132+
expect(reserveSpy).not.toHaveBeenCalled();
133+
expect(inventory.onHand("SKU-1")).toBe(before);
134+
});
135+
136+
test("POST /carts/:id/lines at the CART_LINE_MAX_QTY boundary succeeds (200)", async () => {
137+
const { app } = makeApp();
138+
const cartId = await newCart(app);
139+
140+
const res = await app.request(`/carts/${cartId}/lines`, {
141+
method: "POST",
142+
headers: { ...json, "Idempotency-Key": "k2" },
143+
body: JSON.stringify({ sku: "SKU-1", qty: CART_LINE_MAX_QTY }),
144+
});
145+
146+
expect(res.status).toBe(200);
147+
const body = (await res.json()) as { ok: boolean };
148+
expect(body.ok).toBe(true);
149+
});
150+
151+
async function existingLineId(app: Hono, cartId: string, key: string): Promise<string> {
152+
const addRes = await app.request(`/carts/${cartId}/lines`, {
153+
method: "POST",
154+
headers: { ...json, "Idempotency-Key": key },
155+
body: JSON.stringify({ sku: "SKU-1", qty: 1 }),
156+
});
157+
const addBody = (await addRes.json()) as { line: { lineId: string } };
158+
return addBody.line.lineId;
159+
}
160+
161+
test("PATCH /carts/:id/lines/:lineId over cap is 400", async () => {
162+
const { app } = makeApp();
163+
const cartId = await newCart(app);
164+
const lineId = await existingLineId(app, cartId, "k3");
165+
166+
const overCap = await app.request(`/carts/${cartId}/lines/${lineId}`, {
167+
method: "PATCH",
168+
headers: { ...json, "Idempotency-Key": "k4" },
169+
body: JSON.stringify({ qty: CART_LINE_MAX_QTY + 1 }),
170+
});
171+
expect(overCap.status).toBe(400);
172+
});
173+
174+
test("PATCH /carts/:id/lines/:lineId at the cap is 200", async () => {
175+
const { app } = makeApp();
176+
const cartId = await newCart(app);
177+
const lineId = await existingLineId(app, cartId, "k3b");
178+
179+
const atCap = await app.request(`/carts/${cartId}/lines/${lineId}`, {
180+
method: "PATCH",
181+
headers: { ...json, "Idempotency-Key": "k5" },
182+
body: JSON.stringify({ qty: CART_LINE_MAX_QTY }),
183+
});
184+
expect(atCap.status).toBe(200);
185+
});
186+
187+
test("the exact QA repro — qty: 1e9 on a cart line — is now 400", async () => {
188+
const { app } = makeApp();
189+
const cartId = await newCart(app);
190+
191+
const res = await app.request(`/carts/${cartId}/lines`, {
192+
method: "POST",
193+
headers: { ...json, "Idempotency-Key": "k6" },
194+
body: JSON.stringify({ sku: "SKU-1", qty: 1e9 }),
195+
});
196+
197+
expect(res.status).toBe(400);
198+
});
199+
});
200+
201+
describe("PR C — POST /inventory/reserve qty cap (RESERVE_MAX_QTY, aligned with stockMovementBody)", () => {
202+
test("qty: 1_000_000_001 is 400, reserve never called", async () => {
203+
const { app, inventory } = makeApp();
204+
const reserveSpy = vi.spyOn(inventory, "reserve");
205+
206+
const res = await app.request("/inventory/reserve", {
207+
method: "POST",
208+
headers: { ...json, "Idempotency-Key": "k7" },
209+
body: JSON.stringify({ sku: "SKU-1", qty: RESERVE_MAX_QTY + 1 }),
210+
});
211+
212+
expect(res.status).toBe(400);
213+
expect(reserveSpy).not.toHaveBeenCalled();
214+
});
215+
216+
test("qty: 1_000_000_000 reaches the store (200, OUT_OF_STOCK since it exceeds seeded on-hand)", async () => {
217+
const { app } = makeApp();
218+
219+
const res = await app.request("/inventory/reserve", {
220+
method: "POST",
221+
headers: { ...json, "Idempotency-Key": "k8" },
222+
body: JSON.stringify({ sku: "SKU-1", qty: RESERVE_MAX_QTY }),
223+
});
224+
225+
expect(res.status).toBe(200);
226+
const body = (await res.json()) as { ok: boolean; reason?: string };
227+
expect(body.ok).toBe(false);
228+
expect(body.reason).toBe("OUT_OF_STOCK");
229+
});
230+
});

0 commit comments

Comments
 (0)