From 1b9cc8ac57f109afed237b0ca448990b1cd3e53a Mon Sep 17 00:00:00 2001 From: Kirill Date: Sat, 23 May 2026 10:56:08 +0500 Subject: [PATCH 01/24] docs: add IP-gate access control spec --- ...026-05-23-ip-gate-access-control-design.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-23-ip-gate-access-control-design.md diff --git a/docs/superpowers/specs/2026-05-23-ip-gate-access-control-design.md b/docs/superpowers/specs/2026-05-23-ip-gate-access-control-design.md new file mode 100644 index 0000000..3251152 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-ip-gate-access-control-design.md @@ -0,0 +1,93 @@ +# IP-gate: ограничение доступа на время разработки + +## Задача + +Сайт доступен на реальном домене (через VPS + NPM + Netbird), но находится в активной разработке/тестировании. Нужно ограничить доступ, не мешая разработке и полному тестированию функционала (включая OAuth и webhook-и). + +## Решение + +IP-whitelist на уровне Fastify (`onRequest` хук). Только запросы с разрешённых IP проходят. Внешние webhook-и и OAuth callback-и исключены из проверки. + +## Конфигурация + +### `.env` + +```env +SITE_ACCESS_IPS=1.2.3.4,5.6.7.8 +``` + +- Не задана или пуста — защита **отключена** +- IP через запятую, пробелы игнорируются (трим) +- `request.ip` возвращает реальный IP клиента благодаря `trustProxy: true` + +## Архитектура + +### Новый плагин: `server/src/plugins/ip-gate.js` + +Регистрируется в `server/src/index.js` **перед** всеми маршрутами. + +```js +fastify.register(async function ipGate(fastify, opts) { + fastify.addHook('onRequest', async (request, reply) => { + // защита выключена + // путь в исключениях + // ip в списке + // иначе 403 + }) +}) +``` + +### Логика `onRequest` + +1. `SITE_ACCESS_IPS` пуст → `return` (пропустить) +2. Путь запроса в списке исключений → `return` +3. `request.ip` есть в `SITE_ACCESS_IPS` → `return` +4. Иначе → `reply.code(403).type('text/html').send(htmlPage)` + +### Исключения + +Маршруты, которые должны работать всегда (их вызывают внешние сервисы, а не браузер тестировщика): + +| Путь | Причина | +|---|---| +| `/api/auth/oauth/vk/callback` | VK OAuth callback | +| `/api/auth/oauth/yandex/callback` | Yandex OAuth callback | +| `/api/webhooks/yookassa` | YooKassa payment webhook | +| `/api/admin/notifications/telegram/webhook` | Telegram webhook | + +Статика (загружается браузером тестировщика, поэтому тоже проверяется): +- `/uploads/*` и `/uploads-resized/*` — **не** исключаем, блокируются вместе со всем остальным + +### 403-страница + +HTML-страница с информацией о магазине и статусе разработки: + +- Название: «Любимый Креатив» +- Подзаголовок: «Изделия ручной работы: вещи с характером и вниманием к деталям» +- Сообщение: «Сайт находится в разработке и скоро будет доступен» +- Показывает IP посетителя (чтобы можно было сообщить для добавления в whitelist) +- Минимальная стилизация (чистый HTML + inline CSS, без внешних ресурсов) + +## Точки регистрации + +В `server/src/index.js`: + +```js +// после trustProxy, перед маршрутами +await fastify.register(require('./plugins/ip-gate')) +``` + +## Тестирование + +- **Юнит-тесты**: `server/src/plugins/__tests__/ip-gate.test.js` + - IP в списке → запрос проходит + - IP не в списке → 403 + - Путь-исключение → проходит с любым IP + - `SITE_ACCESS_IPS` не задан → защита выключена + - Пробелы в списке IP → корректная работа + +## Включение/выключение + +- **Включить**: задать `SITE_ACCESS_IPS` в `.env` +- **Выключить**: удалить `SITE_ACCESS_IPS` или оставить пустым +- Перезапуск сервера не требуется если используется `node --watch` From 54022d72ff9ed588916bb237c239002ad5e6bb0e Mon Sep 17 00:00:00 2001 From: Kirill Date: Sat, 23 May 2026 10:58:36 +0500 Subject: [PATCH 02/24] docs: add IP-gate access control implementation plan --- .../2026-05-23-ip-gate-access-control.md | 387 ++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-23-ip-gate-access-control.md diff --git a/docs/superpowers/plans/2026-05-23-ip-gate-access-control.md b/docs/superpowers/plans/2026-05-23-ip-gate-access-control.md new file mode 100644 index 0000000..4ec76c1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-ip-gate-access-control.md @@ -0,0 +1,387 @@ +# IP-gate Access Control Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Добавить IP-whitelist на уровне Fastify, ограничивающий доступ к сайту на время разработки. + +**Architecture:** Новый плагин `server/src/plugins/ip-gate.js` с `onRequest` хуком. Проверяет `request.ip` против `SITE_ACCESS_IPS` из `.env`. Часть путей (OAuth callbacks, webhook-и) исключены из проверки. При отказе — HTML-страница 403 с информацией о магазине и IP посетителя. + +**Tech Stack:** Fastify, Node.js, vitest + +--- + +## Task 1: Plugin — create ip-gate.js + +**Files:** +- Create: `server/src/plugins/ip-gate.js` + +- [ ] **Step 1: Write the plugin** + +```js +const EXCLUDED_PATHS = [ + '/api/auth/oauth/vk/callback', + '/api/auth/oauth/yandex/callback', + '/api/webhooks/yookassa', + '/api/admin/notifications/telegram/webhook', +] + +function build403Html(ip) { + const safeIp = ip || 'не определён' + return ` + + + + +Любимый Креатив — Доступ запрещён + + + +
+

Любимый Креатив

+

Изделия ручной работы: вещи с характером и вниманием к деталям

+

Сайт находится в разработке
и скоро будет доступен

+

Ваш IP: ${safeIp}

+
+ +` +} + +export async function registerIpGate(fastify) { + fastify.addHook('onRequest', async (request, reply) => { + const allowed = process.env.SITE_ACCESS_IPS + if (!allowed) return + + const allowedIps = allowed + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + + if (allowedIps.length === 0) return + + const urlPath = request.url.split('?')[0] + + if (EXCLUDED_PATHS.includes(urlPath)) return + + if (allowedIps.includes(request.ip)) return + + return reply.code(403).type('text/html').send(build403Html(request.ip)) + }) +} +``` + +- [ ] **Step 2: No tests yet — commit the plugin skeleton** + +```bash +git add server/src/plugins/ip-gate.js +git commit -m "feat: add ip-gate plugin with env-based IP whitelist" +``` + +--- + +## Task 2: Tests for ip-gate + +**Files:** +- Create: `server/src/plugins/__tests__/ip-gate.test.js` + +- [ ] **Step 1: Write all tests** + +```js +import Fastify from 'fastify' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { registerIpGate } from '../ip-gate.js' + +function buildApp() { + const app = Fastify({ logger: false, trustProxy: true }) + app.get('/test', async () => ({ ok: true })) + app.get('/api/webhooks/yookassa', async () => ({ ok: true })) + app.get('/api/auth/oauth/vk/callback', async () => ({ ok: true })) + app.get('/api/auth/oauth/yandex/callback', async () => ({ ok: true })) + app.get('/api/admin/notifications/telegram/webhook', async () => ({ ok: true })) + return app +} + +describe('registerIpGate', () => { + let app + const originalIps = process.env.SITE_ACCESS_IPS + + beforeEach(async () => { + app = buildApp() + await registerIpGate(app) + await app.ready() + }) + + afterEach(async () => { + await app.close() + process.env.SITE_ACCESS_IPS = originalIps + }) + + it('пропускает запрос если SITE_ACCESS_IPS не задан', async () => { + delete process.env.SITE_ACCESS_IPS + const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true }) + }) + + it('пропускает запрос с разрешённого IP', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4,5.6.7.8' + const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' }) + expect(res.statusCode).toBe(200) + }) + + it('блокирует запрос с неразрешённого IP (403)', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '9.9.9.9' }) + expect(res.statusCode).toBe(403) + expect(res.headers['content-type']).toMatch(/text\/html/) + expect(res.body).toContain('Любимый Креатив') + expect(res.body).toContain('9.9.9.9') + }) + + it('403-страница показывает "не определён" когда IP отсутствует', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ method: 'GET', url: '/test' }) + expect(res.statusCode).toBe(403) + expect(res.body).toContain('не определён') + }) + + it('пропускает исключённые пути с любым IP (webhook yookassa)', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/api/webhooks/yookassa', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) + + it('пропускает исключённые пути с любым IP (vk callback)', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/api/auth/oauth/vk/callback', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) + + it('пропускает исключённые пути с любым IP (yandex callback)', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/api/auth/oauth/yandex/callback', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) + + it('пропускает исключённые пути с любым IP (telegram webhook)', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/api/admin/notifications/telegram/webhook', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) + + it('корректно тримит пробелы в списке IP', async () => { + process.env.SITE_ACCESS_IPS = ' 1.2.3.4 , 5.6.7.8 ' + const res = await app.inject({ + method: 'GET', + url: '/test', + remoteAddress: '5.6.7.8', + }) + expect(res.statusCode).toBe(200) + }) + + it('пропускает если после трима список IP пуст', async () => { + process.env.SITE_ACCESS_IPS = ' , , ' + const res = await app.inject({ + method: 'GET', + url: '/test', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) + + it('путь с query-параметрами проверяется корректно', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/test?foo=bar', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(403) + }) + + it('исключённый путь с query-параметрами тоже пропускается', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/api/webhooks/yookassa?foo=bar', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail (plugin not registered yet in tests)** + +```bash +cd server && npx vitest run src/plugins/__tests__/ip-gate.test.js +``` +Expected: all tests pass (plugin is registered in the `beforeEach`). + +- [ ] **Step 3: Commit tests** + +```bash +git add server/src/plugins/__tests__/ip-gate.test.js +git commit -m "test: add ip-gate access control tests" +``` + +--- + +## Task 3: Register plugin in index.js + +**Files:** +- Modify: `server/src/index.js` + +- [ ] **Step 1: Add import** + +Add after the existing plugin imports (after line 20): + +```js +import { registerIpGate } from './plugins/ip-gate.js' +``` + +- [ ] **Step 2: Register plugin before routes** + +Add before `registerAuth(fastify)` (before line 92): + +```js +await registerIpGate(fastify) +``` + +- [ ] **Step 3: Verify server starts** + +```bash +cd server && node --env-file=.env --eval " + import('./src/index.js').catch(e => { console.error(e.message); process.exit(1) }) +" +``` + +Wait ~5 seconds, then Ctrl+C. Expected: no errors in startup logs. + +- [ ] **Step 4: Commit** + +```bash +git add server/src/index.js +git commit -m "feat: register ip-gate plugin in server startup" +``` + +--- + +## Task 4: Environment variable docs + +**Files:** +- Modify: `server/.env.example` + +- [ ] **Step 1: Add SITE_ACCESS_IPS to .env.example** + +Add after the `CORS_ORIGIN` comment block (after line 18): + +``` +# Ограничение доступа по IP на время разработки (через запятую). Не задано — защита отключена. +# SITE_ACCESS_IPS=1.2.3.4,5.6.7.8 +``` + +- [ ] **Step 2: Add SITE_ACCESS_IPS to actual .env** + +```bash +echo "" >> server/.env +echo "# Ограничение доступа по IP. Раскомментируй и укажи свои IP." >> server/.env +echo "# SITE_ACCESS_IPS=1.2.3.4" >> server/.env +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/.env.example +git commit -m "docs: add SITE_ACCESS_IPS env var to .env.example" +``` + +--- + +## Task 5: Final verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run all server tests** + +```bash +cd server && npm test +``` +Expected: all tests pass, including ip-gate tests. + +- [ ] **Step 2: Run ESLint on new files** + +```bash +cd server && npx eslint src/plugins/ip-gate.js src/plugins/__tests__/ip-gate.test.js +``` +Expected: no errors. + +- [ ] **Step 3: Test end-to-end manually** + +1. Set `SITE_ACCESS_IPS=127.0.0.1` in `server/.env` +2. Start server: `cd server && npm run dev` +3. `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3333/api/catalog` + Expected: `200` (127.0.0.1 в списке) +4. Test blocked: `curl` from a machine not in the list should get 403 +5. Remove `SITE_ACCESS_IPS` and verify all requests pass again From e22f084940962a0be0ddeb08365859899b8fde0c Mon Sep 17 00:00:00 2001 From: Kirill Date: Sat, 23 May 2026 11:00:02 +0500 Subject: [PATCH 03/24] feat: add IP gate plugin with SITE_ACCESS_IPS env var support --- server/src/plugins/ip-gate.js | 95 +++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 server/src/plugins/ip-gate.js diff --git a/server/src/plugins/ip-gate.js b/server/src/plugins/ip-gate.js new file mode 100644 index 0000000..c69e6ba --- /dev/null +++ b/server/src/plugins/ip-gate.js @@ -0,0 +1,95 @@ +const EXCLUDED_PATHS = [ + '/api/auth/oauth/vk/callback', + '/api/auth/oauth/yandex/callback', + '/api/webhooks/yookassa', + '/api/admin/notifications/telegram/webhook', +] + +function build403Html(ip) { + const safeIp = ip || 'не определён' + return ` + + + + +Любимый Креатив — Доступ запрещён + + + +
+

Любимый Креатив

+

Изделия ручной работы: вещи с характером и вниманием к деталям

+

Сайт находится в разработке
и скоро будет доступен

+

Ваш IP: ${safeIp}

+
+ +` +} + +export async function registerIpGate(fastify) { + fastify.addHook('onRequest', async (request, reply) => { + const allowed = process.env.SITE_ACCESS_IPS + if (!allowed) return + + const allowedIps = allowed + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + + if (allowedIps.length === 0) return + + const urlPath = request.url.split('?')[0] + + if (EXCLUDED_PATHS.includes(urlPath)) return + + if (allowedIps.includes(request.ip)) return + + return reply.code(403).type('text/html').send(build403Html(request.ip)) + }) +} From 8ed2f0e9ba717158f0f9b96d39779b99180985b4 Mon Sep 17 00:00:00 2001 From: Kirill Date: Sat, 23 May 2026 11:01:37 +0500 Subject: [PATCH 04/24] fix: simplify title and status message in 403 page --- server/src/plugins/ip-gate.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/plugins/ip-gate.js b/server/src/plugins/ip-gate.js index c69e6ba..1bdd499 100644 --- a/server/src/plugins/ip-gate.js +++ b/server/src/plugins/ip-gate.js @@ -12,7 +12,7 @@ function build403Html(ip) { -Любимый Креатив — Доступ запрещён +Любимый Креатив