Skip to content

Commit c376d31

Browse files
committed
[Service] Wire-level qty upper bounds on cart lines and /inventory/reserve
Zod .max() caps on the three unbounded qty sites (service-hardening plan §4): CART_LINE_MAX_QTY = 10_000 on addLineBody/patchLineBody (the shopper-facing, anonymous-caller surface), RESERVE_MAX_QTY = 1_000_000_000 on reserveBody (aligned with the existing admin stockMovementBody precedent — the raw inventory primitive, a machine caller). Both exported as named constants so tests assert boundaries against the same symbol. Today qty: 1e9 (or Number.MAX_SAFE_INTEGER) is a "valid" wire request rejected only by store arithmetic; the cap makes "how much may one request ask for" an explicit, tested part of the contract and rejects it early (400 at the zod boundary, before any store call or row write). Wire-only — the domain's positive-integer bound is unchanged (defense-in-depth); no port change. This is NOT a rate limit and does not fix junk-failed-reservation-row amplification, which is request-count-bound, not qty-bound. Follow-up filed and linked from the schemas.ts comment and the changeset: #91 Tests: packages/service/test/qty-bounds.test.ts (8 new, IO-free, in-memory stores via app.request()) — over-cap 400 with structured error, store never touched (reserve() spy + onHand unchanged), at-cap 200 boundary for both POST/PATCH cart-line paths, the reserve-endpoint pair at RESERVE_MAX_QTY / +1, and the exact QA repro (qty: 1e9) now 400. Changeset: @urumi/service minor — the breaking bump at 0.x (no major yet); previously-accepted over-cap requests now 400.
1 parent 6a22fe5 commit c376d31

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)