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 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 an item is no longer recorded there. The README row no longer calls the file
internal and Russian, because it is neither. 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: - The two remaining Russian source comments are in English:
`deploy/traefik/extract-cert.sh` (quote from spec 10.3) and `deploy/traefik/extract-cert.sh` (quote from spec 10.3) and
`internal/app/sasl.go`, where the quotation from the closed plan is dropped `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)
**Статус:** согласовано **Status:** agreed
**Версия:** целевой bump **1.x** MINOR при совместимой миграции текущего админа **Version:** target bump **1.x** MINOR, given a compatible migration of the
в глобального. current administrator into a global one.
**Порядок:** рекомендуется после [web-split](web-split.md), до **Order:** recommended after [web-split](web-split.md), before
[inbound-relay](inbound-relay.md). [inbound-relay](inbound-relay.md).
--- ---
## Что это ## What this is
Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль Today the panel has exactly one subject: `requireAuth` is a boolean gate, not a
([web.go](../../internal/web/web.go) — обёртка role ([web.go](../../internal/web/web.go) — the
`mux.Handle("/", s.requireAuth(authed))`), сессия не несёт ничего, кроме факта `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:
- приложения этого домена (создание, режим отправителя, перегенерация пароля, - that domain's applications (creation, sender mode, password regeneration,
удаление, свой L2-лимит); deletion, its own L2 limit);
- DKIM/DNS-статус домена; - 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)). ([sendLogData](../../internal/web/handlers_monitor.go)).
Вне роли остаётся то, что глобально по своей природе: What stays outside the role is what is global by nature:
- добавление и удаление доменов; - adding and removing domains;
- создание domain-admin пользователей и назначение им доменов; - creating domain-admin users and assigning domains to them;
- `/reload`; - `/reload`;
- полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены - the full backup (that is all of `/data` including `sasldb2`, i.e. every
сразу); domain at once);
- очередь и хвост `mail.log` — они серверные и к домену не привязаны. - 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) относит «несколько пользователей панели, роли» к [product.md](../product.md) puts "multiple panel users, roles" out of scope
out of scope (один администратор). Появление второго субъекта — сознательное (one administrator). A second subject is a deliberate widening of the project's
расширение границ проекта, как и inbound-relay. boundary, as inbound-relay is.
Цена — уровня фазы, а не патча: The cost is phase-sized, not patch-sized:
- таблица пользователей и их привязка к доменам; - a users table and their binding to domains;
- роль в сессии; - the role in the session;
- авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` - authorisation in every handler (not only on the route — today `{id}`/`{aid}`
не сверяются ни с чем, кроме существования); are checked for nothing beyond existence);
- пересмотр первичного setup'а и смены пароля под нескольких пользователей; - reworking first-run setup and password change for several users;
- учёт нового субъекта в бэкапе и экспорте домена. - accounting for the new subject in backup and domain export.
*(Прежняя формулировка этого пункта«2FA и несколько администраторов» — *(The earlier wording of this item"2FA and multiple administrators" — has
заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до 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 с разными правами работают через - A global administrator and a domain-admin with different rights both work
панель; domain-admin не может выйти за пределы **назначенных** доменов; through the panel; the domain-admin cannot reach past the **assigned**
- текущий единственный админ мигрирует в глобального без потери доступа; domains;
- бэкап/восстановление учитывает пользователей и привязки; - the current single admin migrates into a global one without losing access;
- `build`/`vet`/`test`/образ зелёные. - backup/restore accounts for users and their bindings;
- `build`/`vet`/`test`/image green.
## Риски ## Risks
- Неполная проверка `{id}`/`{aid}` в хендлерах — утечка доступа к чужому - An incomplete `{id}`/`{aid}` check in a handler — access leaking to someone
домену; else's domain;
- breaking setup/бэкап — тогда semver major, не 1.x. - 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)
**Статус:** согласовано **Status:** agreed
**Версия:** целевой bump **1.x** MINOR; **возможен 2.x**требует уточнения по **Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once
итогам реализации (не фиксировать major заранее). the implementation lands (do not fix a major in advance).
**Модель:** Opus (инфра/безопасность, риск open relay). **Order:** recommended after [web-split](web-split.md) and
**Порядок:** рекомендуется после [web-split](web-split.md) и
[domain-admin](domain-admin.md). [domain-admin](domain-admin.md).
--- ---
## Цель ## Goal
Возможность принимать почту на порт 25 для явно настроенных доменов и пересылать The ability to accept mail on port 25 for explicitly configured domains and
её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как 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**принять почту, когда основной почтовый сервер домена временно - **Backup-MX**accept mail while the domain's primary mail server is
недоступен, и передать её, когда он вернётся. temporarily unreachable, and hand it over when it comes back.
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер, - **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
(нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на internet itself** (no static or public IP, behind NAT, a private address,
входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает inbound port 25 blocked, and so on). SelfPost, with a public IP and a correct
публичным входным узлом для домена (MX указывает на него) и пересылает почту 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) - **IT IS:** acceptance on 25 for domains from an explicit list, plus
на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`). forwarding (relay/forward) to an upstream (`relay_domains` +
Postfix здесь — чистый пересыльщик, без локальной доставки. `transport_maps` + `relay_recipient_maps`). Postfix here is a pure forwarder,
- **ЭТО НЕ (out of scope, [product.md](../product.md)):** локальная доставка в with no local delivery.
почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost - **IT IS NOT (out of scope, [product.md](../product.md)):** local delivery to
также **не реализует и не тянет в свой образ** движок антиспама/антивируса mailboxes, IMAP/POP3, webmail, Dovecot. No mailboxes at all. SelfPost also
(rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не** **neither implements nor bundles** an anti-spam or anti-virus engine
перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет (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, - Accepting on port 25 changes the threat model (open relay for inbound,
spam-ingress). Поэтому по умолчанию **выключено** флагом env backscatter, spam ingress). So it is **off** by default behind the
`INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора. `INBOUND_RELAY_ENABLE=false` env flag; turning it on is a deliberate step by
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели, the operator.
отдельная ветка генерации конфига. При выключенном флаге входной listener, - Isolation: separate SQLite tables, separate panel handlers and pages, a
таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен. 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` — генерировать - 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
конфиг (`postfix-config.sh`). the config is generated (`postfix-config.sh`).
- **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25 - **`master.cf`:** an inbound `smtp inet` on 25 for accepting from the internet
используется только на исходящую доставку). Отдельный от 465/587: на 25 **не** (today 25 is used only for outbound delivery). Separate from 465/587: on 25
предлагается SASL и **не** разрешается отправка наружу — только приём для SASL is **not** offered and sending outwards is **not** allowed — inbound
`relay_domains`. only, for `relay_domains`.
- **Анти-open-relay для входящей (обязательно):** - **Anti-open-relay for inbound (mandatory):** the inbound smtpd's
`smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd `smtpd_relay_restrictions` / `smtpd_recipient_restrictions` accept mail
принимают почту **только** для доменов из `relay_domains` и **только** для **only** for domains in `relay_domains` and **only** for known recipients
известных получателей (`relay_recipient_maps`); всё прочее — (`relay_recipient_maps`); everything else gets
`reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём `reject_unauth_destination` / `reject_unlisted_recipient`. An open relay, or
«для кого угодно» невозможны. accepting "for anyone", is impossible.
- **Backscatter:** предпочтительно знать валидных получателей (reject unknown - **Backscatter:** knowing the valid recipients is preferable (reject unknown
recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса. recipient at RCPT stage) so that bounces to non-existent addresses are never
- **Панель управляет:** список входящих доменов; для каждого — upstream generated.
destination (`host:port`, транспорт), опциональный список валидных - **The panel manages:** the list of inbound domains; for each one the upstream
получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта destination (`host:port`, transport), an optional list of valid recipients,
(whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе and optional TLS to the upstream. Strict validation of domain, host and port
4), `os/exec` без shell ([security.md](../security.md)). (whitelist), injection-safe writing of map files (as with
- **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не `sender_login_maps` in Phase 4), `os/exec` without a shell
подписываем). journal-milter опционально переиспользовать для журнала входящих ([security.md](../security.md)).
(доп. работа) либо на первом этапе оставить входящий без него; поведение - **Milters:** OpenDKIM is not needed on the inbound path (we do not sign
fail-open сохраняется. someone else's inbound mail). The journal-milter can optionally be reused for
- **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и an inbound journal (extra work), or the inbound path can go without it in the
`message_size_limit` на входном smtpd. 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)
Это ценная опция, но она **не обязательна**: часть операторов вполне устроит This is a valuable option, but it is **not mandatory**: some operators will be
**слепая пересылка без фильтрации** — например, когда backend сам умеет perfectly served by **blind forwarding without filtering**when the backend
фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик. 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`), и входящий релей полностью работоспособен без него. `INBOUND_ANTISPAM_MILTER`), and the inbound relay is fully functional without
it.
Важно другое — где фильтрация возможна технически: при «слепом» relay целевой What matters is something else: where filtering is technically possible. With a
backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя, "blind" relay the destination backend sees **SelfPost's** address as the
поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация connecting IP, not the original sender's, so everything on the backend that
проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF depends on the origin IP breaks (DNSBL and reputation are checked against
домена-отправителя). **Единственная точка, где ещё виден настоящий client IP — SelfPost's IP; SPF returns fail, since SelfPost is not in the sending domain's
входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть SPF). **The only point where the real client IP is still visible is the inbound
*подключаема именно здесь*, а не переложена на backend, который эту информацию 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 и т.п.), - **The anti-spam engine is a separate optional container** (rspamd or
который оператор запускает **только если нужна эта опция** (тот же принцип, similar), which the operator runs **only if this option is wanted** (the same
что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его principle as the reverse proxy — a separate container outside the SelfPost
**не содержит и не запускает** — образ и принцип «один контейнер, три image). SelfPost **neither contains nor starts it** — the image and the "one
процесса» неизменны, [product.md](../product.md) out of scope не нарушается container, three processes" principle are unchanged, and
(SelfPost не реализует антиспам). [product.md](../product.md)'s out-of-scope list is not violated (SelfPost
- **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd. does not implement anti-spam).
Адрес движка задаётся env (например, - **SelfPost provides the attachment point:** a milter hook on the inbound
`INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и smtpd. The engine's address is set via env (for example,
добавляется в `smtpd_milters` **только входного** тракта (не на 465/587). `INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, empty → the hook is off) and
Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит is added to `smtpd_milters` for the **inbound path only** (not on 465/587).
истинный origin. `milter_default_action` для этого milter'а — конфигурируемый Postfix passes the milter the real client IP, HELO and PTR — the filter sees
(fail-open vs tempfail); дефолт определить при реализации. the true origin. `milter_default_action` for that milter is configurable
- **Нативный backstop без зависимостей:** на том же входном хопе доступны (fail-open vs tempfail); the default is to be decided during implementation.
средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки - **A native backstop with no dependencies:** on that same inbound hop,
HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение Postfix's own origin-IP facilities are available — `reject_rbl_client`
аутентификации для downstream через ARC/`Received` там, где часть фильтрации (DNSBL) and HELO/PTR checks — and they work even without an external
всё же остаётся на backend. container. Plus preserving authentication results for downstream through ARC
- **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара or `Received`, where part of the filtering does remain on the backend.
(как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со - **docker-compose:** document an optional anti-spam sidecar fragment (like the
стеком только при включённой опции. alternative reverse-proxy fragments) — the container comes up with the stack
- **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный only when the option is enabled.
бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей - **Persistence:** new tables and map files under `/data` — they land in the
конфигурацией — опционально, пометить. full backup automatically (Phase 9). Domain export/import can be extended
- **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на with the inbound configuration — optional, to be flagged.
сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS - **DNS documentation:** an inbound domain needs an `MX` record pointing at the
README. server (unlike outbound, where no MX is required) — to be reflected in the
README's DNS section.
## Безопасность ## Security
[security.md](../security.md): валидация ввода на сервере, экранирование записи [security.md](../security.md): server-side input validation, escaped writes to
в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter. config files, `exec` without interpolation, no open relay, protection against
backscatter.
## Готово, когда ## Done when
При `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого With `INBOUND_RELAY_ENABLE=true` and a configured domain, mail arriving on port
домена пересылается на заданный upstream; почта для ненастроенных 25 for that domain is forwarded to the given upstream; mail for unconfigured
доменов/получателей отклоняется (не open relay, не backscatter); при заданном domains or recipients is rejected (not an open relay, no backscatter); with
`INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим `INBOUND_ANTISPAM_MILTER` set, inbound mail passes through the external filter
origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при with the real origin IP (verified with a sidecar container), and with it empty
`INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый the hook stays out of the way; with `INBOUND_RELAY_ENABLE=false` the inbound
исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные. 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` + - open relay / backscatter — removed by `relay_domains` +
`reject_unauth_destination`; `relay_recipient_maps` + `reject_unauth_destination`;
- потеря origin IP для фильтрации на backend'е при пересылке — снимается - the loss of the origin IP for filtering on the backend when forwarding —
milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё removed by the anti-spam milter hook plus native DNSBL on the inbound hop,
виден; where the origin IP is still visible;
- порт 25 на приём расширяет поверхность атаки (по умолчанию выключено); - port 25 accepting mail widens the attack surface (off by default);
- semver: при несовместимости контракта (порты, бэкап, поведение без флага) — - semver: if the contract turns out incompatible (ports, backup, behaviour with
возможен major `2.x`; решение после реализации. the flag off) a major `2.x` is possible; the decision comes after the
implementation.
**Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа **External deployment dependency:** the optional anti-spam container — outside
SelfPost, поднимается оператором при включении опции. 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`)
**Статус:** согласовано **Status:** agreed
**Версия:** `1.x`; внутренний рефакторинг, сам по себе breaking не тянет. **Version:** `1.x`; an internal refactor, it does not force a break on its own.
--- ---
## Что это ## What this is
`internal/web` — самый крупный пакет проекта: ~50 файлов (включая шаблоны и `internal/web` is the project's largest package: ~50 files (templates and
static), ~25 `.go` / ~4000 строк Go, в одной плоскости лежат хендлеры всех static assets included), ~25 `.go` files and ~4000 lines of Go, with the
разделов панели, сессии, security-заголовки, проверка Origin, валидация форм и 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`, At its current size the flat package reads fine: the file names
`handlers_apps.go`, `handlers_monitor.go`) работают не хуже каталогов, а (`handlers_domains.go`, `handlers_apps.go`, `handlers_monitor.go`) do the work
разбиение потянуло бы за собой экспорт того, что сейчас пакетно-приватно, — то directories would, and splitting would force exporting what is package-private
есть расширение внутреннего API ради косметики. 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`), пока 1. **web-split**lay down the package structure (including a place for
нет сквозных правок от роли и новых inbound-хендлеров. `web/auth`) while there are no cross-cutting edits from the role and no new
2. **domain-admin** — авторизация в каждом хендлере опирается на уже выбранную inbound handlers.
схему пакета. 2. **domain-admin** — authorisation in every handler builds on a package layout
3. **inbound-relay** — новый вертикальный срез; проще добавить в уже разрезанный 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
Решение принято осознанно в момент старта работ — либо пакет разрезан по 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
`build`/`vet`/`test` зелёные, поведение панели неизменно. 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.