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