# PocketBase: что даёт панель администратора

**Записка о прошлом.** PocketBase ушла из проекта целиком 2026-08-22 —
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
Панели у сервиса нет, и ничто из описанного ниже сегодня не работает. Записка
остаётся затем, что ею мерили цену потери: возврат остановленной записи в работу
делает подкоманда `cmd/devtools resume`, а остальное приносят отдельные задачи.

Отвечает на вопрос разведки `pocketbase-admin-fit`: что панель показывает и
правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы
держать перевод хранилища в планах.

## Как снималось

Версия **0.39.10**, выпуск от 2026-07-30 (`./pocketbase --version`). Смотрел на
пустой базе в каталоге вне репозитория, боевые данные не участвовали. Прогонов
было два:

- **готовый бинарник** — `pocketbase serve --http=127.0.0.1:8099`, суперпользователь
  заведён командой `pocketbase superuser create`. Возможности панели снимал её же
  запросами (`/api/collections`, `/api/logs`, `/api/backups`, `/api/crons`,
  `/api/settings`) и поиском по её собранному коду;
- **своя сборка**, где PocketBase подключён библиотекой к пустому приложению на
  Go, — так, как предполагает задача `pocketbase-storage`.

Оба прогона удалены вместе с песочницей.

## Правка записей — работает целиком

Панель показывает каждую коллекцию таблицей, отбирает записи своим языком
фильтров, сортирует, создаёт, правит и удаляет их по одной. Сверх таблицы в ней
есть выгрузка списка в CSV, журнал запросов с временем ответа и кодом, резервные
копии с загрузкой и восстановлением, список заданий планировщика.

Групповой операции над отмеченными записями в панели нет: удаление идёт по
одной. Проверял поиском по её коду — строк вида «удалить отмеченное» в нём не
нашлось, тогда как «Export as CSV» и «Download JSON» нашлись.

## Пользователи — только те, кого туда положат

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

Второй путь есть, и он работает: **вход можно отдать самой PocketBase**. У
пользовательской коллекции настраивается провайдер `oidc` с произвольными
адресами; я включил его на адреса вида `https://auth.example.com/api/oidc/...`,
и клиент немедленно стал получать провайдера в списке способов входа. Тогда
учётные записи заводятся сами, и панель их видит.

**В саму панель Authelia не пускает.** Вход суперпользователя — своя почта и свой
пароль:

- включить `oidc` у коллекции суперпользователей не удалось: запрос принимается,
  но возвращает коллекцию с выключенным `oauth2`;
- включить второй фактор у неё же не удалось тоже — ответ `403`.

Ограничить панель списком адресов можно: настройка `superuserIPs` принимает
адреса и подсети. **Ею же можно запереть себя** — после того как я поставил туда
чужой адрес, все запросы суперпользователя, включая запрос на сброс настройки,
стали отвечать `403`. Команды сброса в наборе нет: он состоит из `migrate`,
`superuser`, `update` и `serve`.

## Файлы — только свои

Файл живёт полем записи, и раскладку на диске выбирает PocketBase:

```
pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>.ogg
pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>.ogg.attrs
```

Проверено загрузкой файла в 200 КБ: имя `sample.ogg` превратилось в
`sample_uztrv6wvz3.ogg`, рядом лёг файл атрибутов.

*Уточнено 2026-08-12:* суффикс дописывает конструктор имени, а не укладка. Имя,
заданное после конструктора, ложится на диск дословно — см.
[pocketbase-defaults.md](pocketbase-defaults.md).

Сегодняшняя раскладка `data/files` с именами-UUID панели не видна. Путь она
покажет строкой — прослушать и скачать запись по ней нельзя. Способа сослаться
на файл, уже лежащий на диске мимо её каталога, нет.

Поле помечается защищённым, и тогда файл не отдаётся по прямой ссылке: без токена
ответ `404`, с выданным файловым токеном — `200`.

**Резервные копии накрывают ровно её каталог.** Файлы, оставленные снаружи, в них
не попадут — то есть панель и встроенное резервное копирование покупаются одной и
той же ценой.

## Побочное: CGO уходит

Библиотечная сборка встала при `CGO_ENABLED=0` — PocketBase ходит в SQLite через
`modernc.org/sqlite`, а не через `mattn/go-sqlite3`. Требование CGO записано
сегодня свойством стека в `../../CLAUDE.md`, и перевод его снимает.

*Уточнено 2026-08-12:* перевод состоялся, и требования CGO в стеке больше нет —
[../../CLAUDE.md](../../CLAUDE.md), «Стек»: компилятор C нужен только детектору
гонок в гейте.

Бинарник пробника — 33 954 634 байта против 43 498 904 у сегодняшнего приложения
(`go build` без флагов). **Числа не сравнимы напрямую:** в пробнике нет ни бота,
ни клиента SpeechKit, ни клиента Object Storage. Что даст сборка после перевода,
не замерялось.

Панель отдаётся по адресу `/_/` того же порта, что и остальное приложение, — и в
библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало
на `/_/` кодом `200`.

## Вход через OIDC: что выяснилось при реализации

Дописано 2026-08-12 задачей `oidc-login`. Все находки ниже получены одним
способом: чтением исходников `pocketbase@v0.39.10` из кеша модулей и прогонами
против настоящего хранилища на временном каталоге — в ходе ревью того change. Живой Authelia в прогонах не
было ни разу: провайдера подменял свой `httptest`-сервер.

**Коллекция `users` приходит открытой.** Системный шаг библиотеки заводит её с
`CreateRule = ""` (создание доступно анониму) и `PasswordAuth.Enabled = true`
(`migrations/1640988000_init.go`, `core/collection_model_auth_options.go`).
Прогон подтвердил: `POST /api/collections/users/records` → `200`, следом
`auth-with-password` → `200` с токеном. То есть закрытие API за вход обходится
двумя запросами, пока эта поверхность не закрыта своим шагом схемы.

**Правило создания нельзя закрывать полностью.** `CreateRule = nil` означает «только
суперпользователь», а запись при первом входе заводит **внутренний** запрос
самого обмена, идущий без таких прав (`apis/record_crud.go`: проверка
`!hasSuperuserAuth && collection.CreateRule == nil`). Прогон: с `nil` вход
кончался `401`, учётных записей `0`. Работает правило
`@request.context = "oauth2"` — контекст ставит сам обмен
(`core.RequestInfoContextOAuth2`), а посторонний запрос приходит с контекстом по
умолчанию. Открывать правило пустой строкой при этом нельзя: публичный обмен
принимает поля создаваемой записи от вызывающего.

**Обмен кода наружу не экспортирован.** Пакет `apis` отдаёт ошибки, middleware,
`NewRouter`, `Serve` и обёртки; сам обмен — неэкспортированная функция за
маршрутом `POST /api/collections/{c}/auth-with-oauth2`, принимающая `provider`,
`code`, `codeVerifier`, `redirectURL`. Собственный `/api/oauth2-redirect` служит
другому — он ищет клиента realtime-подписки по параметру `state`, то есть
обслуживает всплывающее окно JS-клиента, а не серверный вход.

**`apis.NewRouter` не идемпотентна: собирать её нужно один раз и держать, а не
создавать заново при каждом вызове.**
Она зовёт `bindRealtimeEvents` и `bindUIExtensions`, а те вешают девять
обработчиков **на приложение** и без поля `Id`; `hook.Bind` такому генерирует
новый идентификатор и **добавляет**. Замер: пять вызовов подряд подняли
`OnModelAfterUpdateSuccess` с 4 до 14, а 3000 вызовов — время сотни сохранений
записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. Освобождения нет, только
перезапуск.

**Связывание учётной записи идёт по `sub`, а не найдя — по почте.** Обмен ищет
запись в `_externalAuths` по `providerId`, и лишь затем `FindAuthRecordByEmail`
(`apis/record_auth_with_oauth2.go`). Отсюда цена открытой регистрации: запись,
заведённая посторонним на чужой адрес почты, достаётся первому же настоящему
входу с этим адресом.

**Защищённое поле файла судится двумя вещами сразу** — коротким токеном файла из
строки запроса **и** правилом просмотра коллекции (`apis/file.go`). Незаданное
правило означает «только суперпользователь», поэтому одной пометки `Protected`
мало: прогон показал `404` анониму, вошедшему кукой, вошедшему заголовком и
вошедшему с законно полученным токеном файла — пока правило не назначено.

**Сессия по умолчанию продлеваема бессрочно.** Токен несёт поле
`refreshable=true`, и `POST /api/collections/{c}/auth-refresh` меняет его на
новый с новым сроком. Прогон: три продления подряд, каждое `200`, `exp` растёт.
Настройки «выдавать непродлеваемую сессию» у коллекции нет — закрывается только
слоем приложения поверх маршрута.

**Подпись сессии считается от секрета коллекции и ключа записи**, обе величины в
базе (`core/record_query.go`, `FindAuthRecordByToken`). Отсюда два следствия:
сессия переживает перезапуск сервиса сама, а смена ключа записи
(`Record.RefreshTokenKey()`) обесценивает все её выданные сессии разом.

**Куки библиотека не читает вовсе** — сессию берёт только заголовком
`Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`).

**Учётную запись обмен ищет двумя способами подряд, а связь с провайдером
уникальна.** Сперва — по неизменяемому признаку провайдера, а не найдя — по
адресу почты (`apis/record_auth_with_oauth2.go`, ветка `case authUser.Email !=
""` → `FindAuthRecordByEmail`). Найденной записи он пытается добавить связь, а на
ней стоит уникальный индекс
`idx_externalAuths_record_provider (collectionRef, recordRef, provider)`. Отсюда
исход, обратный ожидаемому: два разных признака провайдера с **одной** почтой не
сливаются в одного владельца молча — второй вход отвергается, обмен отдаёт `400`,
сервис — `401` со строкой `Failed to exchange provider code`, а настоящая причина
остаётся в журнале хранилища строкой `failed to save linked rel: … Value must be
unique`. Дописано 2026-08-15 задачей про заглушку OIDC; получено прогоном против
временного каталога — чтение исходников давало ту же цепочку, но противоположную
развязку.

**Журнал запросов пишет строку запроса целиком.** `activityLogger` на корневом
роутере кладёт `RequestURI` полем `url` в таблицу `_logs`, ретеншен по умолчанию
`MaxDays: 5`. Значит всё, что пришло параметром адреса, оседает там на пять
суток; проект умолчание не переопределяет.

## Что отвергнуто и почему

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

## Разграничение по владельцу: что выяснилось при реализации

Дописано 2026-08-14 задачей `record-ownership`. Все находки ниже получены одним
способом: чтением исходников `pocketbase@v0.39.10` из кеша модулей и прогонами
против настоящего хранилища на временном каталоге — в ходе ревью того change.

**Связь с выключенным каскадом не удерживает целостность при удалении.**
`core/record_model.go`, `deleteRefRecords`: при `CascadeDelete = false` и
необязательном поле хранилище **вынимает** идентификатор из поля связи и
сохраняет запись через `SaveNoValidate`. То есть «не уносить записи следом» и
«сохранить у них владельца» — разные вещи, и связь даёт только первое.

**Наружу проходит только ошибка роутера.** `apis/record_crud.go` заворачивает
отказ хука в `firstApiError(err, e.BadRequestError("Failed to delete record. Make
sure that the record is not part of a required relation reference.", err))`, а
`firstApiError` берёт первый аргумент, только если он `*router.ApiError`. Обычная
ошибка из хука до ответа не доезжает вовсе, и спрашивающий получает библиотечную
подсказку про обязательную связь — в нашем случае указывающую не на ту связь.

**`apis/file.go` выдаёт токен файла на предъявителя, а не на файл.** О файле при
выдаче он не спрашивает. Владельца судит переход по ссылке: правило просмотра
коллекции проверяет защищённое поле файла по учётной записи **из токена**. Значит
чужой токен получить можно всегда, а скачать по нему чужой файл — нет.

**Проверка сессии с именем коллекции отвечает `403`, а не `401`.**
`apis.RequireAuth("users")` пускает только запись названной коллекции; предъявитель
из другой — например, владелец панели — узнан, но не годится, и код отказа это
различает.

**Связь в SQLite лежит пустой строкой, а не `NULL`.** `RelationField.ColumnType`
даёт `TEXT DEFAULT '' NOT NULL`; сырой запрос и чтение через запись коллекции
совпадают побайтово. «Умолчания у колонки нет» верно по замыслу — пустое значение
не совпадает ни с кем, — но не буквально на уровне схемы.
