Files
shop-server/docs/superpowers/plans/2026-05-23-ip-gate-access-control.md
T

388 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Любимый Креатив — Доступ запрещён</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #faf8f5;
color: #3d322b;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
}
.card {
max-width: 520px;
width: 100%;
background: #fff;
border: 1px solid #e8e0d8;
border-radius: 16px;
padding: 48px 40px;
text-align: center;
box-shadow: 0 2px 16px rgb(0 0 0 / 4%);
}
.card h1 {
font-size: 24px;
font-weight: 600;
letter-spacing: -0.3px;
color: #4a3a2e;
margin-bottom: 8px;
}
.card .tagline {
font-size: 14px;
color: #8c8177;
margin-bottom: 32px;
line-height: 1.5;
}
.card .status {
font-size: 16px;
color: #6b5e52;
margin-bottom: 24px;
line-height: 1.6;
}
.card .ip {
font-size: 12px;
color: #b8a99b;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
}
</style>
</head>
<body>
<div class="card">
<h1>Любимый Креатив</h1>
<p class="tagline">Изделия ручной работы: вещи с характером и вниманием к деталям</p>
<p class="status">Сайт находится в разработке<br>и скоро будет доступен</p>
<p class="ip">Ваш IP: ${safeIp}</p>
</div>
</body>
</html>`
}
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