docs: translate the three plans
test / test (push) Has been cancelled

The roadmap links straight into these files, so a reader following an item
landed in Russian one click after an English page. Translated in full; goals,
boundaries, done-when criteria and risks are unchanged in substance.

The model assigned to inbound-relay is dropped, as it was from the roadmap
item: model routing lives in development.md.

Figures and code references were checked against the tree while translating
rather than copied forward — internal/web is still 50 files, 25 .go, ~4300
lines, and requireAuth(authed) and sendLogData are still where domain-admin.md
says they are.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 22:50:38 +03:00
parent 06c2014384
commit 295d1afb7d
4 changed files with 234 additions and 215 deletions
+4
View File
@@ -26,6 +26,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
are no dates, and the stated order is a recommendation. The model assigned to
an item is no longer recorded there. The README row no longer calls the file
internal and Russian, because it is neither.
- The three plans under [docs/plans/](docs/plans/) are now in English, matching
the roadmap that links to them. The model assigned to a plan is no longer
recorded in it. Package sizes quoted in `web-split.md` were re-checked
against the tree and still hold (50 files, 25 `.go`, ~4300 lines).
- The two remaining Russian source comments are in English:
`deploy/traefik/extract-cert.sh` (quote from spec 10.3) and
`internal/app/sasl.go`, where the quotation from the closed plan is dropped
+51 -49
View File
@@ -1,70 +1,72 @@
# План: domain-admin (роль администратора домена)
# Plan: domain-admin (domain administrator role)
**Статус:** согласовано
**Версия:** целевой bump **1.x** MINOR при совместимой миграции текущего админа
в глобального.
**Порядок:** рекомендуется после [web-split](web-split.md), до
**Status:** agreed
**Version:** target bump **1.x** MINOR, given a compatible migration of the
current administrator into a global one.
**Order:** recommended after [web-split](web-split.md), before
[inbound-relay](inbound-relay.md).
---
## Что это
## What this is
Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль
([web.go](../../internal/web/web.go) — обёртка
`mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта
входа.
Today the panel has exactly one subject: `requireAuth` is a boolean gate, not a
role ([web.go](../../internal/web/web.go) — the
`mux.Handle("/", s.requireAuth(authed))` wrapper), and the session carries
nothing beyond the fact of being signed in.
Роль выдаёт доступ к **явно назначенным доменам** (одному или нескольким);
перечень доменов определяет **глобальный администратор**. Для каждого домена из
списка:
The role grants access to **explicitly assigned domains** (one or several); the
list of domains is set by the **global administrator**. For each domain on that
list:
- приложения этого домена (создание, режим отправителя, перегенерация пароля,
удаление, свой L2-лимит);
- DKIM/DNS-статус домена;
- журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть
- that domain's applications (creation, sender mode, password regeneration,
deletion, its own L2 limit);
- the domain's DKIM/DNS status;
- the send log filtered to the domain — the filter already exists in the log
([sendLogData](../../internal/web/handlers_monitor.go)).
Вне роли остаётся то, что глобально по своей природе:
What stays outside the role is what is global by nature:
- добавление и удаление доменов;
- создание domain-admin пользователей и назначение им доменов;
- adding and removing domains;
- creating domain-admin users and assigning domains to them;
- `/reload`;
- полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены
сразу);
- очередь и хвост `mail.log` — они серверные и к домену не привязаны.
- the full backup (that is all of `/data` including `sasldb2`, i.e. every
domain at once);
- the queue and the `mail.log` tail — those are server-wide and not tied to a
domain.
## Почему расширение v1.0
## Why this extends v1.0
[product.md](../product.md) относит «несколько пользователей панели, роли» к
out of scope (один администратор). Появление второго субъекта — сознательное
расширение границ проекта, как и inbound-relay.
[product.md](../product.md) puts "multiple panel users, roles" out of scope
(one administrator). A second subject is a deliberate widening of the project's
boundary, as inbound-relay is.
Цена — уровня фазы, а не патча:
The cost is phase-sized, not patch-sized:
- таблица пользователей и их привязка к доменам;
- роль в сессии;
- авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}`
не сверяются ни с чем, кроме существования);
- пересмотр первичного setup'а и смены пароля под нескольких пользователей;
- учёт нового субъекта в бэкапе и экспорте домена.
- a users table and their binding to domains;
- the role in the session;
- authorisation in every handler (not only on the route — today `{id}`/`{aid}`
are checked for nothing beyond existence);
- reworking first-run setup and password change for several users;
- accounting for the new subject in backup and domain export.
*(Прежняя формулировка этого пункта«2FA и несколько администраторов» —
заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до
одной конкретной роли, потому что нужна не вторая копия всевластного админа, а
ограниченный доступ владельца одного или нескольких доменов — перечень задаёт
глобальный администратор.)*
*(The earlier wording of this item"2FA and multiple administrators" — has
been replaced: 2FA is off the table, and "multiple administrators" is narrowed
to one specific role, because what is needed is not a second all-powerful admin
but limited access for the owner of one or several domains, with the list set
by the global administrator.)*
## Готово, когда
## Done when
- Глобальный администратор и domain-admin с разными правами работают через
панель; domain-admin не может выйти за пределы **назначенных** доменов;
- текущий единственный админ мигрирует в глобального без потери доступа;
- бэкап/восстановление учитывает пользователей и привязки;
- `build`/`vet`/`test`/образ зелёные.
- A global administrator and a domain-admin with different rights both work
through the panel; the domain-admin cannot reach past the **assigned**
domains;
- the current single admin migrates into a global one without losing access;
- backup/restore accounts for users and their bindings;
- `build`/`vet`/`test`/image green.
## Риски
## Risks
- Неполная проверка `{id}`/`{aid}` в хендлерах — утечка доступа к чужому
домену;
- breaking setup/бэкап — тогда semver major, не 1.x.
- An incomplete `{id}`/`{aid}` check in a handler — access leaking to someone
else's domain;
- breaking setup or backup — that would be a semver major, not 1.x.
+140 -131
View File
@@ -1,159 +1,168 @@
# План: inbound-relay (входящий релей)
# Plan: inbound-relay (inbound relay)
**Статус:** согласовано
**Версия:** целевой bump **1.x** MINOR; **возможен 2.x**требует уточнения по
итогам реализации (не фиксировать major заранее).
**Модель:** Opus (инфра/безопасность, риск open relay).
**Порядок:** рекомендуется после [web-split](web-split.md) и
**Status:** agreed
**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once
the implementation lands (do not fix a major in advance).
**Order:** recommended after [web-split](web-split.md) and
[domain-admin](domain-admin.md).
---
## Цель
## Goal
Возможность принимать почту на порт 25 для явно настроенных доменов и пересылать
её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как
выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность
атаки базового исходящего релея.
The ability to accept mail on port 25 for explicitly configured domains and
forward it to a given upstream backend (a backup-MX / relay-forwarder role), as
a **module disabled by default** that changes neither the behaviour nor the
attack surface of the base outbound relay.
## Зачем это нужно (сценарии)
## What it is for (scenarios)
- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно
недоступен, и передать её, когда он вернётся.
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер,
который по каким-то причинам **сам не может принимать почту из интернета**
(нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на
входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает
публичным входным узлом для домена (MX указывает на него) и пересылает почту
на этот внутренний/недоступный извне сервер.
- **Backup-MX** — accept mail while the domain's primary mail server is
temporarily unreachable, and hand it over when it comes back.
- **A front for a server without a public IP** — the operator runs their own
mail server which, for whatever reason, **cannot accept mail from the
internet itself** (no static or public IP, behind NAT, a private address,
inbound port 25 blocked, and so on). SelfPost, with a public IP and a correct
PTR, acts as the domain's public entry node (the MX points at it) and
forwards mail to that internal or otherwise unreachable server.
## Граница объёма (критично — что это НЕ)
## Scope boundary (critical — what this is NOT)
- **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward)
на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`).
Postfix здесь — чистый пересыльщик, без локальной доставки.
- **ЭТО НЕ (out of scope, [product.md](../product.md)):** локальная доставка в
почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost
также **не реализует и не тянет в свой образ** движок антиспама/антивируса
(rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не**
перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет
точку подключения внешнего фильтра.
- **IT IS:** acceptance on 25 for domains from an explicit list, plus
forwarding (relay/forward) to an upstream (`relay_domains` +
`transport_maps` + `relay_recipient_maps`). Postfix here is a pure forwarder,
with no local delivery.
- **IT IS NOT (out of scope, [product.md](../product.md)):** local delivery to
mailboxes, IMAP/POP3, webmail, Dovecot. No mailboxes at all. SelfPost also
**neither implements nor bundles** an anti-spam or anti-virus engine
(rspamd/ClamAV) — but, unlike the earlier wording, it does **not** push
filtering onto the backend either (see the "Anti-spam" section below): it
provides an attachment point for an external filter.
## Почему как опция/плагин
## Why as an option / plugin
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter,
spam-ingress). Поэтому по умолчанию **выключено** флагом env
`INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора.
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели,
отдельная ветка генерации конфига. При выключенном флаге входной listener,
таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.
- Accepting on port 25 changes the threat model (open relay for inbound,
backscatter, spam ingress). So it is **off** by default behind the
`INBOUND_RELAY_ENABLE=false` env flag; turning it on is a deliberate step by
the operator.
- Isolation: separate SQLite tables, separate panel handlers and pages, a
separate branch of config generation. With the flag off, the inbound
listener, the tables and the UI are absent — the base outbound path is
byte-for-byte unchanged.
## Что делать
## What to do
- Env-флаг `INBOUND_RELAY_ENABLE` (default false); при `true` — генерировать
входной сервис и его конфиг из состояния панели тем же путём, что остальной
конфиг (`postfix-config.sh`).
- **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25
используется только на исходящую доставку). Отдельный от 465/587: на 25 **не**
предлагается SASL и **не** разрешается отправка наружу — только приём для
`relay_domains`.
- **Анти-open-relay для входящей (обязательно):**
`smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd
принимают почту **только** для доменов из `relay_domains` и **только** для
известных получателей (`relay_recipient_maps`); всё прочее —
`reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём
«для кого угодно» невозможны.
- **Backscatter:** предпочтительно знать валидных получателей (reject unknown
recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса.
- **Панель управляет:** список входящих доменов; для каждого — upstream
destination (`host:port`, транспорт), опциональный список валидных
получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта
(whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе
4), `os/exec` без shell ([security.md](../security.md)).
- **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не
подписываем). journal-milter опционально переиспользовать для журнала входящих
(доп. работа) либо на первом этапе оставить входящий без него; поведение
fail-open сохраняется.
- **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и
`message_size_limit` на входном smtpd.
- The `INBOUND_RELAY_ENABLE` env flag (default false); when `true`, generate
the inbound service and its config from panel state the same way the rest of
the config is generated (`postfix-config.sh`).
- **`master.cf`:** an inbound `smtp inet` on 25 for accepting from the internet
(today 25 is used only for outbound delivery). Separate from 465/587: on 25
SASL is **not** offered and sending outwards is **not** allowed — inbound
only, for `relay_domains`.
- **Anti-open-relay for inbound (mandatory):** the inbound smtpd's
`smtpd_relay_restrictions` / `smtpd_recipient_restrictions` accept mail
**only** for domains in `relay_domains` and **only** for known recipients
(`relay_recipient_maps`); everything else gets
`reject_unauth_destination` / `reject_unlisted_recipient`. An open relay, or
accepting "for anyone", is impossible.
- **Backscatter:** knowing the valid recipients is preferable (reject unknown
recipient at RCPT stage) so that bounces to non-existent addresses are never
generated.
- **The panel manages:** the list of inbound domains; for each one the upstream
destination (`host:port`, transport), an optional list of valid recipients,
and optional TLS to the upstream. Strict validation of domain, host and port
(whitelist), injection-safe writing of map files (as with
`sender_login_maps` in Phase 4), `os/exec` without a shell
([security.md](../security.md)).
- **Milters:** OpenDKIM is not needed on the inbound path (we do not sign
someone else's inbound mail). The journal-milter can optionally be reused for
an inbound journal (extra work), or the inbound path can go without it in the
first stage; fail-open behaviour is preserved.
- **Rate limit / size:** a coarse per-client-IP limit (`anvil`, as L1) and
`message_size_limit` on the inbound smtpd.
## Антиспам (важная, но опциональная возможность)
## Anti-spam (important, but optional)
Это ценная опция, но она **не обязательна**: часть операторов вполне устроит
**слепая пересылка без фильтрации** — например, когда backend сам умеет
фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик.
Поэтому антиспам-хук по умолчанию **выключен** (пустой
`INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него.
This is a valuable option, but it is **not mandatory**: some operators will be
perfectly served by **blind forwarding without filtering** — when the backend
can filter on content itself, when the upstream is trusted, or when the volume
and risk are low. So the anti-spam hook is **off** by default (an empty
`INBOUND_ANTISPAM_MILTER`), and the inbound relay is fully functional without
it.
Важно другое — где фильтрация возможна технически: при «слепом» relay целевой
backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя,
поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация
проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF
домена-отправителя). **Единственная точка, где ещё виден настоящий client IP —
входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть
*подключаема именно здесь*, а не переложена на backend, который эту информацию
уже потерял.
What matters is something else: where filtering is technically possible. With a
"blind" relay the destination backend sees **SelfPost's** address as the
connecting IP, not the original sender's, so everything on the backend that
depends on the origin IP breaks (DNSBL and reputation are checked against
SelfPost's IP; SPF returns fail, since SelfPost is not in the sending domain's
SPF). **The only point where the real client IP is still visible is the inbound
hop at SelfPost** — so for those who need filtering, it has to be *attachable
right here*, not delegated to a backend that has already lost the information.
Дизайн подключения:
The attachment design:
- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.),
который оператор запускает **только если нужна эта опция** (тот же принцип,
что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его
**не содержит и не запускает** — образ и принцип «один контейнер, три
процесса» неизменны, [product.md](../product.md) out of scope не нарушается
(SelfPost не реализует антиспам).
- **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd.
Адрес движка задаётся env (например,
`INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и
добавляется в `smtpd_milters` **только входного** тракта (не на 465/587).
Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит
истинный origin. `milter_default_action` для этого milter'а — конфигурируемый
(fail-open vs tempfail); дефолт определить при реализации.
- **Нативный backstop без зависимостей:** на том же входном хопе доступны
средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки
HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение
аутентификации для downstream через ARC/`Received` там, где часть фильтрации
всё же остаётся на backend.
- **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара
(как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со
стеком только при включённой опции.
- **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный
бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей
конфигурацией — опционально, пометить.
- **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на
сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS
README.
- **The anti-spam engine is a separate optional container** (rspamd or
similar), which the operator runs **only if this option is wanted** (the same
principle as the reverse proxy — a separate container outside the SelfPost
image). SelfPost **neither contains nor starts it** — the image and the "one
container, three processes" principle are unchanged, and
[product.md](../product.md)'s out-of-scope list is not violated (SelfPost
does not implement anti-spam).
- **SelfPost provides the attachment point:** a milter hook on the inbound
smtpd. The engine's address is set via env (for example,
`INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, empty → the hook is off) and
is added to `smtpd_milters` for the **inbound path only** (not on 465/587).
Postfix passes the milter the real client IP, HELO and PTR — the filter sees
the true origin. `milter_default_action` for that milter is configurable
(fail-open vs tempfail); the default is to be decided during implementation.
- **A native backstop with no dependencies:** on that same inbound hop,
Postfix's own origin-IP facilities are available — `reject_rbl_client`
(DNSBL) and HELO/PTR checks — and they work even without an external
container. Plus preserving authentication results for downstream through ARC
or `Received`, where part of the filtering does remain on the backend.
- **docker-compose:** document an optional anti-spam sidecar fragment (like the
alternative reverse-proxy fragments) — the container comes up with the stack
only when the option is enabled.
- **Persistence:** new tables and map files under `/data` — they land in the
full backup automatically (Phase 9). Domain export/import can be extended
with the inbound configuration — optional, to be flagged.
- **DNS documentation:** an inbound domain needs an `MX` record pointing at the
server (unlike outbound, where no MX is required) — to be reflected in the
README's DNS section.
## Безопасность
## Security
[security.md](../security.md): валидация ввода на сервере, экранирование записи
в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter.
[security.md](../security.md): server-side input validation, escaped writes to
config files, `exec` without interpolation, no open relay, protection against
backscatter.
## Готово, когда
## Done when
При `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого
домена пересылается на заданный upstream; почта для ненастроенных
доменов/получателей отклоняется (не open relay, не backscatter); при заданном
`INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим
origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при
`INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый
исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные.
With `INBOUND_RELAY_ENABLE=true` and a configured domain, mail arriving on port
25 for that domain is forwarded to the given upstream; mail for unconfigured
domains or recipients is rejected (not an open relay, no backscatter); with
`INBOUND_ANTISPAM_MILTER` set, inbound mail passes through the external filter
with the real origin IP (verified with a sidecar container), and with it empty
the hook stays out of the way; with `INBOUND_RELAY_ENABLE=false` the inbound
port, tables and UI are absent and the base outbound relay is unchanged;
`build`/`vet`/`test`/image green.
## Риски
## Risks
- open relay/backscatter — снимается `relay_domains` + `relay_recipient_maps` +
`reject_unauth_destination`;
- потеря origin IP для фильтрации на backend'е при пересылке — снимается
milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё
виден;
- порт 25 на приём расширяет поверхность атаки (по умолчанию выключено);
- semver: при несовместимости контракта (порты, бэкап, поведение без флага) —
возможен major `2.x`; решение после реализации.
- open relay / backscatter — removed by `relay_domains` +
`relay_recipient_maps` + `reject_unauth_destination`;
- the loss of the origin IP for filtering on the backend when forwarding —
removed by the anti-spam milter hook plus native DNSBL on the inbound hop,
where the origin IP is still visible;
- port 25 accepting mail widens the attack surface (off by default);
- semver: if the contract turns out incompatible (ports, backup, behaviour with
the flag off) a major `2.x` is possible; the decision comes after the
implementation.
**Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа
SelfPost, поднимается оператором при включении опции.
**External deployment dependency:** the optional anti-spam container — outside
the SelfPost image, brought up by the operator when the option is enabled.
## Зависимости
## Dependencies
Готовый исходящий тракт (уже реализован). Согласование получено — см. статус
выше.
A finished outbound path (already implemented). Agreement obtained — see the
status above.
+39 -35
View File
@@ -1,52 +1,56 @@
# План: web-split (разбиение `internal/web`)
# Plan: web-split (splitting `internal/web`)
**Статус:** согласовано
**Версия:** `1.x`; внутренний рефакторинг, сам по себе breaking не тянет.
**Status:** agreed
**Version:** `1.x`; an internal refactor, it does not force a break on its own.
---
## Что это
## What this is
`internal/web` — самый крупный пакет проекта: ~50 файлов (включая шаблоны и
static), ~25 `.go` / ~4000 строк Go, в одной плоскости лежат хендлеры всех
разделов панели, сессии, security-заголовки, проверка Origin, валидация форм и
рендер шаблонов.
`internal/web` is the project's largest package: ~50 files (templates and
static assets included), ~25 `.go` files and ~4000 lines of Go, with the
handlers for every panel section, sessions, security headers, origin checking,
form validation and template rendering all sitting in one flat namespace.
Кандидаты на выделение — `web/handlers` и `web/auth`, либо разрез по доменам
панели.
The candidates to split out are `web/handlers` and `web/auth`, or a cut along
the panel's own domains.
## Почему сейчас
## Why now
На нынешнем размере плоский пакет читается: имена файлов (`handlers_domains.go`,
`handlers_apps.go`, `handlers_monitor.go`) работают не хуже каталогов, а
разбиение потянуло бы за собой экспорт того, что сейчас пакетно-приватно, — то
есть расширение внутреннего API ради косметики.
At its current size the flat package reads fine: the file names
(`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`) do the work
directories would, and splitting would force exporting what is package-private
today — widening the internal API for cosmetics.
Смысл появляется, когда пакет начнёт расти: **domain-admin** и **inbound-relay**
добавляют в него код — роль приносит авторизацию в каждый хендлер, входящий
релей — отдельные страницы и хендлеры входящих доменов. Рефакторинг дешевле
делать перед этим ростом, чем после.
It starts to pay off once the package grows: **domain-admin** and
**inbound-relay** both add code to it — the role brings authorisation into
every handler, the inbound relay brings its own pages and handlers for inbound
domains. The refactor is cheaper before that growth than after it.
## Рекомендуемый порядок
## Recommended order
**web-split → domain-admin → inbound-relay** (см. [roadmap](../roadmap.md)).
**web-split → domain-admin → inbound-relay** (see the
[roadmap](../roadmap.md)).
1. **web-split**заложить структуру пакета (в т.ч. место под `web/auth`), пока
нет сквозных правок от роли и новых inbound-хендлеров.
2. **domain-admin** — авторизация в каждом хендлере опирается на уже выбранную
схему пакета.
3. **inbound-relay** — новый вертикальный срез; проще добавить в уже разрезанный
пакет, чем рефакторить вместе с двумя предыдущими фичами.
1. **web-split**lay down the package structure (including a place for
`web/auth`) while there are no cross-cutting edits from the role and no new
inbound handlers.
2. **domain-admin** — authorisation in every handler builds on a package layout
already chosen.
3. **inbound-relay** — a new vertical slice; easier to add to an already split
package than to refactor alongside the two features before it.
Порядок рекомендация, не блокер.
The order is a recommendation, not a blocker.
## Готово, когда
## Done when
Решение принято осознанно в момент старта работ — либо пакет разрезан по
выбранной схеме, либо зафиксировано, что он остаётся плоским. После разрезки:
`build`/`vet`/`test` зелёные, поведение панели неизменно.
The decision is made deliberately when the work starts — either the package is
split along the chosen scheme, or it is settled that it stays flat. After a
split: `build`/`vet`/`test` green, the panel's behaviour unchanged.
## Риски
## Risks
- Преждевременное разбиение — лишний внутренний API и churn без выгоды;
- откладывание до после роста — сложнее рефакторинг в перемешку с фичами.
- Splitting too early — a superfluous internal API and churn with nothing to
show for it;
- leaving it until after the growth — a harder refactor, tangled up with the
features.