# 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