## Context

Сегодня HTTP API открыт наружу без проверки — так записано первой строкой модели
угроз. Приглашение второго человека упирается в это: у записей нет владельца, а
подобранный идентификатор задачи отдаёт чужую расшифровку.

Решение от 2026-08-11 (`ADR-2026-08-11-pocketbase-storage-with-admin-panel`)
назвало способ: **ответ провайдера разбирает хранилище, а не наш код**. У
коллекции пользователей включается провайдер `oidc` с адресами Authelia, учётные
записи заводятся сами, и панель их видит. Проверено на версии 0.39.10 —
`docs/research/pocketbase.md`, раздел «Пользователи — только те, кого туда
положат».

**Способ остаётся верным, но его механика уже проверена по исходникам
библиотеки, и три ожидания постановки она не подтверждает.** Проверено чтением
`pocketbase@v0.39.10`:

1. **Куки библиотека не читает вовсе.** Сессию она берёт единственным способом —
   заголовком `Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`).
   Критерий приёмки задачи написан про куку.
2. **Эндпоинта выхода библиотека не приносит.** Список её адресов
   аутентификации — `auth-methods`, `auth-refresh`, `auth-with-password`,
   `auth-with-oauth2`, `request-otp`, `auth-with-otp`, восстановление пароля,
   подтверждение почты и смена почты (`apis/record_auth.go`). Выхода среди них
   нет.
3. **Браузерный вход по редиректу библиотека своим не приносит.** Она приносит
   обмен уже полученного кода: `POST /api/collections/{c}/auth-with-oauth2`
   требует `provider`, `code`, `codeVerifier` и `redirectURL`. Её собственный
   `/api/oauth2-redirect` служит другому: он ищет клиента realtime-подписки по
   параметру `state` и отдаёт код туда (`apis/record_auth_with_oauth2_redirect.go`)
   — это механика её JS-клиента с всплывающим окном, а не серверный вход.

Отсюда объём: инициировать вход, принять возврат и завести куку — наш код.
Разбор ответа провайдера, заведение учётной записи и связь с внешним провайдером
остаются за хранилищем, как и решено. Решение 2026-08-11 не пересматривается.

## Goals / Non-Goals

**Goals:**

- запрос к приёму записи и к опросу готовности без сессии получает отказ и
  ничего не заводит;
- вход идёт у Authelia по OIDC, учётные записи заводятся сами;
- выход закрывает доступ немедленно, а не по истечении срока;
- сессия переживает выкладку;
- проба здоровья и метрики остаются открытыми.

**Non-Goals:**

- владелец у записи и сужение выборки по нему — задача `record-ownership`;
- вход для программ по личным токенам — отдельная цель роадмапа. Внешняя
  программа, ходившая в API анонимно, этим изменением ломается намеренно, и
  замены ей здесь не появляется;
- белый список Telegram — живёт до `telegram-account-link`;
- панель администратора — в неё провайдер не пускает, наружу её закрывает
  обратный прокси;
- своя страница входа со скриптом: у сервиса нет фронтенда, и заводить его ради
  входа незачем.

## Decisions

### Сессия предъявляется кукой, а заголовок остаётся внутренним

**Выбрано:** наш обработчик возврата ставит куку `HttpOnly`, `Secure`,
`SameSite=Lax` со значением, выданным хранилищем. Промежуточный слой перед
проверкой перекладывает значение куки в заголовок `Authorization`, если заголовка
нет. Дальше работает штатная проверка библиотеки.

Человек увидит обычный вход: перешёл, авторизовался у Authelia, вернулся —
работает. Ни строки скрипта на его стороне.

Отвергнуто:

- **заголовок `Authorization` как единственный способ.** Это механика библиотеки
  и путь наименьшего кода, но браузер такой заголовок сам не шлёт: понадобился
  бы свой фронтенд, который держит значение и подставляет его. Фронтенда у
  сервиса нет, а заводить его ради входа — работа шире задачи. Критерий приёмки
  задачи вдобавок написан про куку;
- **своя таблица сессий.** Даёт полный контроль над выходом и сроком, но заводит
  второй способ делать то, что хранилище уже делает, — и второй дом для факта
  «кто вошёл». Отвергнуто по концептуальной целостности.

Заголовок при этом остаётся рабочим: его требуют собственные адреса
аутентификации хранилища, и глушить их значит ломать библиотеку изнутри. Это
осознанно оставленная вторая дверь, и она названа в спеке.

### Форма решения выбрана из трёх, а не из одной

Прежде трёх решений ниже — выбор самой формы. Рассматривались три.

**A — свой тонкий слой входа поверх хранилища.** Выбрана. Наш код ведёт флоу и
ставит куку, обмен кода и заведение учётной записи остаются за хранилищем.
Цена: сверка состояния и установка куки — наша ответственность, то есть ошибки
в чувствительном месте наши.

**B — вход целиком на обратном прокси.** Authelia стоит перед сервисом и не
пускает неузнанные запросы, приложение доверяет заголовку от прокси. Нашего кода
почти ноль. Отвергнуто по трём причинам сразу: при прямом обращении к порту
заголовок подделывает кто угодно в той же сети, а сервис не имеет способа
отличить прокси от постороннего; учётные записи в панели не появляются вовсе, а
решение 2026-08-11 требует обратного; вход для программ по личным токенам из
этой формы не вырастает — его пришлось бы делать заново и мимо.

**C — фронтенд и штатный клиент хранилища.** Своя страница, всплывающее окно,
подписка, значение сессии в хранилище браузера. Всё штатно для библиотеки.
Отвергнуто: у сервиса нет фронтенда, и заводить его ради входа — работа шире
задачи; значение сессии становится доступно скриптам страницы, то есть XSS
уносит сессию целиком, тогда как кука с запретом чтения скриптом этого не даёт.

### Вход и возврат ведёт наш код, разбор ответа — хранилище

**Выбрано:** три своих адреса — начало входа, возврат от провайдера, выход.
Начало входа заводит `state` и PKCE-verifier, кладёт их во временную куку и
уводит человека на `authURL` провайдера. Возврат сверяет `state`, а код отдаёт
хранилищу вызовом его же обмена — тем, что стоит за `auth-with-oauth2`.

**Обмен кода библиотека наружу не отдаёт** — он живёт неэкспортированной
функцией за собственным адресом хранилища. Решением владельца от 2026-08-12
обработчик возврата зовёт **этот адрес внутри процесса**, через роутер
хранилища, а не по сети.

Цена названа и принята: получается петля «наш обработчик → наш роутер → наш
обработчик», ответ разбирается текстом, а типизированная ошибка теряется.
Взамен решение 2026-08-11 соблюдается дословно — разбор ответа провайдера
остаётся за хранилищем, и учётные записи видны в панели.

Отвергнуто:

- **собрать обмен своими руками** из кусков, которые библиотека всё же отдаёт.
  Прямой код без петли, но разбор ответа провайдера переезжает к нам — это
  пересмотр решения 2026-08-11 отдельным ADR, и владелец его не выбрал;
- **всплывающее окно и realtime-подписка**, как делает JS-клиент библиотеки.
  Работает без нашего кода вовсе, но требует того самого фронтенда и держит
  открытым realtime-соединение ради одного входа.

### Кого пускать, решает провайдер, а не сервис

Решением владельца от 2026-08-12 сервис своей проверки допуска **не делает**:
кто допущен, определяет правило Authelia на этого клиента. Всякий, кого
провайдер пропустил, получает учётную запись и доступ.

Цена принята и обязана быть записанной: правило живёт вне репозитория, в
настройках выкладки, и сервис на него полагается так же, как полагается на
обратный прокси в части панели администратора. Настроенный слишком широко
клиент открывает сервис всем, у кого есть учётная запись в общей Authelia, — и
проверить это по коду нельзя. Строка об этом идёт в `docs/security.md`, раздел
«Что разграничивает доступ».

Отвергнуто: **проверка группы своим кодом** — защита стояла бы в сервисе и не
зависела от настройки контура, но владелец выбрал не заводить второе место, где
решается допуск.

### Выход обесценивает выданные сессии, а не только убирает куку

**Выбрано:** выход обновляет ключ токенов учётной записи
(`Record.RefreshTokenKey()` плюс сохранение) и убирает куку. Подпись сессии
считается от этого ключа, поэтому все прежние значения перестают проходить
разом.

Отвергнуто:

- **только уборка куки.** Унесённое значение продолжало бы открывать доступ до
  истечения срока — то есть выход не закрывал бы доступ, а делал вид;
- **чёрный список выданных значений.** Даёт точечный выход одной сессии, но
  требует своей таблицы и её чистки; при одном человеке и одном браузере это
  цена без покупателя.

Цена выбранного названа прямо: выход закрывает **все** сессии учётной записи, а
не только текущую. При сегодняшнем числе пользователей это незаметно, и
переделка, когда станет заметно, — чёрный список из отвергнутого варианта.

### Сессия переживает перезапуск сама

Проверено по исходникам: подпись считается от секрета коллекции
(`Collection().AuthToken.Secret`) и ключа записи, оба лежат в базе
(`core/record_query.go`, `FindAuthRecordByToken`). Значит требование выполняется
устройством хранилища, и нашей работы здесь нет — есть проверка тестом.

### Настройки провайдера приводятся к конфигу при каждом запуске

**Выбрано:** шаг схемы включает провайдера с пустыми значениями, а адреса,
идентификатор клиента и секрет проставляются при подъёме сервиса из конфига.

Причина в инварианте: **применённый шаг схемы не переписывается**. Проставь
секрет однажды шагом — и ротация секрета в конфиге до хранилища не доедет вовсе,
вход сломается после смены ключа, а починить это можно будет только руками в
панели.

Отвергнуто:

- **секрет в шаге схемы.** Разбито инвариантом выше;
- **настройка руками в панели.** Работает, но не воспроизводится: поднятый с
  нуля сервис оказывается без входа, и знание живёт в голове владельца.

### Что изменило ревью кода

Три решения приняты владельцем 2026-08-12 уже после того, как код был написан:
ревью нашло, что заявленное поведение не работает.

**Продление сессии выключено.** Хранилище выдаёт сессию продлеваемой, и
предъявитель менял своё значение на новое бессрочно, никуда не входя. При живом
продлении семисуточный срок не значил ничего — а он объявлен единственным
каналом, которым отзыв доступа у провайдера доходит до сервиса. Цена: вход раз в
семь суток. Отвергнуто: сверяться с провайдером по расписанию (новая связь с
Authelia и обработка её недоступности — работа шире задачи) и принять как есть
(тогда паспорт теряет способ остановить того, кто тратит слишком много).

**Файл записи открыт вошедшим.** Пометка поля защищённым сама по себе закрыла
файл вообще для всех, кроме владельца панели: защищённый файл судится ещё и
правилом просмотра коллекции, а незаданное правило означает «только
суперпользователь». Назначено правило для всякого узнанного. Отвергнуто:
оставить файл только панели — тогда задача про прослушивание записи начинается с
того же вопроса.

**Форма адреса провайдера проверяется на старте.** Непустая, но негодная строка
проходила проверку конфига и отвергалась хранилищем позже — из хука подъёма, до
регистрации пробы здоровья. Сервис падал целиком, вместе с ботом и воркерами, а
у владельца не было даже кода состояния. Отвергнуто: поднимать пробу здоровья
раньше настройки провайдера — это завело бы состояние «сервис жив, вход сломан»,
которого спека не описывает.

## Risks / Trade-offs

- **Секрет клиента появляется в новом месте — в базе.** → Инвариант проекта
  запрещает секрету попадать в git, в лог, в ответ и в `error_text`; база в этом
  перечне не значится, и запрета не нарушает. Но место новое, и модель угроз
  обязана его назвать: чтение файла базы теперь равносильно чтению секрета
  клиента. Пишется в `docs/security.md` этой же задачей.
- **Ломается внешняя программа, ходившая в API анонимно.** → Ломка намеренная и
  объявлена в предложении: это и есть предмет задачи. Замены для программ
  (личные токены) в этом изменении нет — она отдельной целью.
- **Вторая дверь: заголовок `Authorization` остаётся принимаемым.** → Он
  предъявляет ту же сессию и той же проверке, поэтому обхода не даёт. Но это
  второй способ войти, и в спеке он назван, чтобы не был обнаружен ревью как
  находка.
- **Выход закрывает все сессии учётной записи.** → Названо решением выше, цена
  принята.
- **PKCE-verifier и `state` живут во временной куке.** → Кука ставится на время
  входа, `HttpOnly` и `SameSite=Lax`, и убирается на возврате. Хранить их в
  памяти процесса нельзя: выкладка посреди входа роняла бы вход.
- **Признак `Secure` закрывает локальный запуск.** → Браузер не сохранит такую
  куку по `http://localhost`, и вход перестанет работать у того, кто поднимает
  сервис командой из раздела команд. Признак берётся из конфига с умолчанием
  «включено», и расхождение образца называется строкой в
  `docs/conventions/config.md`.
- **Коллекция пользователей остаётся умолчательной `users`.** → Своя коллекция
  означала бы задание правил и способов входа с нуля вместо подчистки
  умолчаний, а переезд позже — перевязку связей с провайдером и обесценивание
  всех выданных сессий. Цена умолчательной: её заводит системный шаг библиотеки
  с открытым созданием записи, и закрывать это приходится нам.
- **Проверить вход целиком без живой Authelia нельзя.** → Тесты закрывают
  сверку `state`, отказ без сессии, выход и сохранность сессии; живой вход у
  провайдера остаётся ручной проверкой владельца на выкладке. Это граница
  покрытия, и она называется в докладе.

## Migration Plan

Шаг схемы включает провайдера у коллекции пользователей и накатывается при
подъёме, как и прежние шаги. Данных он не трогает: ни одной записи не
переписывается, учётные записи заводятся сами при первом входе.

Откат — прежний образ: шаг схемы обратим своим `down`, а до первого входа в
коллекции пользователей пусто.

Порядок выкладки: сперва завести клиента в Authelia и получить секрет, потом
положить его в конфиг на сервере, потом выкладывать. Обратный порядок поднимает
сервис с провайдером без секрета — вход не работает, а API уже закрыт.

## Open Questions

- Адрес возврата должен совпадать с тем, что записан клиенту в Authelia. Значение
  выбирается при заведении клиента и попадает в конфиг; здесь оно не
  фиксируется.
