Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Mallok decides the contracts on both sides. Its documentation is the reference:
- **An inquiry's emails are a job** (`lib/inquiry-jobs.ts`), queued with `ctx.enqueueStatement` in that batch and given an id and nothing else. It may run twice: each email is marked on the inquiry once it has been handed to Mallok (`notified_at`, `acknowledged_at`), and only what is not marked is sent. The seller's is in English and answered to the buyer; the buyer's, in their language, is sent only when `inquiry_acknowledge` is on — it is off by default because the form has no challenge in front of it. This is the one place the plugin uses Mallok's jobs; the order code still writes its own outbox, which nothing drains.
- **Inquiries are read in the admin** in a `table` panel with their lines as a related table, and marked, exported and deleted by the actions in `lib/inquiry-admin.ts`. An amount is stored twice, as minor units and as the text the buyer saw, because a table panel lists a column as it is stored. `cart_id` and `ip_hash` are in the table and not in the panel.
- **An inquiry is the first personal data the shop holds.** It goes in no log and no address, and the plugin's own job carries an id only. Every value goes through `escapeHtml` on its way into an email; a name, a company and a phone number are made one line each (white space collapses; it is not refused, because a refused form comes back empty); an address is held to an allow-list (`EMAIL_PATTERN_SOURCE`), because the admin makes a `mailto:` link of it; a cell of the export that a spreadsheet would run as a formula is made text, and so is every piece a semicolon would make a cell of (`inert`). **What the plugin cannot keep to itself is the email**: `ctx.sendEmail` stores the whole message in Mallok's `job` table, where deleting the inquiry does not reach it. `SECURITY.md` says so; do not claim otherwise.
- **What the shop holds is in a site export** (`exportFiles`, `lib/export.ts`; design §19): variants with the slug of their product, prices, the stock ledger, orders and their lines, payment events, the outbox, inquiries and their lines — a JSON file each under `shop/`, a row an object, as stored, with a manifest and the shop's own settings, which Mallok's `site.json` does not carry. Tables are read with `*`, so a column a migration adds is exported without anyone remembering; `test/shop/export.test.ts` fails when a new `p_shop_` table is neither exported nor on its list of those left behind (carts, rates, the scheduler's state), and when an exported table has a column the test does not list — so that a new column is looked at before it is carried out of the shop. A column an export adds to a row has a name no table's column has (`variant_sku`, `product_slug`): of two columns of one name D1 keeps the last. An inquiry's `cart_id` and `ip_hash` stay behind. Nothing is rewritten for a spreadsheet: that is the admin's CSV, which is for reading; this is for carrying. A table over `EXPORT_ROW_LIMIT`, or files over `EXPORT_BYTE_LIMIT` together, fail the export rather than leave rows out, because Mallok then refuses to call it a backup — a file missing rows would pass for one. Both are guards against a Worker's memory and neither is a measurement. The reason is in the endpoint's answer and in the Worker's log (`shop_export_refused`); Mallok's command line and admin show only that the shop failed, and suggest switching the failing plugin off — which would give a backup with nothing of the shop's in it.
- **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.
Expand Down Expand Up @@ -105,6 +106,7 @@ When one is built, take it off this list and prove the behaviour with a test or
- A challenge cannot be drawn on a plugin's page: a theme may load no script from another host and `afterRender` does not run there, so the inquiry form has no Turnstile in front of it though Mallok could verify a token. What stands in its place is in `SECURITY.md`; do not add a script to get round it.
- `ctx.enqueueStatement` takes no condition, so a job is queued beside a conditional write even when the write stored nothing. A job has to find nothing to do in that case.
- A page cannot be rendered from a `POST` without being listed under the `POST`'s address, so a form the server refuses comes back with its fields empty.
- A plugin's export is built whole, in memory, in the one request that exports the site: there is no way to hand Mallok a table a page at a time. So the shop's export has a ceiling of rows a table, and a shop past it cannot be exported at all until Mallok offers one.
- Nothing tells a plugin of a deletion it missed, and nothing here sweeps up afterwards: a variant with no product may as well be one that was loaded before its product was published, and a sweep cannot tell the two apart.
- Switching a plugin on or off, or changing a setting, can leave cached pages as they were — and the admin's "Clear cached pages" can report success without clearing anything. So the plugins are switched on before the first publish (`scripts/lib/local-shop.mjs`, the README), and `test/shop/helpers.ts` deletes the cached home page by hand after changing settings. Keep both until a Mallok release says the purge can be trusted.

Expand All @@ -113,7 +115,7 @@ When one is built, take it off this list and prove the behaviour with a test or
- 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 **every time**: that is the proof the two calls really interleave. Through `SELF.fetch` they do so about half the time, and where a request reads before it writes, the other half never reaches the guard. `holdWrites` in `test/shop/helpers.ts` holds each request at its write until the test has seen them all arrive; the inquiry's races use it. It waits by polling a flag: a promise settled by one request for another stops the Worker. Functions called side by side in one test are the other case: each runs as far as its reading before the next begins, every time. `atTheSameMoment` runs them and fails unless every call had read before any wrote, so that this is seen on every run and not assumed; the payment and fulfilment races use it. An order's write is stopped by several keys at once — the status it was read in, the event's keys, the ledger row's id, the outbox row's id — so taking one away turns no test red; taking them all away turns each race red every time.
- A race is tested by running the calls with `Promise.all`, and such a test only counts once breaking the guard turns it red **every time**: that is the proof the two calls really interleave. Through `SELF.fetch` they do so about half the time, and where a request reads before it writes, the other half never reaches the guard. `holdWrites` in `test/shop/helpers.ts` holds each request at its write until the test has seen them all arrive; the inquiry's races use it. It waits by polling a flag: a promise settled by one request for another stops the Worker. Functions called side by side in one test are the other case: each runs as far as its reading before the next begins, every time. `atTheSameMoment` runs them and fails unless every call had made its reading before any made its write, so that this is seen on every run and not assumed; the payment and fulfilment races use it. What it watches is the order of each call's trips to the database it was handed: tell it how many readings a function makes when that is not one, and have the function use that database and no other. A payment's write is stopped by several keys at once — the status it was read in, the event's keys, the ledger row's id, the outbox row's id — so taking the shared status condition away turns no race between two payments red; the races between two different changes of an order, which have no key in common, do go red.
- `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.
- `test/theme/pages.test.ts` renders every layout from content it creates itself, with everything set; `test/theme/bare.test.ts` does the same for a site that has filled in almost nothing, and fails on any empty element or `href=""`. Neither reads `content/`. A template that prints a wrapper has to check that there is something to put in it — and `content.html` is not a string, so capture it before comparing it with `blank`. The sample is checked by `test/content.test.ts` and `test/project.test.ts` (files only, no Worker) and by the smoke run.
- `npm run smoke:shop` is the only place theme, plugin, content and the Mallok CLI run together; run it when touching any of them. It publishes the real sample, requests every kind of page, and follows every header and footer link in all four languages.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Nundar builds on `mallok@0.1.0-rc.11`, the first release with the plugin API the
| **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; the pages of a product are purged from the cache when one of its prices moves | |
| **Cart** | A form beside each size on a product page, and a cart page in the site's own design and language: set a quantity, remove a line, choose a currency. No script anywhere in it. MOQ, stock and the most a line may hold (500) are enforced by the server, which says what it refused on the cart page | Checkout |
| **Inquiries** | The cart sent as one request for a quote, from a form on the cart page: who to answer, and a message. A size with no price can go in the cart to be asked about. The buyer lands on the inquiry's number; the seller is told by email, answerable straight to the buyer, and finds every inquiry in the admin with its lines — to mark, export as CSV, or delete | A challenge in front of the form (it waits for Mallok), which is why the email confirming to the buyer is off unless switched on |
| **Taking your data with you** | What the shop holds is in the site's export (`mallok export`), beside the content: variants, prices, the stock ledger, orders, payment events, inquiries and the shop's settings, a JSON file each under `shop/`, as stored | An import of those files; an export of a table too large for one request (both wait for Mallok) |
| **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 Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Nundar 基于 `mallok@0.1.0-rc.11`,这是第一个带有商城其余部分所
| **定价** | 美元基准价;欧元和英镑按 ECB 汇率换算,带缓冲、价位取整和漂移阈值;手动价格永不被覆盖;某个商品的价格变动时,清除该商品页面的缓存 | |
| **购物车** | 商品页每个尺寸旁有一个表单;购物车页沿用站点自己的设计和语言,可改数量、移除、选币种,全程不需要脚本。起订量、库存和单行数量上限(500)由服务端校验,被拒绝的原因直接显示在购物车页上 | 结账 |
| **询盘** | 在购物车页填一张表单(回复给谁、留言),把整个购物车作为一张询价单发出。没有价格的尺寸也可以加入购物车来询价。买家提交后看到询盘编号;卖家收到一封邮件,直接回复即可回到买家,并在后台看到每张询盘及其明细,可以标记、导出 CSV 或删除 | 表单前面的人机验证(等 Mallok 提供),因此给买家的确认邮件默认关闭,需要手动开启 |
| **带走你的数据** | 商城持有的数据都在站点导出(`mallok export`)里,和内容放在一起:规格、价格、库存台账、订单、付款事件、询盘和商城自己的设置,各为 `shop/` 下的一个 JSON 文件,按存储原样导出 | 把这些文件再导入;单次请求装不下的大表的导出(两者都要等 Mallok) |
| **订单与付款** | 逻辑已写好并有测试,但还没有入口可以用到:带行快照的订单、无论 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 Down
17 changes: 17 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ security document describes each. Nundar implements none of them again.
| Stripe keys and the webhook signing secret | Not stored yet | They will be plugin secrets, which Mallok encrypts. No code path reads them today. |
| What a buyer says of themselves in an inquiry: name, email address, company, phone, message | D1, on the inquiry | Written when a cart is sent as a request for a quote. It is the first personal data the shop holds. Read in the admin by a signed-in administrator, or a token with `export`; deleted there. Never written to a log or put in an address, and the job the plugin queues carries an id only. **The email is another matter**: once handed to Mallok it sits, whole, in Mallok's own queue — see the residual risks. |
| A mark of the visitor who sent an inquiry | D1, on the inquiry | Mallok's one-way hash of the connecting address, which is what the limit per visitor counts by. The address itself is never stored, and the admin's list does not show the mark. |
| The shop's part of a site export | Not stored: built when asked | Mallok's export, which needs a signed-in administrator or a token with `export`, includes every order and inquiry — names, email addresses, phone numbers, companies, countries, shipping addresses, messages — as files under `shop/`, and the shop's settings, among them the address inquiries are announced to. Whoever holds an export holds that. It leaves out a cart's id and the mark of an inquiry's sender. |
| 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's variant form — which needs a signed-in administrator, or a token with `content:write` — or the plugin's own scheduled repricing. Every change of stock is a row in a ledger. |
Expand Down Expand Up @@ -291,6 +292,22 @@ Stated plainly rather than left for an auditor to find:
how they can. Whether a browser's autofill ever fills the field no person
sees — which would drop a real request without a word — has not been
tried.
- **An export is as private as the database.** It carries orders and
inquiries as they are stored. Keep an exported directory where the
database's own backups would be kept, and do not commit one.
- **A shop can outgrow its export.** The export is built in one request, in
memory; a table with more rows than `EXPORT_ROW_LIMIT`, or files of more
text together than `EXPORT_BYTE_LIMIT`, fail it rather than be cut short,
and Mallok then produces no backup of the site at all. The limits are
guards against a Worker's memory that have not been measured on a real
account — on the smallest plan the CPU allowance may stop an export well
before them — and nothing prunes the stock ledger or the orders. It is
written up for Mallok.
- **When the shop's export fails, do not switch the shop off to get a
backup.** Mallok's command line and admin say only that the plugin
failed, and suggest fixing or disabling it; with the shop disabled the
export succeeds and has nothing of the shop's in it. The reason is in the
Worker's log, under `shop_export_refused`.
- **Sample content and sample variants are public test data.** Do not load
`seed/shop-sample.sql` into a production database. The sample catalogue
describes a supplier that does not exist: its certifications, test figures
Expand Down
Loading
Loading