Витрина и админ-бэкенд для kokoc.store — Crocs,
джиббитсы и Adidas Originals из Вьетнама с доставкой по России.
Сделано в FURAI LAB.
Один Cloudflare Worker: серверный рендеринг HTML, JSON API, админ-панель, слой для агентов и мини-игра матч-3 с подсчётом очков на стороне сервера — всё работает на эдже поверх D1, KV и R2. Без фронтенд-фреймворка и без сборки.
| Слой | Решение |
|---|---|
| Рантайм | Cloudflare Workers |
| База данных | D1 (SQLite на эдже) |
| Key-value | Workers KV — рейт-лимиты, настройки, промокоды |
| Объекты | R2 — изображения товаров |
| Статика | Workers Static Assets (public/) |
| Рендеринг | Серверные шаблонные строки, инлайн CSS, ванильный JS |
| Тесты | Vitest + @cloudflare/vitest-pool-workers (настоящий рантайм Workers + D1) |
| Языки | Русский (индексируется) / английский (/en/*, noindex — см. ниже) |
Витрина
| Путь | Описание |
|---|---|
/ | Главная страница |
/catalog | Все товары: сортировка, теги, поиск |
/crocs | Страница Crocs — свой H1, тексты, FAQ, размерная сетка |
/adidas | Страница Adidas Originals |
/limited | Лимитированные дропы |
/product/:slug | Карточка товара с вариантами, изображениями и отзывами |
/collabs, /collabs/:slug | Страницы коллабораций |
/delivery, /about | Статические страницы |
/privacy | Политика обработки персональных данных (152-ФЗ) |
/offer | Публичная оферта — агентский договор, принимаемый при оформлении заказа |
/minigame | «Собери Jibbitz» — матч-3, за прохождение выдаётся промокод на набор джиббитсов |
/en/* | Английская версия каждой страницы витрины |
/robots.txt, /sitemap.xml | Генерируются из актуального каталога |
/r2/*, /cdn/* | Прокси изображений перед R2 |
Публичный API
| Метод | Путь | Описание |
|---|---|---|
GET | /api/health | Состояние сервиса и доступность биндингов |
GET | /api/catalog/products | Каталог с пагинацией |
GET | /api/catalog/products/:slug | Карточка товара (быстрый просмотр) |
GET/POST | /api/cart, /api/cart/items, /api/cart/items/:id | Операции с корзиной |
GET | /api/payment/methods | Какие способы оплаты доступны в чекауте и ставка агентского вознаграждения |
POST | /api/orders | Создание заказа (проверяется по D1, с рейт-лимитом) |
POST | /api/orders/:id/paid | Покупатель заявляет, что перевёл деньги — заказ уходит на проверку |
GET/POST | /api/products/:slug/reviews | Чтение одобренных отзывов, отправка новых |
POST | /api/subscribe | Подписка на рассылку |
POST | /api/minigame/start, /api/minigame/finish | Сессия игры, серверное воспроизведение ходов |
GET | /api/minigame/status | Получало ли устройство промокод раньше |
Для агентов
| Путь | Описание |
|---|---|
/llms.txt | Гид по сайту в Markdown |
/mcp | MCP-сервер поверх Streamable HTTP |
/.well-known/api-catalog | Linkset со ссылками на API |
/.well-known/openapi.json | OpenAPI-описание публичного API |
/.well-known/mcp/server-card.json | Карточка MCP-сервера |
/.well-known/agent-skills/index.json | Индекс skill'ов, плюс отдельный документ на каждый |
Любая страница витрины также рендерится в Markdown, если это указано в запросе (Accept: text/markdown). Этот вариант отдаётся с no-store: Cloudflare варьирует кэш только по Accept-Encoding, поэтому кэшируемый Markdown-ответ мог бы уйти браузеру под тем же HTML-адресом.
Личный кабинет — /account/*, кабинет покупателя: профиль, подарки, избранное и заказы на одной странице.
| Метод | Путь | Описание |
|---|---|---|
GET | /account | Кабинет (нужна сессия) |
GET/POST | /account/login | Вход по паролю либо запрос одноразовой ссылки |
GET/POST | /account/register | Регистрация; GET ведёт на /account/login?mode=register |
GET | /account/auth?token=… | Обмен одноразового токена на сессию |
GET | /account/logout | Сброс сессии |
POST | /account/profile | Сохранение имени и телефона (нужна сессия) |
POST | /account/password | Задать или сменить пароль (нужна сессия) |
GET/PUT | /account/api/wishlist | Синхронизация избранного, вызывается витриной |
Серверный рендеринг. Кабинет несёт ровно один инлайновый скрипт под CSP-nonce, и тот существует только потому, что два действия обязаны трогать localStorage: добавление в корзину (kokoc_cart, общий с витриной через same-origin storage) и удаление отложенного товара — оно должно обновить ещё и kokoc_favs, иначе следующая загрузка витрины синхронизирует слаг обратно. Страница входа не выполняет кода вообще, и это важнее всего именно на ней: туда вводят пароль.
Идентичность. Аккаунт ключуется по email и создаётся двумя путями: первым входом или оформлением заказа. Оба сходятся на одной строке — upsertCustomer заполняет пробелы через COALESCE и никогда не затирает известное имя пустым. Заказы ищутся по orders.customer_id, а не по email на заказе: второе выдало бы покупателю каждый заказ, когда-либо оформленный на адрес, который он давно не контролирует.
Пароли необязательны для аккаунта и дополняют вход по ссылке, а не заменяют его: ссылка остаётся единственным входом для аккаунтов, созданных бэкфиллом из прошлых заказов, и служит восстановлением. Хранятся как pbkdf2-sha256$<итерации>$<соль>$<хэш>, поэтому стоимость перебора можно поднять без миграции — верификатор перехэшируется при следующем удачном входе. 100 000 итераций, ниже цифры OWASP осознанно: это Worker с лимитом CPU, и вход, упавший по таймауту, хуже work factor, который офлайновый перебор пройдёт чуть быстрее.
Регистрация подтверждается почтой. Выбранный пароль хэшируется и паркуется на одноразовом токене, а применяется только к аккаунту, у которого пароля ещё нет — иначе ввод чужого адреса в форму регистрации плюс один любопытный клик открывали бы чужую историю заказов. По той же причине ответ формы одинаков независимо от того, зарегистрирован адрес или нет; различается только письмо.
Избранное. Источник правды в браузере остаётся localStorage — сердечки должны срабатывать мгновенно и у гостя без аккаунта. customer_wishlist зеркалит список, чтобы он пережил очистку кэша и второе устройство. Сверка на загрузке страницы — объединение, а не разница: «есть на сервере, нет здесь» неотличимо от «сохранено на другом устройстве», и разница молча опустошила бы список любого, кто пользуется двумя браузерами.
Подарки. Мини-игра выдаёт коды на устройство по куке kokoc_sid и продолжает так делать — правило «один приз на устройство» держится на ней. promo_codes.customer_id проставляется при открытии кабинета: это единственный запрос, несущий одновременно эту куку и сессию аккаунта, потому что сессия скоупнута Path=/account и до эндпоинта игры не доходит.
Админка — всё под /admin/* закрыто подписанной сессионной кукой. /admin/api/* покрывает товары, варианты, изображения, заказы, отзывы, коллабы, клиентов, подписчиков, категории, бренды, скидки, настройки и статистику.
Действующий документ — /offer, агентский договор, рендерится из pages/legal.js на русском с любезным переводом на английский. Его форма определяет несколько вещей, которые выглядят как продуктовые решения, но таковыми не являются:
/limited написано «доступны к заказу», а не «в наличии».В оферте есть и техническая часть, без которой агентский договор обходиться не может, но которую легко упустить: таможенные платежи сверх нормы личного пользования — на получателе и не входят в сумму заказа (§19), декларантом выступает покупатель, право собственности переходит к нему в момент покупки Агентом (§8.5, ст. 996/1011 ГК), отчёт агента считается принятым после 30 дней молчания (§14.4–14.6, ст. 1008), дополнительная выгода принадлежит агенту (§4.8 — ст. 992 по умолчанию делит её пополам, если договор молчит), а невостребованная посылка может уйти на хранение или быть продана через 15 дней (§8.8). Повреждения фиксируются при передаче (§12.6), но это правило о доказывании, и оно прямо не отменяет право на отказ из §10 — в похожем договоре тот же механизм используется, чтобы вовсе отказывать в возврате.
Реквизиты продавца хранятся в одном месте — config/app.js, поле legalEntity — и читаются футером, /privacy и /offer. pages/legal.js реэкспортирует их как LEGAL_ENTITY для существующих мест использования.
OFFER_REVISION обязана меняться вместе с текстом. Дата редакции, которая не отслеживает изменения текста, хуже, чем её отсутствие: она сообщает покупателю, что документ, который он принял, — тот же самый, что перед ним сейчас.
Соответствие текстов витрины и оферты друг другу проверяется тестами, а не памятью: delivery.test.js проверяет формулировки про доставку и возврат, footer.test.js — что юридические ссылки есть на каждой странице. routes/mcp.js и lib/agent-discovery.js тоже описывают агентскую модель, чтобы ИИ-агент, пересказывающий условия, не представлял сделку как прямую продажу.
Два способа оплаты, покупатель выбирает в одном и том же модальном окне. Оба раскрывают агентское вознаграждение до того, как деньги куда-либо уйдут.
Агентское вознаграждение. Показанная цена — это цена целиком; вознаграждение сидит внутри неё, ровно как описано в пункте 4.7 оферты (14 900 ₽ = 12 665 ₽ на исполнение поручения + 2 235 ₽ вознаграждение по ставке 15%). Ставка — единый процент, который задаётся в Админка → Настройки и хранится в KV под ключом settings:agency_fee в базисных пунктах, чтобы арифметика оставалась целочисленной. lib/agency-fee.js делит сумму заказа на части; округление уходит в вознаграждение, а остаток — в сумму поручения, так что обе части всегда складываются обратно в сумму, которую видит покупатель. И сумма, и ставка записываются в заказ (agency_fee_minor, agency_fee_bp), потому что ставка редактируема, а заказ должен продолжать описывать именно ту сделку, которая была заключена фактически. Ставка 0 — значение по умолчанию — делает всю фичу неактивной и скрывает строку с вознаграждением.
Банковский QR. Сумма заказа и его номер кодируются в payload по стандарту ГОСТ Р 56042-2014 — том же формате, что и QR на квитанции ЖКХ, — и рендерятся на сервере в SVG через lib/qr.js. Любое российское банковское приложение сканирует его и подставляет получателя, сумму и назначение платежа. Не нужен ни договор эквайринга, ни регистрация мерчанта, ни третья сторона, держащая деньги.
Ограничение в 300 байт — самое узкое место здесь. Стандарт ограничивает размер payload, кириллица стоит два байта на символ, а получатель, записанный как «Индивидуальный предприниматель <ФИО>» плюс длинное название банка, занимает 298 байт ещё до того, как учтено назначение платежа. Изначальный код считал назначение платежа опциональным бюджетом и молча выпускал QR вовсе без назначения — перевод падал на выписку без единой привязки к заказу.
Поэтому теперь назначение платежа — это список вариантов от самого длинного к самому короткому, и кодируется первый, который влезает:
Заказ N. По агентскому договору (оферта kokoc.store/offer). Без НДС.
Заказ N. По агентскому договору. Без НДС.
Заказ N. По агентскому договору.
Заказ N.
От обрезки по байтам отказались: она давала назначения, обрывающиеся посреди домена ((оферта ko), которые читались как повреждённый платёж. Если не влезает даже номер заказа, buildPaymentQr выбрасывает исключение, и чекаут откатывается на обычные копируемые реквизиты — оплатить всё равно можно, но QR, который невозможно сверить с заказом, никогда не выпускается. requisiteWarnings() показывает ту же арифметику в админке в момент сохранения реквизитов, включая то, какая именно формулировка реально попадёт в код.
Обратите внимание: назначение платежа звучит как «по агентскому договору», а не «оплата товара» — для агента на УСН доходом является только вознаграждение (пп. 9 п. 1 ст. 251 НК), и банковская выписка должна говорить то же самое, что и оферта. Точную формулировку стоит согласовать с бухгалтером перед запуском.
Поскольку код не имеет доступа к банковскому счёту, ни один публичный эндпоинт не может пометить заказ оплаченным. POST /api/orders/:id/paid переводит payment_status из awaiting_payment в pending_review и фиксирует время; человек сверяет заказ с банковской выпиской в админке. Поэтому оплата по QR требует имя, телефон и адрес доставки заранее — входящий перевод, к которому привязан только номер заказа, должен быть сопоставим с тем, куда отправлять товар.
Реквизиты хранятся в KV под ключом settings:bank_requisites и вводятся в Админка → Настройки. Они валидируются на сервере, включая проверку контрольного разряда ЦБ РФ и для счёта, и для корреспондентского счёта, — потому что одна опечатанная цифра даёт QR, который прекрасно сканируется и отправляет деньги в никуда. requisiteWarnings() добавляет неблокирующие подсказки — например, если вместо расчётного счёта ИП (40802) указан личный счёт (40817), или если имя получателя настолько длинное, что вытесняет номер заказа из назначения платежа.
Пока действующих реквизитов нет, способ оплаты через QR отображается неактивным, а не скрытым: способ реальный, просто счёт ещё не готов. Он автоматически включается через /api/payment/methods, как только реквизиты сохранены. (Раньше использовался hidden, который ничего не давал — .pay-method { display: flex } перебивает стиль браузера [hidden], поэтому кнопка оставалась видимой и кликабельной рядом с пометкой о недоступности.)
WhatsApp. Заказ фиксируется, а дальше покупатель продолжает разговор с человеком, который вручную обрабатывает размер, адрес и оплату.
Пока не сделано: кассовый чек по 54-ФЗ. Приём безналичного платежа от физлица требует фискального чека, а агент, действующий от своего имени, обязан выдать его на полную сумму. В этом коде ничего для этого не реализовано — нужна облачная касса или банковский сервис, а не код.
src/
index.js Точка входа воркера (fetch + очистка корзин по расписанию)
server.js Корневой роутер
config/
app.js Домен, название сервиса, реквизиты продавца (legalEntity)
brand-pages.js Конфиг брендовых страниц /crocs и /adidas
lib/
account-auth.js Сессии покупателя, ссылки для входа, пароли
agency-fee.js Делит сумму заказа на вознаграждение и сумму поручения
agent-discovery.js llms.txt, OpenAPI, карточка MCP, документы skill'ов
avatar.js Идентикон, выведенный из идентификатора аккаунта
cache.js Короткоживущий edge-кэш поверх чтений каталога
catalog.js Запросы каталога и карточки товара
charm-icons.js Спрайты для мини-игры
checkout.js Модалка чекаута: разметка, стили, клиентская логика
collabs.js Данные коллабораций
cookie-consent.js Баннер согласия, подмешивается на уровне ответа
cookies.js Разбор кук, сессионная кука
csrf.js Проверка same-origin для изменяющих запросов
email.js Транзакционные письма
footer.js Общий футер сайта: ссылки, реквизиты, стили
gifts.js Привязывает призы мини-игры к аккаунту
html.js Экранирование HTML (сервер + клиент)
i18n.js Переводы ru/en, локаль из пути
ids.js Случайные идентификаторы и промокоды
locale-links.js Удерживает англоязычных посетителей в дереве /en
markdown.js HTML → Markdown для агентского представления
minigame-engine.js Детерминированное воспроизведение матч-3
navbar.js Общая навигация, шторка корзины, кнопка чекаута
payment.js Payload по ГОСТ Р 56042-2014, валидация реквизитов,
варианты назначения платежа и предупреждения о ёмкости
qr.js Кодировщик QR (ISO/IEC 18004), byte mode, SVG-вывод
products.js Запросы товаров
ratelimit.js Скользящее окно рейт-лимита на KV, хеширование IP
response.js Хелперы ответов
reviews.js Отзывы и агрегаты рейтинга
rich-text.js Санитизация форматированных описаний
security.js CSP-nonce и заголовки безопасности
seo.js Мета-теги, canonical, JSON-LD
sitemap.js Генерация sitemap.xml
theme.js, typography.js Общие дизайн-токены
uploads.js Белый список загрузок, санитизация ключей R2
webmcp.js Регистрация инструментов для агентов прямо на странице
whatsapp-fab.js Плавающая кнопка WhatsApp
wishlist.js Серверное зеркало списка отложенных товаров
pages/ Серверный рендеринг HTML
brand-catalog.js Общая реализация для /crocs и /adidas
crocs.js, adidas.js Тонкие обёртки над brand-catalog
legal.js /privacy и /offer поверх общего каркаса
catalog.js, product.js, landing.js, collabs.js, minigame.js, ...
account/ Личный кабинет покупателя и страница входа
admin/ Оболочка админки и клиентские модули по разделам
routes/
api/ Публичный API
account/ Кабинет, авторизация и синхронизация избранного
admin/ API админки и авторизация
mcp.js Обработка MCP-запросов
db/migrations/ Схема D1 (0001–0023)
test/ 1024 теста
docs/architecture-v1.md
public/ Статические файлы
Локаль зашита в путь: голый путь — русский, /en/... — английский. Это осознанно не кука и не Accept-Language — edge-кэш игнорирует Vary: Cookie, поэтому дизайн на куках отдавал бы под обоими адресами тот язык, который увидел первым.
Английское дерево — noindex, follow. Это UI-перевод поверх каталога, чьи названия и описания в D1 хранятся по-русски, поэтому английский адрес — не самостоятельный локализованный документ; индексация отдала бы Google почти дубликаты страниц, нацеленных на русские запросы. По той же причине не проставляется hreflang — он валиден только между настоящими альтернативами.
lib/cache.js кэширует чтения каталога и товара на 60 секунд, а не готовую страницу.
Кэшировать HTML было бы быстрее, и это неверный компромисс: каждый ответ витрины несёт CSP-nonce на конкретный запрос, и единственная гарантия, которую даёт nonce, — атакующий не может предсказать nonce ответа, в который попадёт его инъекция. Закэшированная страница замораживает это значение, и внедрённый <script> окажется закэширован рядом с подходящим nonce — и выполнится у каждого посетителя, которому отдадут эту запись.
Замер на проде, один и тот же адрес, промах против попадания: 184 мс → 143 мс. Оставшиеся ~140 мс — сеть и TLS, их серверный кэш не трогает. Основной выигрыш — в нагрузке: сотня одновременных посетителей теперь стоит один запрос к D1 в минуту, а не сотню.
Стоит иметь в виду следствие: публикация товара в админке становится видна в течение минуты, а не мгновенно.
Если когда-нибудь понадобится кэшировать саму страницу — nonce нужно заменить на CSP-хэши: инлайн-скрипты статичны для каждой страницы, поэтому 'sha256-…' переживёт кэширование.
?tag=jibbitz) индексируются и стягивают варианты sort/offset на голый адрес фильтра. Неизвестные теги откатываются на родительскую страницу.noindex, follow с canonical на себя же. Пара noindex + canonical на другой адрес рискует тем, что noindex консолидируется на цель — то есть можно потерять индексацию и страницы каталога тоже.sitemap.xml содержит только индексируемые адреса с настоящими значениями lastmod; сегодняшняя дата никогда не подставляется искусственно, а при недоступности D1 sitemap деградирует до статических страниц.sku, itemCondition, priceValidUntil и офферы; aggregateRating появляется только когда у товара есть одобренные отзывы.robots.txt генерирует воркер. wrangler.toml закрепляет run_worker_first = ["/robots.txt"] — без этого первой отдаётся статика, и побеждает public/robots.txt. Отдельный тест сверяет оба файла побайтово, чтобы они не разошлись.OAI-SearchBot, ChatGPT-User, Claude-User, Claude-SearchBot, PerplexityBot), разрешены; краулеры для обучающих корпусов — запрещены.Каждый ответ отдаётся с заголовками безопасности, страницы витрины получают свежий CSP-nonce на каждый запрос — выполнится только тот инлайн-скрипт, который сервер отдал намеренно.
| Заголовок | Значение |
|---|---|
Content-Security-Policy | default-src 'self', nonce-… для скриптов на каждый запрос |
Permissions-Policy | камера, микрофон, платежи, USB отключены |
Strict-Transport-Security | max-age=31536000; includeSubDomains |
Referrer-Policy | strict-origin-when-cross-origin |
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
HSTS — настройка дашборда, а не кода. Cloudflare вырезает заголовокStrict-Transport-Security, пришедший от источника, если HSTS не включён
для зоны (SSL/TLS → Edge Certificates), и для статики применяет собственный
заголовок, а не тот, что пришёл от Worker'а. Вlib/security.jsзаголовок
выставлен, потому что это корректно на уровне источника и документирует
намерение, но тумблер, который реально доставляет его до браузера, живёт в
дашборде.
Помимо заголовков:
ADMIN_PASSWORD не отзывает уже открытые сессии — для единственного администратора это приемлемо.Path и свой секрет, не общие с админкой. Токены входа одноразовые, живут 20 минут и хранятся как SHA-256 — дамп базы не содержит рабочего доступа. Форма входа отвечает одинаково на известный и неизвестный адрес, форма регистрации — на уже зарегистрированный и новый, поэтому ни одной из них нельзя спросить, кто здесь покупает.kokoc_acct — подсказка, а не пропуск — читаемая и ничего не стоящая кука рядом с настоящей сессией: по ней скрипт витрины понимает, стоит ли вообще спрашивать про избранное. На сервере её не читает никто. Подделка даёт ровно один запрос, который отвечает authenticated: false, потому что эндпоинт проверяет подписанную куку. Изменение, которое начнёт принимать решения по этому значению на сервере, — уязвимость, и на это есть тест./api/*, применяется один раз в роутере API, так что новые маршруты защищены по умолчанию, а не по явному подключению.file.type, присланного загружающим. SVG отклоняется — внутрь можно спрятать скрипт.npm install
npm run dev # wrangler dev на http://localhost:8787
Локальные секреты кладутся в .dev.vars (он в .gitignore):
ADMIN_PASSWORD=<любой, только для локали>
ADMIN_SECRET=<случайные 32+ символа>
Перед первым запуском примените миграции к локальной D1:
npx wrangler d1 migrations apply kokoc-store --local
wrangler.toml задаёт migrations_dir = "db/migrations". Без этого wrangler ищет в ./migrations, пишет «No migrations present» и молча ничего не делает — именно так однажды изменение схемы уехало в прод незамеченным.
Продакшен-секреты проходят через wrangler secret put и никогда не лежат в файлах.
npm run lint # только проверка
npm run format # переформатировать
npm run fix # форматирование + безопасные автофиксы линта
Biome вместо ESLint + Prettier: один конфиг, одна зависимость, и линтер, который физически не может спорить с форматтером о кавычках.
Набор правил — рекомендованный, с несколькими отключёнными правилами, каждое из которых прокомментировано в biome.jsonc с указанием причины. Три стоит знать заранее, потому что они выглядят как баги, но ими не являются: ${...} внутри обычных строк в lib/i18n.js — это плейсхолдеры, которые подставляются через .replace(), а не сломанные шаблонные литералы; while ((m = re.exec(s)) !== null) в lib/markdown.js — стандартная идиома для сканирования регулярками; lib/payment.js намеренно вырезает управляющие символы регуляркой перед сборкой платёжного QR.
Ширина строки — 100 символов вместо стандартных 80: p95 существующих строк — 94 символа, так что 80 удвоило бы диф переформатирования и не дало бы прироста читаемости.
.git-blame-ignore-revs содержит один коммит, который переформатировал всё дерево целиком, поэтому git blame его пропускает. GitHub читает этот файл автоматически; локально нужно git config blame.ignoreRevsFile .git-blame-ignore-revs.
npm test # 1024 теста
npm run test:watch
Тесты гоняются на настоящем рантайме Workers с применёнными миграциями D1 — то есть проверяют реальный SQL, а не моки. Помимо локального запуска они выполняются на каждый push и pull request через GitHub Actions.
Наборы, которые существуют специально ради того, чтобы уже пойманные баги не вернулись:
xss-regression.test.js — данные товаров и корзины должны оставаться экранированными в каждом месте, где вызывается innerHTML.security-regression.test.js — валидация количества в корзине, белый список загрузок, санитизация ключей R2, покрытие CSRF, отсутствие дублей хелперов.csp-nonce-integration.test.js — nonce в заголовке должен совпадать с nonce на странице.cache.test.js — каждый ответ получает свой nonce даже при прогретом кэше.api.orders.ratelimit.test.js — создание заказа лимитировано и реально пишет строку в базу (первый тест в проекте, который проверяет *успешный* заказ; именно его отсутствие позволило рассинхрону схемы остаться незамеченным вплоть до миграции 0017).payment.purpose.test.js — назначение платежа всегда содержит номер заказа, деградирует по целым вариантам, а не обрезается посреди слова, и payload никогда не превышает 300 байт. Написан после того, как на реалистичных реквизитах ИП получился QR вовсе без назначения платежа.agency-fee.test.js — вознаграждение и сумма поручения всегда складываются обратно в сумму заказа без остатка. Лишняя копейка на экране оплаты читается как то, что магазин подгоняет цифры.footer.test.js — на каждой странице один футер, ни одна страница не переопределяет его CSS заново, ссылки локализуются в дерево /en, и везде используется один и тот же почтовый адрес.delivery.test.js — тексты витрины не противоречат /offer: нигде не утверждается, что возврат невозможен, доставка описана как оценка сроков.account.test.js — покупатель с сессией видит свои заказы и ничьи больше: заказы ищутся по customer_id, а не по email из сессии, и форма входа отвечает одинаково на известный и неизвестный адрес.account.password.test.js — пароль попадает только на аккаунт, чей почтовый ящик кто-то доказуемо открыл, и ничто, отправленное посторонним, не меняет существующий аккаунт. В том числе случай, ради которого всё и построено: регистрация на чужой адрес не должна давать доступ к чужой истории, даже если владелец перейдёт по ссылке.legal.test.js — оба юридических документа рендерят каждый пункт на обеих локалях, и в тексте оферты не остаётся черновых заметок, адресованных владельцу магазина.npx wrangler d1 migrations apply kokoc-store --remote
npm run deploy # wrangler deploy
npx wrangler rollback
Требует биндингов D1, KV и R2 в wrangler.toml.
Миграции — всегда первым шагом. Задеплоенный код читает колонки, которые добавляет миграция; сделать это в обратном порядке — и эндпоинт, который их пишет, начнёт падать. Столь же важно: никогда не применять DDL-изменение через d1 execute без добавления соответствующей строки в d1_migrations. Колонка payment_method, добавленная вручную, оставила журнал миграций позади — и следующий migrations apply, спустя недели, упал на duplicate column name, заблокировав за собой не связанную с этим миграцию.
Сделано в FURAI LAB — edge-native системы и цифровая автономия.