Skip to content
18 changes: 16 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ Mallok decides the contracts on both sides. Its documentation is the reference:
- **`lib/scheduled.ts`** does one small piece of work per minute tick: a repricing chunk in progress, else a rate fetch if due, else clearing expired carts. State between ticks is in `p_shop_state`.
- **Migrations**: additive and idempotent. Mallok's migrator drops whole-line `--` comments, then splits on `;` — a comment must have a line to itself. They run on the Worker's first request, not from a CLI.
- **The cart route** (`routes/cart.ts`, `POST /_mallok/p/shop/cart`) takes a plain form POST and answers 303. The cart cookie is scoped to `/_mallok/p/shop`: Mallok bypasses its edge cache for any public request that carries a cookie.
- Pure logic (`money`, `pricing`, `ecb`, `order-state`, `currency`) takes no database; DB functions take a `D1Database` and, where time matters, a `now`.
- **Orders and payment are built but not reachable** (design §14). `lib/orders.ts` places an order and confirms its payment, `lib/order-fulfilment.ts` ships, cancels and refunds, `lib/outbox.ts` records what each change still owes, `lib/stripe-webhook.ts` decides what a Stripe delivery means and which status to answer, `lib/stripe-signature.ts` and `lib/stripe-client.ts` talk to Stripe over `fetch`, `lib/order-email.ts` builds the buyer's emails. No route, page or admin screen calls them, and nothing drains the outbox: those wait for Mallok. Do not add a route that works around that.
- **A change to an order is conditional on the status it was read in** (`ORDER_STILL_IN_STATUS` in `lib/order-guard.ts`), and the statement that changes the status comes last in its batch. That is what makes a racing second call write nothing.
- **A change to an order writes its outbox row in the same batch** (`orderChangedOutbox`). What follows the change — an email, a purge — is owed from that row. Never send from a function's return value: it is lost whenever the Worker stops after the batch. Rows are read back by `seq`, the order they were committed in, never by `created_at`, which is whatever time the caller passed.
- Pure logic (`money`, `pricing`, `ecb`, `order-state`, `currency`, `availability`, `stripe-signature`) takes no database; DB functions take a `D1Database` and, where time matters, a `now`.

### The theme and content

Expand All @@ -66,6 +69,7 @@ Mallok decides the contracts on both sides. Its documentation is the reference:
- No render-time hook with database access, so prices and variants are not on pages yet.
- Plugin routes cannot render through the theme, so there is no cart page yet.
- Plugin admin panels are read-only tables; variants are seeded from `seed/shop-sample.sql`.
- Plugin routes receive a parsed body, so a Stripe signature cannot be checked in one: there is no webhook route, and so no checkout.
- `reference[]` fields are not resolved, so a product names one collection.

Each is a task in Mallok's plan for plugin API 2. When Mallok ships one, upgrade, remove the corresponding limitation here, and prove the new behaviour with a test or the smoke run.
Expand All @@ -75,14 +79,24 @@ Each is a task in Mallok's plan for plugin API 2. When Mallok ships one, upgrade
- Tests in `test/shop` and `test/theme` run inside workerd against the site's own Worker. `test/shop/helpers.ts` brings a site up through Mallok's HTTP API (first request → admin → token → settings → plugin enabled); tables are created by Mallok's migrator, never by hand. Create content with `createContent`, not by inserting rows.
- Mallok caches pages in `caches.default`. In a test, create all content before requesting any page.
- A test for a fix must be seen failing without the fix. For new guards, break the guard and confirm the test goes red.
- A race is tested by running the calls with `Promise.all`, and such a test only counts once breaking the guard turns it red: that is the proof the two calls really interleave.
- `countD1Calls` in `test/shop/helpers.ts` counts round trips; use it wherever the number is a design constraint. `interceptBatches` runs a hook around each batch: it is how a test changes the data between a function's reading and its writing, or loses the answer to a write that committed.
- `npm run smoke:shop` is the only place theme, plugin, content and the Mallok CLI run together; run it when touching any of them.

## Commerce invariants (do not "simplify" them away)

- Money is integer minor units (`lib/money.ts`), never a float.
- A cart line is a variant and a quantity, never a price; `priceCart` recomputes from the database and reports every problem at once.
- MOQ and stock are enforced server-side in `quantityIssue`, shared by add-to-cart and cart pricing.
- `stock` carries `CHECK (stock >= 0)`. `test/shop/schema.test.ts` proves a failing decrement rolls back its whole D1 batch, and that `WHERE stock >= qty` does not — the payment write must rely on the constraint.
- `stock` carries `CHECK (stock >= 0)`. `test/shop/schema.test.ts` proves a failing decrement rolls back its whole D1 batch, and that `WHERE stock >= qty` does not — the payment write relies on the constraint.
- Stock comes off when a payment is confirmed, never when an order is placed, and only for stock-tracked variants. `markOrderPaid` is one batch — event, stock, ledger, outbox, status — and must stay one: splitting it brings back the state where stock is taken for an unpaid order, or an order is paid and its email never owed.
- One payment takes stock once, however it is reported: the same event again, a different event for the same payment intent, or two deliveries at the same moment. The parallel tests in `test/shop/orders.test.ts` hold this; do not weaken them into sequential ones.
- What to do with a payment is read from the data — is it on record, what status is the order in, is the stock there — and read again after a write that failed. Never from the text of an error.
- A payment the order cannot take (cancelled, or settled by another payment) is recorded as `refused` with an outbox row naming the payment to refund. The order and the stock are not touched, and the money is never left without a trace.
- A webhook body is trusted only after `verifyStripeSignature` has passed on the bytes as received. Answer 5xx only for what delivering again could change — a database failure; Stripe redelivers anything that is not a 2xx for three days.
- Money columns check `typeof(x) = 'integer'`: SQLite stores 99.5 in an `INTEGER` column rather than refuse it.
- A Stripe failure is described by Stripe's identifiers and the status, never its free-text message: nothing rules out that text repeating a buyer's email address.
- An order line is a snapshot of SKU, name and unit price. Nothing that later happens to the product may change a past order.
- A `manual` price is never overwritten; the base price is never rewritten; an `auto` price moves only past the drift threshold.
- Order status changes only through `lib/order-state.ts`.
- Every external input passes Zod; every SQL value is bound; redirects go only to same-site paths; logs carry no personal data.
Expand Down
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ same pull request and explain the new reasoning.
| MOQ | Enforced by the form and again by the server. A form can be bypassed. |
| Stock | Protected by `CHECK (stock >= 0)`. A decrement that would oversell fails, and rolls back the whole D1 batch it is part of — keep it that way rather than relying on `WHERE stock >= qty`, which matches no row without failing. |
| Manual prices | Never overwritten by an exchange-rate refresh. |
| Payment | One D1 batch: the event, the stock, the ledger, the outbox row and the order's status commit together or not at all. Every statement in it is conditional on the order's status, so a second delivery racing the first writes nothing. |
| Order status | Changed only through `lib/order-state.ts`, and written only if the order is still in the status that was checked. |
| Follow-ups | Whatever must happen after an order changes — an email, a purge — is a row in `p_shop_outbox`, written in the same batch as the change. Never something done afterwards on the strength of a return value. |
| Order lines | Snapshots. Renaming, repricing or delisting a product never changes a past order. |
| Webhooks | Verified against the bytes as received before anything is parsed. 5xx only for what delivering again could change; everything else is answered 200 and written down. |
| Language | Decided by the URL alone. Never redirect or switch by IP — crawlers would see one language. |
| References | A `reference` field names the target's slug **in the same language**. `test/content.test.ts` checks the sample content. |
| Bundle identity | A bundle needs a `mallok.json` only when something outside the content names it: the sample variants attach to the product by its translation group. Where the file exists, `test/content.test.ts` keeps it in step with the bundle. |
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Nundar builds on `mallok@0.1.0-rc.9`. Some of the shop needs extension points Ma
| **Catalogue data** | Variants, prices as integer minor units, stock, MOQ, lead time, a made-to-order policy | Editing them in the admin (read-only for now; the sample data is loaded from SQL) |
| **Pricing** | USD base price; EUR and GBP derived from ECB rates with a buffer, rounding to a price point and a drift threshold; manual prices never overwritten | |
| **Cart** | Add, set and remove through a plain form POST, with MOQ and stock enforced server-side | The cart page; submitting a cart as one inquiry |
| **Checkout** | | Payment, orders and order email (the next phase) |
| **Orders and payment** | The logic, tested and not yet reachable: orders with line snapshots, a payment that takes stock exactly once however often Stripe reports it, oversold orders, refunds that return stock, Stripe signature checks, order emails in four languages | The checkout and order pages, the webhook route, and order handling in the admin |

The reasoning and the plan are in [`docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md`](docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md).

Expand All @@ -70,6 +70,8 @@ The reasoning and the plan are in [`docs/superpowers/specs/2026-09-30-nundar-on-
- The cart stores variants and quantities only — never a price.
- MOQ is enforced by the form *and* by the server, because a form can be bypassed.
- Stock carries a database constraint, so a payment's decrement cannot go negative: a test proves the whole D1 batch rolls back.
- Stock comes off when a payment is confirmed, never before, and once: tests deliver the same payment twice at the same moment.
- A payment is believed only with Stripe's signature on the exact bytes received, and only for five minutes.
- A manually set price is never overwritten by an exchange-rate refresh.
- Language is decided by the URL alone, never by the visitor's IP.

Expand Down
4 changes: 3 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Nundar 基于 `mallok@0.1.0-rc.9`。商城的一部分功能需要 Mallok 目前
| **目录数据** | 规格、以整数最小单位存储的价格、库存、起订量、交期、按单生产策略 | 在后台编辑这些数据(目前只读,示例数据由 SQL 载入) |
| **定价** | 美元基准价;欧元和英镑按 ECB 汇率换算,带缓冲、价位取整和漂移阈值;手动价格永不被覆盖 | |
| **购物车** | 通过普通表单提交加入、修改、移除,服务端校验起订量和库存 | 购物车页;把购物车作为一次询盘提交 |
| **结账** | | 付款、订单和订单邮件(下一阶段) |
| **订单与付款** | 逻辑已写好并有测试,但还没有入口可以用到:带行快照的订单、无论 Stripe 通知多少次都只扣一次库存的付款、超卖订单、退还库存的退款、Stripe 签名校验、四种语言的订单邮件 | 结账页和订单页、webhook 路由、后台的订单处理 |

原因和计划见 [`docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md`](docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md)。

Expand All @@ -69,6 +69,8 @@ Nundar 基于 `mallok@0.1.0-rc.9`。商城的一部分功能需要 Mallok 目前
- 购物车只存规格和数量,绝不存价格。
- 起订量由表单校验,**并且**由服务端再校验一次,因为表单可以被绕过。
- 库存带数据库约束,付款时的扣减不可能变成负数:有测试证明整个 D1 batch 会回滚。
- 库存只在付款确认后扣减,绝不提前,而且只扣一次:有测试让同一笔付款在同一时刻到达两次。
- 一笔付款只有带着 Stripe 对原始字节的签名才会被采信,而且只在五分钟内有效。
- 手动设置的价格永远不会被汇率刷新覆盖。
- 语言只由网址决定,绝不按访客 IP 判断。

Expand Down
64 changes: 63 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ security document describes each. Nundar implements none of them again.

| Data | Where it lives | Notes |
|---|---|---|
| Card numbers, CVV | **Nowhere in Nundar** | Payment is not implemented yet. The design sends buyers to Stripe's hosted checkout, so card data will never reach this code. |
| Card numbers, CVV | **Nowhere in Nundar** | Payment goes through Stripe's hosted checkout, so card data never reaches this code. A test lists the columns an order may have. No checkout route exists yet. |
| Buyer email and shipping address | D1, on the order | Written when an order is placed, which nothing can do yet: the order logic is built and has no route. Never written to a log. |
| Stripe keys and the webhook signing secret | Not stored yet | They will be plugin secrets, which Mallok encrypts. No code path reads them today. |
| Cart contents | D1, keyed by an unguessable 128-bit id | Variant ids and quantities only — **never prices**. A test asserts the table has no price column. |
| The cart cookie | `HttpOnly`, `SameSite=Lax`, `Secure` on HTTPS, scoped to `/_mallok/p/shop` | It identifies a cart and nothing else. It is not a session and grants nothing in the admin. |
| Prices, stock, variants | D1 | Changed only through the admin or the plugin's own scheduled repricing. |
Expand Down Expand Up @@ -77,6 +79,52 @@ These are deliberate and should not be "simplified" away:
at write time, so a price set by hand between the read and the write still
stands.

### Payment, built and not yet reachable

The order and payment logic is in the repository with its tests, and no route
calls it yet. These controls are in that code now, so that they are not left
to be remembered when the routes are written:

- **A payment is believed only with Stripe's signature** over the bytes as
received, compared in constant time, and only within five minutes of the
time Stripe signed it. Only the `v1` scheme is read. With no signing secret
configured, everything is refused.
- **No amount comes from a client.** An order's lines are priced by the
server, and the call that opens a Checkout session takes a single amount,
the order's total, rather than line items a client could have shaped.
- **Stock is taken when a payment is confirmed, never before.** Taking it when
an order is placed would let scripted, unpaid orders empty the catalogue.
- **A payment takes stock once**, whether Stripe delivers the event twice,
sends two events for one payment, or delivers twice at the same moment.
Every write is conditional on the order's status, and tests run the
deliveries in parallel.
- **A payment is all or nothing.** The event, the stock, the ledger, what is
owed afterwards and the order's status are one D1 batch. An order whose
stock has gone is marked `oversold` with nothing taken, for a person to
refund.
- **Money taken is never left without a trace.** A payment for an order that
cannot take it — a cancelled one, or one another payment has settled — is
recorded with the payment to refund, and the order and the stock are left
alone.
- **A webhook fails only for what a retry could fix.** Anything else answered
with an error would be redelivered for three days. A payment naming an
order this shop does not have is answered and not acted on: every shop on a
Stripe account sees every payment of that account.
- **Money is a whole number at the database too.** The order tables check the
storage type of every amount, so a fraction of a minor unit cannot be
stored even by code that forgot to round.
- **A Stripe error never carries what was sent.** It is described by Stripe's
own identifiers and the HTTP status. The free-text message is dropped:
nothing rules out its repeating a value that was sent, such as an email
address.
- **An order's status moves only as the state machine allows**, and only if
the order is still where it was read. An oversold order cannot ship.
- **Order lines are snapshots.** A later change to a product cannot alter what
a past order says was bought, or for how much.
- **Emails escape everything a person typed** — product names, SKUs, tracking
numbers — before it becomes markup in a buyer's inbox.
- **A failure reason never carries personal data.** It names the order by id.

## Known residual risks

Stated plainly rather than left for an auditor to find:
Expand All @@ -96,6 +144,20 @@ Stated plainly rather than left for an auditor to find:
exists, do not show prices by another route.
- **Sample content and sample variants are public test data.** Do not load
`seed/shop-sample.sql` into a production database.
- **Nobody is told about a payment that has to be refunded.** An oversold
order, and a payment for an order that was cancelled while its buyer was
still on Stripe's page, are both recorded with a row in the outbox — and
nothing reads the outbox yet. Until something does, a person finds them
only by looking. Cancelling an order should also expire its Checkout
session, so that the second case cannot arise; that is not built either.
- **The amount Stripe reports is not compared with the order's total.** The
session is opened for the order's total by the server, so they agree unless
something on the Stripe side changes what the buyer pays. Not yet decided;
see the design's §14.
- **Orders that are never paid stay in the database as `pending`**, with the
buyer's email and address, until something removes them. Nothing does yet.
- **An order that costs nothing cannot be completed.** Stripe is never asked
to charge zero, and nothing confirms a free order without it yet.

## Dependency posture

Expand Down
Loading
Loading