Storefront and admin backend for kokoc.store — Crocs,
Jibbitz charms, and Adidas Originals sourced from Vietnam, delivered across Russia.
Built by FURAI LAB.
A single Cloudflare Worker: server-rendered HTML, a JSON API, an admin panel, an agent-facing layer, and a match-3 minigame with server-authoritative scoring — all running at the edge on D1, KV, and R2. No frontend framework, no build step.
| Layer | Choice |
|---|---|
| Runtime | Cloudflare Workers |
| Database | D1 (SQLite at the edge) |
| Key-value | Workers KV — rate limiting, settings, promo lookups |
| Objects | R2 — product images |
| Static assets | Workers Static Assets (public/) |
| Rendering | Server-side template strings, inline CSS, vanilla JS |
| Tests | Vitest + @cloudflare/vitest-pool-workers (real Workers runtime + D1) |
| Languages | Russian (indexed) / English (/en/*, noindex — see below) |
Storefront
| Path | Description |
|---|---|
/ | Landing page |
/catalog | All products, with sort / tag / search filters |
/crocs | Crocs landing page — own H1, copy, FAQ, size chart |
/adidas | Adidas Originals landing page |
/limited | Limited drops |
/product/:slug | Product detail with variants, images, reviews |
/collabs, /collabs/:slug | Collaboration pages |
/delivery, /about | Static content |
/privacy | Personal data policy (152-ФЗ) |
/offer | Public offer — the agency contract accepted at checkout |
/minigame | "Собери Jibbitz" match-3, earns a promo code for a Jibbitz set |
/en/* | English view of every storefront page |
/robots.txt, /sitemap.xml | Generated from live catalog data |
/r2/*, /cdn/* | Image proxy in front of R2 |
Public API
| Method | Path | Description |
|---|---|---|
GET | /api/health | Service status and binding availability |
GET | /api/catalog/products | Paginated catalog |
GET | /api/catalog/products/:slug | Product detail (quick-view) |
GET/POST | /api/cart, /api/cart/items, /api/cart/items/:id | Cart operations |
GET | /api/payment/methods | Which payment methods checkout can offer, and the agency fee rate |
POST | /api/orders | Create an order (validated against D1, rate-limited) |
POST | /api/orders/:id/paid | Buyer claims a bank transfer was sent — queues it for review |
GET/POST | /api/products/:slug/reviews | Read approved reviews, submit new ones |
POST | /api/subscribe | Newsletter signup |
POST | /api/minigame/start, /api/minigame/finish | Game session, server-side replay |
GET | /api/minigame/status | Whether this device already earned a code |
For agents
| Path | Description |
|---|---|
/llms.txt | Site guide in Markdown |
/mcp | MCP server over Streamable HTTP |
/.well-known/api-catalog | Linkset pointing at the API surface |
/.well-known/openapi.json | OpenAPI description of the public API |
/.well-known/mcp/server-card.json | MCP server card |
/.well-known/agent-skills/index.json | Skill index, plus one document per skill |
Any storefront page also renders as Markdown when the request prefers it (Accept: text/markdown). That view is no-store: Cloudflare only varies on Accept-Encoding, so a cacheable Markdown response would be served to browsers under the HTML URL.
Customer account — /account/*, the buyer's cabinet: profile, gifts, wishlist and orders on one page.
| Method | Path | Description |
|---|---|---|
GET | /account | The cabinet (session required) |
GET/POST | /account/login | Sign in with a password, or request a magic link |
GET/POST | /account/register | Registration; GET redirects to /account/login?mode=register |
GET | /account/auth?token=… | Exchange a one-time token for a session |
GET | /account/logout | Clear the session |
POST | /account/profile | Save name and phone (session required) |
POST | /account/password | Set or change the password (session required) |
GET/PUT | /account/api/wishlist | Wishlist sync, called from the storefront |
Server-rendered. The cabinet ships one inline script, under a CSP nonce, and it exists only because two actions must touch localStorage: adding to the cart (kokoc_cart, shared with the shop through same-origin storage) and removing a saved product, which has to update kokoc_favs too or the next storefront page load would sync the slug straight back. The login page runs no code at all, which matters more there than anywhere else: it is the page passwords are typed on.
Identity. An account is keyed by email and can be created two ways: by signing in for the first time, or by checking out. Both converge on the same row — upsertCustomer fills gaps with COALESCE and never overwrites a known name with a null. Orders are matched by orders.customer_id, never by the email on the order, which would hand a buyer every order ever placed with an address they no longer control.
Passwords are optional per account and additive to the magic link rather than a replacement: the link stays as the only way in for accounts created out of past orders, and as the recovery path. Stored as pbkdf2-sha256$<iterations>$<salt>$<hash>, so the work factor travels with each row and can be raised without a migration — the verifier is re-hashed on the next successful login. 100,000 iterations, below the OWASP figure on purpose: this runs inside a Worker with a CPU cap, and a login that times out is worse than a work factor an offline attacker grinds a little faster.
Registration is confirmed by email. The chosen password is hashed and parked on a single-use token, and applied only to an account that has no password yet — otherwise typing a stranger's address into the registration form, plus one curious click, would hand over their order history. For the same reason the response to the form is identical whether the address is already registered or not; only the letter differs.
Wishlist. localStorage stays the source of truth in the browser, because hearts must work instantly and must work for visitors who have no account yet. customer_wishlist mirrors it so the list survives a cleared cache or a second device. Reconciliation on page load is a union, never a diff: "on the server but not here" is indistinguishable from "saved on another device", so a diff would silently empty the list of anyone using two browsers.
Gifts. The mini-game issues codes against the kokoc_sid device cookie and still does — "one prize per device, ever" depends on it. promo_codes.customer_id is filled in when the cabinet is opened, which is the only request carrying both that cookie and the account session, because the session is scoped Path=/account and never reaches the game endpoint.
Admin — everything under /admin/* sits behind a signed session cookie. /admin/api/* covers products, variants, images, orders, reviews, collabs, clients, subscribers, categories, brands, discounts, settings, and stats.
The operative document is /offer — an agency contract, rendered from pages/legal.js in Russian with a courtesy English translation. Its shape drives several things that look like product decisions but are not:
/limited says «доступны к заказу», not «в наличии».The offer also carries the machinery an agency contract needs and is easy to omit: customs charges above the personal-use allowance are the recipient's and sit outside the order total (§19), the customer is the declarant, title passes to them when the Agent buys (§8.5, arts. 996/1011), the agent's report is deemed accepted after 30 days of silence (§14.4–14.6, art. 1008), additional benefit belongs to the agent (§4.8 — art. 992 splits it by default when the contract is silent), and an unclaimed parcel can go into storage or be sold after 15 days (§8.8). Damage is recorded at handover (§12.6), but that is an evidence rule and explicitly does not override the right of withdrawal in §10 — a comparable contract uses the same mechanism to deny returns outright.
Seller requisites live once, in config/app.js as legalEntity, and are read by the footer, /privacy and /offer. pages/legal.js re-exports it as LEGAL_ENTITY for its existing call sites.
OFFER_REVISION must move whenever the text does. A revision date that does not track the text is worse than none: it tells the customer the document they accepted is the one in front of them.
Storefront copy and the offer are kept in agreement by tests rather than by memory: delivery.test.js asserts the returns and delivery wording, and footer.test.js asserts the legal links ship on every page. routes/mcp.js and lib/agent-discovery.js state the agency model too, so an AI agent relaying the terms does not describe a straight sale.
Two checkout paths, chosen by the buyer in the same modal. Both disclose the agency fee before any money moves.
The agency fee. The price shown is the gross total; the fee lives inside it, exactly as clause 4.7 of the offer describes (14 900 ₽ = 12 665 ₽ towards performing the instruction + 2 235 ₽ fee at 15%). The rate is a single percentage set in Admin → Настройки, stored in KV under settings:agency_fee in basis points so the arithmetic stays in integers. lib/agency-fee.js splits a total; rounding goes to the fee and the remainder to the principal amount, so the two parts always add back to the total the customer sees. Both the amount and the rate are written to the order (agency_fee_minor, agency_fee_bp) because the rate is editable and an order must keep describing the deal that was actually struck. A rate of 0 — the default — makes the whole feature inert and hides the line.
Bank QR. The order total and number are encoded into a GOST R 56042-2014 payload — the same format as the QR on a Russian utility bill — and rendered server-side as an SVG by lib/qr.js. Any Russian banking app scans it and pre-fills payee, amount and reference. No acquiring contract, no merchant registration, no third party holding the money.
The trade-off is stated on the payment screen rather than buried: it is an inter-bank transfer, so it can take until the next business day to arrive and the sender's bank may charge a fee. An SBP dynamic QR would be instant, but issuing one requires a merchant agreement and an acquiring contract.
The 300-byte ceiling is the sharp edge here. The standard caps the payload, Cyrillic costs two bytes a character, and a payee written out as «Индивидуальный предприниматель <ФИО>» plus a long bank name reaches 298 bytes before the reference is even considered. The original code budgeted the whole reference as optional and silently emitted a QR with no reference at all — a transfer landing on the statement with nothing tying it to an order.
So the reference is now a list of variants, longest first, and the first that fits is encoded:
Заказ N. По агентскому договору (оферта kokoc.store/offer). Без НДС.
Заказ N. По агентскому договору. Без НДС.
Заказ N. По агентскому договору.
Заказ N.
Byte-wise truncation is gone: it produced references ending mid-domain ((оферта ko), which read as a corrupted payment. If not even the order number fits, buildPaymentQr throws and the checkout falls back to plain copyable requisites — the buyer can still pay, but a QR that cannot be reconciled is never produced. requisiteWarnings() surfaces the same arithmetic in the admin panel at the moment the details are saved, including which wording will actually be encoded.
Note the reference says «по агентскому договору», not «оплата товара»: for an agent on УСН only the fee is income (пп. 9 п. 1 ст. 251 НК), and the bank statement should say the same thing the offer does. Confirm the exact wording with an accountant before launch.
Because nothing here can see the bank account, no public endpoint can mark an order paid. POST /api/orders/:id/paid moves payment_status from awaiting_payment to pending_review and stamps the time; a human confirms against the bank statement in the admin panel. The QR path therefore requires a name, phone and delivery address up front — an incoming transfer with nothing but an order number attached has to be matchable to somewhere to ship.
Requisites live in KV under settings:bank_requisites and are entered in Admin → Настройки. They are validated server-side, including the Bank of Russia check digit for both the account and the correspondent account, because one mistyped digit produces a QR that scans perfectly and sends money nowhere. requisiteWarnings() adds non-blocking advice — a 40817 personal account where a sole trader's 40802 belongs, or a payee name long enough to squeeze out the order number.
Until valid requisites exist the QR option renders disabled rather than hidden: the method is real, the account simply is not ready. It re-enables itself from /api/payment/methods the moment the details are saved. (It used to be hidden, which did nothing — .pay-method { display: flex } beats the UA stylesheet's [hidden], so the button stayed visible and clickable next to a note saying it was unavailable.)
WhatsApp. The order is recorded and the buyer continues the conversation with a person, who handles sizing, address and payment manually.
Still outstanding: a 54-ФЗ cash receipt. Accepting a cashless payment from an individual requires a fiscal receipt, and an agent acting in its own name issues one for the full amount. Nothing in this codebase does that — it needs a cloud till or a bank-provided service, not code.
src/
index.js Worker entrypoint (fetch + scheduled cart cleanup)
server.js Top-level router
config/
app.js Domain, service name, seller requisites (legalEntity)
brand-pages.js Per-brand config for /crocs and /adidas
lib/
account-auth.js Buyer sessions, magic links, passwords
agency-fee.js Splits an order total into fee and principal
agent-discovery.js llms.txt, OpenAPI, MCP card, skill documents
avatar.js Identicon derived from the account id
cache.js Short-lived edge cache over catalog reads
catalog.js Catalog and product-detail queries
charm-icons.js Minigame sprite set
checkout.js Checkout modal: markup, styles, client logic
collabs.js Collaboration data
cookie-consent.js Consent banner, injected at the response layer
cookies.js Cookie parsing, session cookie
csrf.js Same-origin guard for mutating requests
email.js Transactional email
footer.js Shared site footer: links, requisites, styles
gifts.js Binds mini-game prizes to an account
html.js HTML escaping (server + client)
i18n.js ru/en translations, locale from path
ids.js Random IDs and promo codes
locale-links.js Keeps English visitors in the /en tree
markdown.js HTML → Markdown for the agent view
minigame-engine.js Deterministic match-3 replay
navbar.js Shared navigation, cart drawer, checkout button
payment.js GOST R 56042-2014 payloads, requisite validation,
payment-reference variants and capacity warnings
qr.js QR encoder (ISO/IEC 18004), byte mode, SVG output
products.js Product queries
ratelimit.js KV sliding-window limiter, IP hashing
response.js Response helpers
reviews.js Reviews and rating aggregates
rich-text.js Sanitised rich-text descriptions
security.js CSP nonces and security headers
seo.js Meta tags, canonicals, JSON-LD
sitemap.js sitemap.xml generation
theme.js, typography.js Shared design tokens
uploads.js Upload allow-list, R2 key sanitising
webmcp.js In-page tool registration for agents
whatsapp-fab.js Floating WhatsApp button
wishlist.js Server mirror of the saved-products list
pages/ Server-rendered HTML
brand-catalog.js Shared implementation for /crocs and /adidas
crocs.js, adidas.js Thin wrappers over brand-catalog
legal.js /privacy and /offer over one shared shell
catalog.js, product.js, landing.js, collabs.js, minigame.js, ...
account/ Buyer's cabinet and its login page
admin/ Admin shell and per-section client modules
routes/
api/ Public API
account/ Cabinet, auth and wishlist sync
admin/ Admin API and auth
mcp.js MCP request handling
db/migrations/ D1 schema (0001–0023)
test/ 1024 tests
docs/architecture-v1.md
public/ Static assets
The locale lives in the path: a bare path is Russian, /en/... is English. It is deliberately *not* a cookie or an Accept-Language header — the edge cache ignores Vary: Cookie, so a cookie-driven design served whichever language it saw first under both URLs.
The English tree is noindex, follow. It is a UI translation over a catalogue whose titles and descriptions live in D1 in Russian, so an English URL is not a distinct localized document; indexing it would hand Google near-duplicates of the pages that target Russian queries. For the same reason no hreflang is emitted — it is only valid between genuine alternates.
lib/cache.js caches catalog and product reads for 60 seconds, not the rendered page.
Caching the HTML would be faster, and it is the wrong trade: every storefront response carries a per-request CSP nonce, and the one guarantee a nonce provides is that an attacker cannot predict the nonce of the response their injected markup lands in. A cached page freezes that value, so an injected <script> would be cached beside a nonce that matches it and would run for every visitor served that entry.
Measured on production, same URL, miss vs hit: 184 ms → 143 ms. The remaining ~140 ms is network and TLS, which no server-side cache touches. The larger benefit is load: a hundred concurrent visitors now cost one D1 query per minute instead of a hundred.
Consequence worth knowing: publishing a product in the admin becomes visible within a minute, not instantly.
If page-level caching is ever wanted, replace the nonce with CSP hashes — the inline scripts are static per page, so 'sha256-…' survives caching.
?tag=jibbitz) are indexable and consolidate their sort/offset variants onto the bare filter URL. Unknown tags fold back into the parent page.noindex, follow and canonicalize to themselves. Pairing noindex with a canonical pointing elsewhere risks the noindex being consolidated onto the target — i.e. losing the catalog page too.sitemap.xml lists only indexable URLs, with real lastmod values; it never fabricates today's date, and it degrades to the static pages if D1 is down.sku, itemCondition, priceValidUntil and offers; aggregateRating appears only once a product has approved reviews.robots.txt is generated by the worker. wrangler.toml pins run_worker_first = ["/robots.txt"] — without it, static assets are served first and public/robots.txt wins instead. A test compares the two byte for byte so they cannot drift.OAI-SearchBot, ChatGPT-User, Claude-User, Claude-SearchBot, PerplexityBot) are allowed; training-corpus crawlers are disallowed.Every response carries security headers; storefront pages get a fresh CSP nonce per request, so only the inline script block the server deliberately emits can run.
| Header | Value |
|---|---|
Content-Security-Policy | default-src 'self', per-request nonce-… for scripts |
Permissions-Policy | camera, microphone, payment, usb disabled |
Strict-Transport-Security | max-age=31536000; includeSubDomains |
Referrer-Policy | strict-origin-when-cross-origin |
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
HSTS is a dashboard setting, not a code setting. Cloudflare stripsStrict-Transport-Securitycoming from the origin unless HSTS is enabled for
the zone (SSL/TLS → Edge Certificates), and it applies its own header to static
assets rather than to Worker responses. The header is set inlib/security.js
because it is correct at the origin and documents the intent, but the switch
that makes it reach browsers lives in the dashboard.
Beyond headers:
ADMIN_PASSWORD does not revoke live sessions; that is acceptable for a single administrator.Path and secret from the admin's. Login tokens are single-use, expire in 20 minutes and are stored as SHA-256, so a database dump contains no usable credential. The login form answers identically for a known and an unknown address, and the registration form for an already-registered and a new one, so neither can be used to ask who shops here.kokoc_acct is a hint, not a credential — a readable, valueless cookie set beside the real session so storefront script can tell whether it is worth asking about the wishlist. Nothing on the server reads it. Forging it buys one request that answers authenticated: false, because the endpoint checks the signed cookie. A change that branches on this value server-side is a vulnerability, and there is a test asserting it grants nothing./api/* requests, applied once in the API router so new routes are protected by default rather than opt-in.file.type. SVG is rejected (it can carry inline script).npm install
npm run dev # wrangler dev on http://localhost:8787
Create .dev.vars for local secrets (git-ignored):
ADMIN_PASSWORD=<anything, local only>
ADMIN_SECRET=<random 32+ chars>
Apply migrations to the local D1 before first run:
npx wrangler d1 migrations apply kokoc-store --local
wrangler.toml sets migrations_dir = "db/migrations". Without it wrangler looks in ./migrations, reports "No migrations present" and silently does nothing — which has already shipped a schema change to production once.
Production secrets go through wrangler secret put and never live in files.
npm run lint # check only
npm run format # rewrite formatting
npm run fix # format + safe lint autofixes
Biome rather than ESLint + Prettier: one config, one dependency, and a linter that cannot disagree with the formatter about quotes.
The rule set is the recommended one with a few rules off, each annotated in biome.jsonc with the reason. Three are worth knowing up front, because they look like bugs and are not: ${...} inside ordinary strings in lib/i18n.js are placeholders substituted with .replace(), not broken template literals; while ((m = re.exec(s)) !== null) in lib/markdown.js is the standard regex scanning idiom; lib/payment.js strips control characters by regex on purpose before assembling the payment QR.
Line width is 100 rather than the default 80 — the p95 of existing lines is 94, so 80 would have doubled the reformatting diff and bought no readability.
.git-blame-ignore-revs lists the one commit that reformatted the whole tree, so git blame skips it. GitHub reads the file automatically; locally it is git config blame.ignoreRevsFile .git-blame-ignore-revs.
npm test # 1024 tests
npm run test:watch
Tests run against the real Workers runtime with D1 migrations applied, so they exercise actual SQL rather than mocks. They also run on every push and pull request via GitHub Actions.
Suites that exist specifically to stop known bugs from returning:
xss-regression.test.js — product and cart data must stay escaped at every innerHTML call site.security-regression.test.js — cart quantity validation, upload allow-list, R2 key sanitising, CSRF coverage, and helper deduplication.csp-nonce-integration.test.js — the nonce in the header must match the one in the page.cache.test.js — every response still gets its own nonce with the cache warm.api.orders.ratelimit.test.js — order creation is limited, and actually writes a row (the first test in the project to assert a *successful* order; its absence is why a schema mismatch went unnoticed until migration 0017).payment.purpose.test.js — the payment reference always carries the order number, degrades between whole variants rather than being cut mid-word, and the payload never exceeds 300 bytes. Written after discovering that realistic ИП requisites produced a QR with no reference at all.agency-fee.test.js — the fee and the principal always add back up to the total exactly. A stray kopeck on the payment screen reads as the store fiddling the figures.footer.test.js — one footer per page, no page restates its CSS, links get localized into the /en tree, and one mailbox is used everywhere.delivery.test.js — storefront copy does not contradict /offer: no claim that returns are impossible, delivery framed as an estimate.account.test.js — a signed-in buyer sees their own orders and nobody else's: orders are matched on customer_id and never on the email in the session, and the login form answers identically for a known and an unknown address.account.password.test.js — a password only ever lands on an account whose mailbox somebody has demonstrably opened, and nothing a stranger submits changes an existing account. Includes the case that motivates the design: registering with somebody else's address must not take over their history even if they click the link.legal.test.js — both legal documents render every clause in both locales, and no drafting notes addressed to the store owner survive in the offer text.npx wrangler d1 migrations apply kokoc-store --remote
npm run deploy # wrangler deploy
npx wrangler rollback
Requires wrangler.toml bindings for D1, KV, and R2.
Migrations go first, always. Deployed code reads columns the migration adds; ship it the other way round and the endpoint that writes them starts throwing. Equally: never apply a DDL change with d1 execute without adding the matching row to d1_migrations. A payment_method column added by hand left the journal behind, and the next migrations apply — weeks later — failed on duplicate column name and blocked an unrelated migration behind it.
Built by FURAI LAB — edge-native systems and digital autonomy.