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: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Mallok decides the contracts on both sides. Its documentation is the reference:
- Templates are restricted Liquid. Output is escaped; only `content.html` and `page.head` are emitted verbatim. `page.head` carries hreflang and structured data from Mallok and must stay in `layouts/base.liquid`.
- Interface strings are in `locales/*.json` (flat maps, the default locale is the fallback). Site-specific copy — the home page's headline and sections, the footer, the links behind the buttons — is a theme option, never a string in a template; per-language option values go under `themeOptions.$locales` in `site.json`, and `test/project.test.ts` fails when a language is left without one. The tagline is not an option: it is Mallok's own setting, a map of language to text in `site.json`. It follows the site's name in the home page's title, and describes that page unless the `home_description` option gives the language a fuller text.
- **Kinds**: `product`, `collection`, `application` (the sample's industry pages, at `/industries`), `case`, `faq`, `tool`, `article`, `page`. Every kind with a `base` has a `listLayout` here, because each list is a page worth having; a kind without one has no list page — its base path answers 404 — and no entry in `site.kinds`. A template links to a kind's list only through `site.kinds.<kind>`, inside `{% if %}`: the breadcrumb (`partials/crumbs.liquid`) and the home page's link to the catalogue do, and `test/theme/unlisted.test.ts` holds both on a site that serves products without a list.
- **The cart page is the layout `layouts/shop-cart.liquid`**, declared under `pluginLayouts` in `theme.json` as `shop/cart`. It reads `plugin_page`, not `content`; `plugins` is empty on it. Every control is a plain form posting to `plugin_page.action`, a quantity field carries the minimum order in `min` and `step`, and the page has no script. The plugin has no words: a refusal or a line's problem arrives as a kind (`below_moq`, `insufficient_stock`, `unavailable`, `no_price`, `quantity_too_large`, `cart_full`), the pack has a `problem_<kind>` for each, and the number it is about is printed after the words. The layout titles itself through `{% block title %}` in `layouts/base.liquid`, since Mallok titles a plugin's page with the site's name.
- **The cart page is the layout `layouts/shop-cart.liquid`**, declared under `pluginLayouts` in `theme.json` as `shop/cart`. It reads `plugin_page`, not `content`; `plugins` is empty on it. Every control is a plain form posting to `plugin_page.action`, a quantity field carries the minimum order in `min` and `step` and the most a line may hold in `max` (`plugin_page.max_quantity`; on a product page `plugins.shop.max_quantity`), and the page has no script. The plugin has no words: a refusal or a line's problem arrives as a kind (`below_moq`, `insufficient_stock`, `unavailable`, `no_price`, `quantity_too_large`, `cart_full`), the pack has a `problem_<kind>` for each, and the number it is about is printed after the words. The layout titles itself through `{% block title %}` in `layouts/base.liquid`, since Mallok titles a plugin's page with the site's name.
- **`plugins.shop` is optional everywhere.** It is absent when the plugin is off, when its read failed, in `mallok build` and in the admin's preview, and a product may have no variants. A template wraps what it prints from it in `{% if %}`, and the page is whole without it: the sizes with their SKUs, and no prices. A variant is found by walking `plugins.shop.variants`, never by `variants[sku]`: Liquid answers `size`, `first` and `last` on any collection itself. The words around a value — "Unit price", "In stock", "business days" — are the theme's, in its packs; `avail_<state>` names the three states.
- **A product is one page with its sizes on it**, not a page per size. `facets` are the attributes a buyer filters by; `sizes` maps each SKU to what distinguishes it; `specs` is the full table. `partials/spec-table.liquid` (the specification finder, the catalogue, a collection's products, a product's neighbours) takes its columns from the first product that has `facets` and fills every row by attribute name, so every product in a language must use the same names. Below 72rem the same table is laid out as cards, two to a row on a tablet: seven columns need about 1100px in German.
- **References resolve by slug within the same language.** An `application` and a `case` name their `product`; the product page lists them through `content.backrefs.application` and `content.backrefs.case`. A `product` names its `collection`; the collection page lists `content.backrefs.product`. Mallok resolves `reference` only, not `reference[]`.
Expand Down Expand Up @@ -118,7 +118,7 @@ When one is built, take it off this list and prove the behaviour with a test or
- A page is in one currency. A currency is offered only when every priced variant on the page has a price in it (`currenciesFor`). Where there is none the page prints no price, and its structured data offers none.
- The structured data offers what the page prints and nothing else: the same variants, the same amounts, the same states (`offersFor`; the test compares the two digit for digit).
- 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. The quantity field on a page starts at the minimum order and steps by it; that is a convenience, and the server is the check.
- MOQ, stock and the most a cart line may hold — 500, the owner's figure (`MAX_LINE_QUANTITY`) — are enforced server-side in `quantityIssue`, shared by add-to-cart and cart pricing. The quantity field on a page starts at the minimum order, steps by it and stops at the ceiling (`min`, `step`, `max`); that is a convenience, and the server is the check. A minimum order above the ceiling is refused in the admin, and a size that has one is offered no form.
- A refused change writes nothing and creates no cart; a sum is stated only for a cart every line of which can be ordered as it stands.
- A redirect is judged by the path the browser will read, not the text that was sent: `/.//host` is one slash as typed and another site once resolved.
- `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.
Expand Down
6 changes: 4 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,10 @@ These are deliberate and should not be "simplified" away:
that names no live cart: a fresh id is issued. The id is the only thing
that protects a cart, so the shop never takes up one a visitor chose, and
an expired cart's lines do not come back with the next thing added.
- **Cart size is bounded**: at most 100 lines and 10,000 units per line, so one
cart cannot be grown without limit.
- **Cart size is bounded**: at most 100 lines and 500 units per line, so one
cart cannot be grown without limit. The 500 is a rule of the shop as well
as a bound: it is judged with the minimum order, so a line above it cannot
be ordered either, however it came to be in a cart.
- **The cart's routes are rate limited** through Mallok's rate-limit bindings, each route with its own count per visitor, at the relaxed tier: a buyer changes a cart many times in ordinary use.
- **Logs never contain personal data.** The scheduled work logs counts and a
reference date.
Expand Down
10 changes: 9 additions & 1 deletion docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,14 @@ The phases follow Mallok's own roadmap (`mallok: docs/PRODUCT_VISION.md §9`: 0.
- Decision: Mallok is published (`0.1.0-rc.7` on npm, `latest`).
- The owner implements the Mallok extension points in the Mallok project from a separate task list; Nundar work starts now against that plan.

Decided later:

9. **The most a cart line may hold (2026-10-09).**
- Decision: 500 of one variant. It was 10,000, carried over from the previous implementation as a guard against scripted abuse (§17, left open, item 7).
- It is a rule of the shop now, so it is judged where the minimum order is — `quantityIssue`, which add-to-cart and cart pricing share — and a quantity field carries it as `max`. A line above it that is already in a cart cannot be ordered and says so.
- A minimum order above 500 is refused in the admin, and a variant that has one from a seed or by hand is offered no form.
- The sample's largest minimum order is 200.

## 12. What phase 1A changed or found

Recorded here rather than edited away, so a later reader can see what the design said, what the build found, and why they differ.
Expand Down Expand Up @@ -618,7 +626,7 @@ Along the way the review's reading of the cart turned up a column: Mallok's `con
4. *Stock set in the admin is a figure, not a difference.* When the seller types one, a payment that lands between opening the form and saving it is overwritten by it. The ledger stays true — it records the difference from the stock as it was when the write landed — but the seller is not told. A save that names no figure touches no stock.
5. *The not-found page has no cart link*: it waits for Mallok (written up for it).
6. *The cart page's forms are not rate-limited beyond Mallok's relaxed tier* (120 a minute per visitor per route), which is best-effort by Cloudflare's own description.
7. *Ten thousand to a line.* `MAX_LINE_QUANTITY` came over from the previous implementation as a guard against scripted abuse. A catalogue of small parts sells washers by the fifty thousand; whether the ceiling is right for one is the owner's to say. Until then a minimum order above it is refused in the admin.
7. *Ten thousand to a line.* Decided on 2026-10-09: five hundred (§11, decision 9).
8. *The step is the browser's.* A quantity field moves in steps of the minimum order (2026-09-03 §4.5.1); the server enforces the minimum and accepts any quantity above it. Whether multiples are a rule or a convenience is not written down.
9. *A price of nothing.* The admin refuses one; a row with one, from a seed or by hand, is shown and can go in a cart, where it totals zero — which meets §14's open item 4 when checkout is built.
10. *Deletions the plugin missed.* If the delete hook fails, or the plugin is off when a product is deleted, its variants stay: on no page and in no order, with their SKUs taken. Nothing tells a plugin afterwards.
4 changes: 2 additions & 2 deletions scripts/smoke-shop.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -490,10 +490,10 @@ try {
`the product page offers no usable form for its 20 mm size: ${JSON.stringify(form)}`,
);
expect(
/<input type="number" name="quantity" min="100" step="100" value="100"/.test(
/<input type="number" name="quantity" min="100" step="100" max="500" value="100"/.test(
product.html,
),
'the quantity field does not start at the minimum order and step by it',
'the quantity field does not start at the minimum order, step by it and stop at the most a line may hold',
);

// A request can skip the field: the server says no itself. It sends the
Expand Down
22 changes: 20 additions & 2 deletions src/plugins/shop/lib/cart-pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* time the buyer fixes something.
*/

import type { CartLine } from './cart.js';
import { type CartLine, MAX_LINE_QUANTITY } from './cart.js';
import { CURRENCIES, type Currency, settleCurrency } from './currency.js';
import { sumMinor } from './money.js';

Expand All @@ -30,6 +30,12 @@ export type CartIssue =
readonly moq: number;
readonly requested: number;
}
| {
readonly kind: 'quantity_too_large';
readonly variantId: string;
readonly max: number;
readonly requested: number;
}
| {
readonly kind: 'insufficient_stock';
readonly variantId: string;
Expand Down Expand Up @@ -70,7 +76,9 @@ interface TitleRow {
}

/**
* Whether a quantity of one variant can be ordered, ignoring price.
* Whether a quantity of one variant can be ordered, ignoring price: at least
* its minimum order, at most what a cart line may hold, and no more than
* there is.
*
* Shared by add-to-cart and by cart pricing so the two can never disagree
* about what is orderable.
Expand All @@ -90,6 +98,16 @@ export function quantityIssue(
requested: quantity,
};
}
// Before the stock, because it is the same for every variant and no
// delivery changes it: "at most 500" is still true tomorrow.
if (quantity > MAX_LINE_QUANTITY) {
return {
kind: 'quantity_too_large',
variantId: variant.id,
max: MAX_LINE_QUANTITY,
requested: quantity,
};
}
// A made-to-order variant is produced on demand, so stock does not limit it.
if (variant.stock_policy === 'track' && quantity > variant.stock) {
return {
Expand Down
10 changes: 7 additions & 3 deletions src/plugins/shop/lib/cart-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,21 @@
* empty nothing in it
* orderable every line can be ordered as it stands
* subtotal the sum of the lines; '' unless the cart is orderable
* max_quantity the most one line may hold
* lines[] variant_id, sku, name, path, quantity, moq, unit_price,
* line_total, problem, available
*
* A line's `problem` is '' or the reason it cannot be ordered: `unavailable`,
* `below_moq`, `insufficient_stock`, `no_price`. A theme prints its own
* words for each. `available` is how many can be had when the reason is
* stock, and null otherwise.
* `below_moq`, `quantity_too_large`, `insufficient_stock`, `no_price`. A
* theme prints its own words for each. `available` is how many can be had
* when the reason is stock, and null otherwise.
*
* The route adds where its forms post (`action`), the cart's own address
* (`cart_path`) and, after a change that was refused, what was refused
* (`problem`).
*/

import { MAX_LINE_QUANTITY } from './cart.js';
import type { CartFacts, CartIssue } from './cart-pricing.js';
import type { Currency } from './currency.js';
import { formatMoney, sumMinor } from './money.js';
Expand All @@ -49,6 +51,7 @@ export interface CartView {
readonly empty: boolean;
readonly orderable: boolean;
readonly subtotal: string;
readonly max_quantity: number;
readonly lines: readonly CartLineView[];
}

Expand Down Expand Up @@ -96,6 +99,7 @@ export function cartView(facts: CartFacts, locale: string): CartView {
locale,
)
: '',
max_quantity: MAX_LINE_QUANTITY,
lines,
};
}
9 changes: 7 additions & 2 deletions src/plugins/shop/lib/cart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ export interface CartLine {
/** A cart that nobody touches for this long is deleted. */
export const CART_TTL_SECONDS = 60 * 60 * 24 * 30;

/** Real orders never reach this; anything above it is scripted abuse. */
export const MAX_LINE_QUANTITY = 10_000;
/**
* The most of one variant a cart line may hold: the owner's figure (design
* §11, 2026-10-09). It is a rule of the shop and not only a guard against a
* script, so it is judged where the minimum order is, in `quantityIssue`,
* and a page says it in the quantity field's `max`.
*/
export const MAX_LINE_QUANTITY = 500;

/** A ceiling on lines, so one cart cannot be grown without bound. */
export const MAX_CART_LINES = 100;
Expand Down
3 changes: 3 additions & 0 deletions src/plugins/shop/lib/storefront.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* currencies the currencies every priced variant has a price in
* price_from the lowest unit price that can be ordered; '' when none
* prices_from the same, once per currency
* max_quantity the most of one variant a cart line may hold
* variants[] id, sku, label, moq, availability, lead_time, price, prices,
* orderable
*
Expand Down Expand Up @@ -89,6 +90,7 @@ export interface ProductView {
readonly currencies: readonly Currency[];
readonly price_from: string;
readonly prices_from: readonly DisplayPrice[];
readonly max_quantity: number;
readonly variants: readonly VariantView[];
}

Expand Down Expand Up @@ -280,6 +282,7 @@ export function productView(
price_from:
from.find((price) => price.currency === currency)?.display ?? '',
prices_from: from,
max_quantity: MAX_LINE_QUANTITY,
variants: variants.map((variant) => {
const own = display(amounts.get(variant.id), currencies, locale);
const price =
Expand Down
4 changes: 2 additions & 2 deletions src/plugins/shop/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@
"required": true,
"default": 1,
"min": 1,
"help": "The least that can be ordered. On a page the quantity starts here and goes up in steps of it. At most 10,000, which is the most one cart line may hold.",
"max": 10000
"help": "The least that can be ordered. On a page the quantity starts here and goes up in steps of it. At most 500, which is the most one cart line may hold.",
"max": 500
},
"stock_policy": {
"type": "select",
Expand Down
12 changes: 6 additions & 6 deletions src/plugins/shop/routes/cart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
* be switched: a page rendered by the POST itself would be listed, in every
* language, at an address that only takes a POST.
*
* A quantity field's `min` and `step` enforce the minimum order in the
* browser. This enforces it again, because a form can be bypassed.
* A quantity field's `min`, `step` and `max` enforce the minimum order and
* the most a line may hold in the browser. This enforces both again, because
* a form can be bypassed.
*
* The cart page is one visitor's own: Mallok serves it uncached and
* unindexed, whatever is returned here.
Expand Down Expand Up @@ -364,13 +365,12 @@ export async function cartUpdate(
if (variant === undefined || variant.product_published !== 1) {
return refuse('unavailable');
}
if (target > MAX_LINE_QUANTITY) {
return refuse('quantity_too_large');
}
const issue = quantityIssue(variant, target);
if (issue !== null) {
return refuse(
issue.kind === 'below_moq' || issue.kind === 'insufficient_stock'
issue.kind === 'below_moq' ||
issue.kind === 'quantity_too_large' ||
issue.kind === 'insufficient_stock'
? issue.kind
: 'unavailable',
);
Expand Down
7 changes: 4 additions & 3 deletions src/theme/layouts/shop-cart.liquid
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
The page is one visitor's own: Mallok serves it uncached and unindexed.
Every control is a plain form posting to `plugin_page.action`, so the cart
works without a script, and the quantity fields carry the minimum order in
`min` and `step` — which the server checks again.
`min` and `step` and the most a line may hold in `max` — which the server
checks again.
{%- endcomment -%}
{% block title %}{{ t.cart_title }} — {{ site.name }}{% endblock %}
{% block content %}
Expand Down Expand Up @@ -47,7 +48,7 @@
{%- endcomment -%}
{% if line.problem != blank %}
{% assign reason = "problem_" | append: line.problem %}
<p class="cart-line-note" data-problem="{{ line.problem }}">{{ t[reason] }}{% if line.problem == "below_moq" %} {{ line.moq }}{% elsif line.problem == "insufficient_stock" %} {{ line.available }}{% endif %}</p>
<p class="cart-line-note" data-problem="{{ line.problem }}">{{ t[reason] }}{% if line.problem == "below_moq" %} {{ line.moq }}{% elsif line.problem == "insufficient_stock" %} {{ line.available }}{% elsif line.problem == "quantity_too_large" %} {{ cart.max_quantity }}{% endif %}</p>
{% endif %}
</div>
{% if line.unit_price != blank %}
Expand All @@ -61,7 +62,7 @@
<form class="offer-buy" method="post" action="{{ cart.action }}">
<input type="hidden" name="action" value="set">
<input type="hidden" name="variant" value="{{ line.variant_id }}">
<label class="offer-qty"><span>{{ t.quantity }}</span><input type="number" name="quantity" min="{{ line.moq }}" step="{{ line.moq }}" value="{{ line.quantity }}" required inputmode="numeric"></label>
<label class="offer-qty"><span>{{ t.quantity }}</span><input type="number" name="quantity" min="{{ line.moq }}" step="{{ line.moq }}"{% if cart.max_quantity %} max="{{ cart.max_quantity }}"{% endif %} value="{{ line.quantity }}" required inputmode="numeric"></label>
<button class="btn btn-outline btn-sm" type="submit">{{ t.update }}{% if line.sku != blank %}<span class="visually-hidden"> {{ line.sku }}</span>{% endif %}</button>
</form>
{% endif %}
Expand Down
Loading
Loading