← FURAI LAB EN RU

Kokoc Store

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.


Stack

LayerChoice
RuntimeCloudflare Workers
DatabaseD1 (SQLite at the edge)
Key-valueWorkers KV — rate limiting, settings, promo lookups
ObjectsR2 — product images
Static assetsWorkers Static Assets (public/)
RenderingServer-side template strings, inline CSS, vanilla JS
TestsVitest + @cloudflare/vitest-pool-workers (real Workers runtime + D1)
LanguagesRussian (indexed) / English (/en/*, noindex — see below)

Routes

Storefront

PathDescription
/Landing page
/catalogAll products, with sort / tag / search filters
/crocsCrocs landing page — own H1, copy, FAQ, size chart
/adidasAdidas Originals landing page
/limitedLimited drops
/product/:slugProduct detail with variants, images, reviews
/collabs, /collabs/:slugCollaboration pages
/delivery, /aboutStatic content
/privacyPersonal data policy (152-ФЗ)
/offerPublic 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.xmlGenerated from live catalog data
/r2/*, /cdn/*Image proxy in front of R2

Public API

MethodPathDescription
GET/api/healthService status and binding availability
GET/api/catalog/productsPaginated catalog
GET/api/catalog/products/:slugProduct detail (quick-view)
GET/POST/api/cart, /api/cart/items, /api/cart/items/:idCart operations
GET/api/payment/methodsWhich payment methods checkout can offer, and the agency fee rate
POST/api/ordersCreate an order (validated against D1, rate-limited)
POST/api/orders/:id/paidBuyer claims a bank transfer was sent — queues it for review
GET/POST/api/products/:slug/reviewsRead approved reviews, submit new ones
POST/api/subscribeNewsletter signup
POST/api/minigame/start, /api/minigame/finishGame session, server-side replay
GET/api/minigame/statusWhether this device already earned a code

For agents

PathDescription
/llms.txtSite guide in Markdown
/mcpMCP server over Streamable HTTP
/.well-known/api-catalogLinkset pointing at the API surface
/.well-known/openapi.jsonOpenAPI description of the public API
/.well-known/mcp/server-card.jsonMCP server card
/.well-known/agent-skills/index.jsonSkill 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.

MethodPathDescription
GET/accountThe cabinet (session required)
GET/POST/account/loginSign in with a password, or request a magic link
GET/POST/account/registerRegistration; GET redirects to /account/login?mode=register
GET/account/auth?token=…Exchange a one-time token for a session
GET/account/logoutClear the session
POST/account/profileSave name and phone (session required)
POST/account/passwordSet or change the password (session required)
GET/PUT/account/api/wishlistWishlist 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:

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.

Payments

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.

Structure

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

Locale

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.

Caching

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.

SEO

Security

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.

HeaderValue
Content-Security-Policydefault-src 'self', per-request nonce-… for scripts
Permissions-Policycamera, microphone, payment, usb disabled
Strict-Transport-Securitymax-age=31536000; includeSubDomains
Referrer-Policystrict-origin-when-cross-origin
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
HSTS is a dashboard setting, not a code setting. Cloudflare strips
Strict-Transport-Security coming 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 in lib/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:

Local development

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.

Lint and formatting

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.

Tests

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:

Deploy

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.