From 663021f7121b66e86157a15c9f8a15bc4212aa8f Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 15 Aug 2026 18:51:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BE=D0=B1=D1=80=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=80=D0=BA=D0=B0=D1=81=D0=BE=D0=BC=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D1=88=D0=B8=D1=82=D0=BE=20=D0=B2=20=D0=B1=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=D1=80=D0=BD=D0=B8=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное вшивается через go:embed и раздаётся корневым маршрутом: разметка на неизвестном пути вне корней сервиса, отказ контракта внутри корня - перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов, а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь - шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка входят в гейт, а в Dockerfile появилась ступень приложения --- .dockerignore | 30 + .gitignore | 9 +- CLAUDE.md | 21 +- Dockerfile | 21 + Taskfile.yml | 76 + ...-08-15-node-in-container-not-on-machine.md | 62 + docs/adr/README.md | 1 + docs/architecture.md | 29 +- docs/conventions/go-linters.md | 3 + docs/conventions/logging.md | 4 +- docs/conventions/web-ui.md | 39 +- docs/database.md | 1 + docs/passport.md | 4 +- docs/research/README.md | 1 + docs/research/webapp-serving.md | 69 + docs/review.md | 40 + docs/security.md | 32 +- internal/controller/http/auth.go | 12 +- internal/controller/http/webapp.go | 339 ++ internal/controller/http/webapp_test.go | 253 ++ journal_route_test.go | 36 +- main.go | 61 +- .../2026-08-15-spa-skeleton/.openspec.yaml | 2 + .../archive/2026-08-15-spa-skeleton/design.md | 257 ++ .../2026-08-15-spa-skeleton/proposal.md | 56 + .../review/code-review.md | 395 +++ .../specs/access/spec.md | 33 + .../specs/webapp/spec.md | 271 ++ .../archive/2026-08-15-spa-skeleton/tasks.md | 147 + openspec/specs/access/spec.md | 31 + openspec/specs/webapp/spec.md | 282 ++ web/biome.json | 24 + web/embed.go | 44 + web/embed/.gitkeep | 0 web/embed_test.go | 46 + web/index.html | 12 + web/package-lock.json | 2967 +++++++++++++++++ web/package.json | 24 + web/src/App.vue | 3 + web/src/api.ts | 77 + web/src/main.ts | 5 + web/src/router.ts | 12 + web/src/screens/HomeScreen.test.ts | 86 + web/src/screens/HomeScreen.vue | 46 + web/tsconfig.json | 15 + web/vite.config.ts | 23 + 46 files changed, 5958 insertions(+), 43 deletions(-) create mode 100644 .dockerignore create mode 100644 docs/adr/ADR-2026-08-15-node-in-container-not-on-machine.md create mode 100644 docs/research/webapp-serving.md create mode 100644 internal/controller/http/webapp.go create mode 100644 internal/controller/http/webapp_test.go create mode 100644 openspec/changes/archive/2026-08-15-spa-skeleton/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-15-spa-skeleton/design.md create mode 100644 openspec/changes/archive/2026-08-15-spa-skeleton/proposal.md create mode 100644 openspec/changes/archive/2026-08-15-spa-skeleton/review/code-review.md create mode 100644 openspec/changes/archive/2026-08-15-spa-skeleton/specs/access/spec.md create mode 100644 openspec/changes/archive/2026-08-15-spa-skeleton/specs/webapp/spec.md create mode 100644 openspec/changes/archive/2026-08-15-spa-skeleton/tasks.md create mode 100644 openspec/specs/webapp/spec.md create mode 100644 web/biome.json create mode 100644 web/embed.go create mode 100644 web/embed/.gitkeep create mode 100644 web/embed_test.go create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.vue create mode 100644 web/src/api.ts create mode 100644 web/src/main.ts create mode 100644 web/src/router.ts create mode 100644 web/src/screens/HomeScreen.test.ts create mode 100644 web/src/screens/HomeScreen.vue create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..897748c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +# Что не уезжает в контекст сборки образа. +# +# Файл заведён не ради веса: без него `COPY web/ ./` кладёт каталог зависимостей +# с машины собирающего **поверх** дерева, поставленного `npm ci` в контейнере, и +# ступень собирает приложение из того, что лежит у него, а не из файла замка. +# Сборка при этом зелёная — расхождение молчаливое. +# +# `.gitignore` этого не закрывает: docker его не читает. + +# Зависимости и собранное приложение — ставит и собирает сама ступень образа. +web/node_modules/ +web/embed/dist/ +web/*.tsbuildinfo + +# Боевые и локальные данные: каталог записей и база того, кто запускал сервис +# у себя. В образе им делать нечего. +data/ + +# Настройки с секретами. Образ берёт конфиг на сервере, а не из дерева. +config.toml +.env + +# История репозитория: в слой сборки не нужна. +.git/ +.gitignore + +# Каталоги процесса, а не сборки. +openspec/ +tasks/ +docs/ diff --git a/.gitignore b/.gitignore index f38658d..b3d584a 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,11 @@ config.toml # Sample and test audio files *.m4a *.mp3 -*.ogg \ No newline at end of file +*.ogg + +# Приложение: зависимости и собранное. Метка `web/embed/.gitkeep` остаётся в +# git — без неё `go build ./...` отказывает у того, кто приложение не собирал. +web/node_modules/ +web/embed/dist/ +# Слепок проверки типов: его пишет сборка, и в git он значил бы «собрано у меня». +web/*.tsbuildinfo diff --git a/CLAUDE.md b/CLAUDE.md index 7efc98a..3c5a338 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,9 @@ SpeechKit и отдаёт текст тому, кто запись загруз Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и панель администратора, — `aws-sdk-go-v2` для Object -Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка — +Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Приложение — Vue 3 +с роутером пятой версии и сборкой Vite; собранное вшито в бинарник, проверяют +его Biome и юнит-тесты Vue. Сборка — Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`. ## Инварианты @@ -118,10 +120,15 @@ go vet ./... gofmt -l . golangci-lint run go run . -c config.toml # флаг -c или --config, по умолчанию config.toml +task front # приложение: зависимости, Biome, юнит-тесты, сборка task image # docker-образ; тег и раскладка — docs/architecture.md task gate # весь набор проверок разом ``` +Node на машину **не ставится**: шаг сборки приложения зовёт его контейнером, а +образ берёт из ступени `Dockerfile`. Требованием к машине разработчика поэтому +становится docker — тот же, которым собирается образ. + Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml` — скопируй `config.example.toml` и заполни; известные прорехи образца перечислены в [docs/conventions/config.md](docs/conventions/config.md) строками @@ -153,7 +160,8 @@ task gate # весь набор проверок разом Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на любой отказ шага выходит с 201, а код шага печатает строкой («exit status 3»), поэтому словарь читается по коду скрипта. -- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`, +- **Что красит безусловно и почему:** красная сборка приложения, находка Biome, + красный юнит-тест приложения, отказ сборки, тестов, `go vet`, гонка, найденная детектором (`go test -race`), переписанный применённый шаг схемы, неотформатированный файл, находка `golangci-lint`, расхождение объявленных @@ -169,7 +177,14 @@ task gate # весь набор проверок разом `gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком, сверка версий Go, сверки документов и `govulncheck`: они смотрят всё дерево либо требуют сети, а pre-commit обязан быть быстрым. -- **Шагу `vulns` нужна сеть**, и он один такой: база уязвимостей живёт на +- **Сетезависимые шаги названы здесь поимённо, и перечень этот закрыт.** Один из + них — `front`: он ставит зависимости приложения из реестра пакетов, а docker до + того тянет образ сборочного окружения. Отказ сети и реестра там — отказ окружения, код 3, + отдельно от красной сборки, у которой код 1; полный кэш установщика снимает + поход в сеть вовсе. Без короткого обращения-пробы шаг **висел** бы вместо + отказа: установщик уходит в повторы с нарастающей паузой на каждом пакете, а + гейт, который висит, хуже красного. +- **Шагу `vulns` нужна сеть** тоже: база уязвимостей живёт на vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Судит он достижимость из кода: находка в модуле, чей уязвимый символ мы не вызываем, diff --git a/Dockerfile b/Dockerfile index f15e5ae..1600210 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,20 @@ +# Front build stage +# +# Приложение собирается до бинарника: вшивание требует готового каталога. +# Имя этого образа — единственное; шаг набора проверок берёт его отсюда же, +# чтобы версия сборочного окружения не жила вторым числом в Taskfile.yml. +FROM docker.io/library/node:24-alpine AS front-build + +WORKDIR /web + +# Зависимости ставятся из файла замка командой, которая его не правит: +# иначе собранное в образе перестаёт совпадать с собранным в наборе проверок. +COPY web/package.json web/package-lock.json ./ +RUN npm ci + +COPY web/ ./ +RUN npm run build + # Build stage FROM docker.io/library/golang:1.26-alpine AS build-env @@ -16,6 +33,10 @@ RUN go mod download # Copy source code COPY . . +# Собранное приложение приезжает ступенью выше: в дереве сборки его нет, +# а вшивание без него отдаёт бинарник, который отвечает «приложение не собрано». +COPY --from=front-build /web/embed/dist ./web/embed/dist + # Build the application RUN CGO_ENABLED=0 go build -o transcriber . diff --git a/Taskfile.yml b/Taskfile.yml index b012d0f..6e6111f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -24,6 +24,9 @@ tasks: gate: desc: 'Все проверки разом. База диффа: task gate BASE=' cmds: + # Приложение собирается первым: вшивание требует готового каталога, и + # `go build` без него соберёт бинарник со вчерашней сборкой. + - task: front - go build ./... - go vet ./... - | @@ -122,6 +125,79 @@ tasks: fi done + front: + desc: 'Приложение: зависимости, проверки, сборка' + vars: + # Образ берётся из Dockerfile: там он объявлен ступенью сборки. Второй дом + # версии сборочного окружения разошёлся бы с первым молча, а своего шага + # сверки у него, в отличие от версий Go, нет. + NODE_IMAGE: + sh: grep -oP '^FROM \K\S*node:\S*' Dockerfile | head -1 + # Кэш установщика лежит вне дерева проекта: внутри контейнера он не пережил + # бы прогон, и каждый набор проверок тянул бы зависимости заново. + NPM_CACHE: '{{.NPM_CACHE | default "/tmp/transcriber-npm-cache"}}' + cmds: + # Node на машину не ставится — он зовётся контейнером, тем же образом, + # каким собирается ступень образа. Требованием к машине остаётся docker. + - | + set -eu + + if ! command -v docker >/dev/null 2>&1; then + echo "docker не найден в PATH" + echo "приложение собирается контейнером: https://docs.docker.com/engine/install/" + exit 3 + fi + + if [ -z "{{.NODE_IMAGE}}" ]; then + echo "в Dockerfile не нашлось ступени с образом node" + exit 3 + fi + + mkdir -p "{{.NPM_CACHE}}" + + run() { + docker run --rm \ + -u "$(id -u):$(id -g)" \ + -e npm_config_cache=/npmcache \ + -v "{{.NPM_CACHE}}:/npmcache" \ + -v "$PWD/web:/web" \ + -w /web \ + "{{.NODE_IMAGE}}" \ + sh -c "$1" + } + + # Зависимости ставятся из файла замка командой, которая его не правит: + # иначе набор проверок пачкал бы рабочее дерево, а собранное им + # расходилось бы с собранным в образе. + # + # Сперва — установка из кэша, без единого обращения наружу: кэш лежит + # вне дерева проекта и переживает прогоны, поэтому обычный случай сети + # не требует вовсе. + if ! run 'npm ci --offline' >/dev/null 2>&1; then + # Кэша не хватило — значит нужна сеть, и её наличие проверяется одним + # коротким обращением. Без этой проверки установщик уходит в повторы с + # нарастающей паузой и **висит на каждом пакете**: гейт, который висит, + # хуже красного — он не даёт ни исхода, ни причины. + if ! run 'npm ping --fetch-timeout=15000 --fetch-retries=0' >/dev/null 2>&1; then + echo "реестр пакетов недоступен" + echo "шагу нужна сеть: он ставит зависимости приложения и тянет образ" + exit 3 + fi + + # Сеть здесь уже заведомо есть — проба реестра прошла. Значит всякий + # отказ установки это отказ проекта: замок разошёлся с package.json, + # пакет снят из реестра, сломался его postinstall. По словарю кодов + # это дрейф, а не окружение: код 3 отправил бы человека чинить docker + # и сеть вместо `git diff web/package-lock.json`. + if ! run 'npm ci'; then + echo "зависимости приложения не установились, а реестр доступен" + echo "смотри расхождение web/package-lock.json с web/package.json" + exit 1 + fi + fi + + run 'npm run check && npm run test && npm run build' + shell: desc: 'shellcheck на скрипты оболочки' cmds: diff --git a/docs/adr/ADR-2026-08-15-node-in-container-not-on-machine.md b/docs/adr/ADR-2026-08-15-node-in-container-not-on-machine.md new file mode 100644 index 0000000..292c381 --- /dev/null +++ b/docs/adr/ADR-2026-08-15-node-in-container-not-on-machine.md @@ -0,0 +1,62 @@ +# Node зовётся контейнером, а не ставится на машину разработчика + +- **Дата:** 2026-08-15 +- **Источник:** [../../openspec/changes/archive/2026-08-15-spa-skeleton/design.md](../../openspec/changes/archive/2026-08-15-spa-skeleton/design.md), + раздел «Node не ставится на машину, а зовётся контейнером» + +## Решение + +Шаг сборки приложения гоняет установщик пакетов и сборщик **внутри контейнера**, +а не вызывает их из `PATH`: + +> Требованием к машине разработчика становится docker, которым и так собирается +> образ, — второго устанавливаемого окружения сверх `ffmpeg` не появляется +> вовсе. + +Образ сборочного окружения берётся из ступени `Dockerfile`, а не объявляется +вторым числом в `Taskfile.yml`. + +## Почему + +Довод в дизайне назван прямо: + +> Так снимается расхождение, которое иначе завелось бы молча: версия Node на +> машине разработчика и версия в образе — два разных числа, и собранное ими +> приложение различается ровно тогда, когда различаются они. + +Отвергнуты два очевидных подхода, и оба с названной ценой. **Поставить Node на +машину** — вводит второе устанавливаемое окружение и разъезжается с версией в +образе. **Дать выбор — контейнер или локальный Node** — это второй способ делать +одно и то же, и собранное ими различалось бы в зависимости от того, у кого что +стоит. + +## Почему это ADR + +Запись проходит триггер **намеренным отказом** от очевидного подхода: поставить +Node на машину — ровно то, что делают по умолчанию, и отказ от этого надо +объяснить один раз, а не на каждом вопросе «почему у нас нельзя просто +`npm run build`». + +## Что это меняет в прежнем решении + +[ADR-2026-08-11-spa-on-vue](ADR-2026-08-11-spa-on-vue.md) записал последствием, +что «машина разработчика получает второе требуемое окружение сверх `ffmpeg`», и +подразумевал под ним Node. Окружением оказался **docker**. Сам выбор фреймворка и +наличие шага сборки это не пересматривает, поэтому статуса «заменено на» у той +записи нет: заменена не она, а толкование одного её последствия. + +## Последствия + +- `+` версия сборочного окружения живёт **одним** местом — ступенью + `Dockerfile`, — и своего шага сверки ей не нужно. +- `+` собранное в наборе проверок и собранное в образе совпадает, потому что + совпадает окружение сборки, а не потому что «обычно совпадает». +- `−` **набор проверок перестаёт работать без docker**, и отказ этот приходит + кодом окружения. Тем же кодом приходит отказ реестра пакетов: сетезависимых + шагов в наборе становится два вместо одного. +- `−` контейнер ходит под тем же пользователем, что и вызвавший, а кэш + установщика уводится наружу — обе частности обязательны: без них собранное + ляжет от `root`, а зависимости будут тянуться заново каждый прогон. +- `−` **вес и время самой ступени в образе неизвестны**: финальный образ от неё + не растёт (ступень в рабочий слой не копируется), а время сборки решением + владельца от 2026-08-15 не замеряется вовсе. diff --git a/docs/adr/README.md b/docs/adr/README.md index d843099..fe2bf29 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,6 +35,7 @@ | Дата | Запись | Статус | | --- | --- | --- | +| 2026-08-15 | [Node зовётся контейнером, а не ставится на машину разработчика](ADR-2026-08-15-node-in-container-not-on-machine.md) | | | 2026-08-15 | [Приложение живёт своим пространством адресов, а не общим с хранилищем](ADR-2026-08-15-app-namespace.md) | | | 2026-08-15 | [Страница архива задаётся ключом, а не номером](ADR-2026-08-15-cursor-paging.md) | | | 2026-08-15 | [Длительность и размер — снимок принятого колонками записи](ADR-2026-08-15-record-snapshot-columns.md) | | diff --git a/docs/architecture.md b/docs/architecture.md index 7e16b00..e7ddbf7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,6 +45,11 @@ Здесь же обязанность, переехавшая с убранного опроса готовности: причину остановки владелец записи узнаёт карточкой. Задача `json-api-for-spa` 2026-08-15; +- [webapp](../openspec/specs/webapp/spec.md) — **приложение в браузере**: чем + сервис его отдаёт, каким адресом оно открывается, что делает обновление + страницы посреди него и что человек видит, открыв его. Здесь же правило + неизвестного пути — разметка вне корней сервиса, отказ внутри, — срок хранения + ответов и то, что раздача пишет в журнал. Задача `spa-skeleton` 2026-08-15; - [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются открытыми. Задача `oidc-login` @@ -97,6 +102,8 @@ | Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом | | Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций | | Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» | +| Приложение | `web/` | Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в `web/embed/dist` и вшивается в бинарник; в git его нет | +| Раздача приложения | `internal/controller/http`, `webapp.go` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика | Цепочка рубежей — `uploaded` → `normalized` → `submitted` → `transcribed` → `done`; рубеж называет достигнутое, а не предстоящее, и нормирует его @@ -115,6 +122,11 @@ асинхронное: запрос возвращает идентификатор операции, готовность опрашивается через `operation.api.cloud.yandex.net:443`, текст читается потоком. - **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`. +- **Node и его установщик пакетов.** Нужны только сборке приложения и на машину + не ставятся: шаг зовёт их контейнером, а образ берёт из ступени `Dockerfile`. + Требованием к машине разработчика поэтому становится docker. Реестр пакетов — + сетезависимый адрес набора проверок; все такие перечислены в + [CLAUDE.md](../CLAUDE.md), «Гейт». ## Эксплуатация @@ -187,6 +199,7 @@ | Отображение доменной ошибки в ответ | `internal/controller/http.mapDomainError` — код, машиночитаемый код отказа и сообщение человеку; ветвь по умолчанию определена, новая ветвь заводится добавлением сюда. Отказы, рождённые слоями библиотеки (предел тела, ограничитель частоты, неизвестный путь), к той же форме приводит слой `OneErrorForm`, стоящий снаружи всех прочих | | Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса | | Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём | +| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути и уровень журнала | Единых точек, которых **нет** и которые ожидались бы: идентификаторы генерируются вызовом `uuid.NewString()` по месту. Время из этого перечня ушло @@ -201,8 +214,15 @@ образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой `inv pl -- transcriber` из `pet-project-server`. -Сборка двухступенчатая, финальный слой — alpine с `ca-certificates` и `ffmpeg`, -процесс работает под непривилегированным пользователем `transcriber`. +Сборка трёхступенчатая: приложение, бинарник, рабочий слой. Приложение +собирается первым — вшивание требует готового каталога, — а в рабочий слой Node +не попадает. Финальный слой — alpine с `ca-certificates` и `ffmpeg`, процесс +работает под непривилегированным пользователем `transcriber`. + +**По весу финальный образ от ступени приложения не растёт вовсе:** она отдаёт +следующей только собранное, а сама в рабочий слой не копируется. Вшитое +приложение прибавляет к бинарнику 86 072 байта. Время сборки образа не +замерялось и замеряться не будет — решение владельца от 2026-08-15. ## Открытые вопросы @@ -217,7 +237,10 @@ зависит возвращение убранного входа. Панель администратора при этом Authelia не закрывает: у неё свой пароль суперпользователя. -- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA, +- **Приложение.** Каркас поставлен `spa-skeleton` 2026-08-15: приложение + открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и + списка нет — их делают `upload-and-status-screen` и `records-list-screen`. + Решено делать SPA, устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и сборкой Vite — 2026-08-11, [ADR](adr/ADR-2026-08-11-spa-on-vue.md), сравнение кандидатов в diff --git a/docs/conventions/go-linters.md b/docs/conventions/go-linters.md index d312411..e85c6d9 100644 --- a/docs/conventions/go-linters.md +++ b/docs/conventions/go-linters.md @@ -130,6 +130,9 @@ | Скрипты оболочки | `Taskfile.yml` → шаг `shell` (`shellcheck`), он же на pre-commit | | Форма `Dockerfile` | `Taskfile.yml` → шаг `dockerfile` (`hadolint`), он же на pre-commit | | Одно число версии Go в `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` | `Taskfile.yml` → шаг `go-version` (`scripts/check-go-version.sh`) | +| Форматирование и статический анализ кода приложения | `web/biome.json` → Biome, зовётся шагом `front` командой `npm run check`. Разбирает и однофайловые компоненты; правила — набор `recommended` плюс своя форма (одинарные кавычки, точка с запятой по необходимости) | +| Типы разметки и кода приложения | `vue-tsc`, и он входит в **команду сборки**, а не стоит отдельным шагом: несобираемое приложение и непроверенные типы — один отказ | +| Поведение экранов приложения | `Taskfile.yml` → шаг `front`, юнит-тесты Vue (`npm run test`). Без них требование «приложение показывает вошедшего» не проверял бы никто, а набор проверок оставался бы зелёным на сломанном экране | ### Хранилище, документы, секреты, зависимости diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md index ccde8a9..8304256 100644 --- a/docs/conventions/logging.md +++ b/docs/conventions/logging.md @@ -94,7 +94,7 @@ OpenSpec. - Доменные поля — плоский `snake_case`. - Системные домены — точечная иерархия (по образцу OpenTelemetry): `http.*`, - `ext.*`. + `ext.*`, `webapp.*`. - JSON плоский: все поля на верхнем уровне, без вложенности. | Когда добавляем | Поля | @@ -103,6 +103,8 @@ OpenSpec. | на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` | | на запись об ошибке | `error` | | на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` | +| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт), `http.path_length`. Самого пути в строке нет: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. Вместо пути в `http.route` стоит `<приложение>` | +| на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» | Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум. diff --git a/docs/conventions/web-ui.md b/docs/conventions/web-ui.md index c509985..7eda56f 100644 --- a/docs/conventions/web-ui.md +++ b/docs/conventions/web-ui.md @@ -10,17 +10,19 @@ описывала htmx с прямым запретом на шаг сборки и реактивные фреймворки; она снята целиком вместе со сменой решения на SPA 2026-08-10. -**Кода приложения ещё нет.** Правила ниже выведены из выбора и из замера на -пробном экране, а не из написанного кода: первым их применяет и проверяет -`spa-skeleton`. Место, где правило разойдётся с тем, что окажется удобным, — -повод править эту запись, а не обходить её молча. +Правила ниже применены каркасом приложения (`spa-skeleton`, 2026-08-15): до него +они были выведены из выбора и из замера на пробном экране, а не из написанного +кода. Место, где правило разойдётся с тем, что окажется удобным, — повод править +эту запись, а не обходить её молча. Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок наружу — [errors.md](errors.md). **Механизировано:** типы разметки и кода проверяет `vue-tsc`, и он входит в -команду сборки, а не стоит отдельным шагом. Правил линтера для кода приложения -пока нет. +команду сборки, а не стоит отдельным шагом. Форматирование и статический анализ +держит **Biome**, поведение экранов — **юнит-тесты Vue**; оба шага входят в набор +проверок наравне со сборкой. Инструменты зовутся контейнером, а не из `PATH`: +требованием к машине разработчика остаётся docker, а не установленный Node. ## Что решено про само приложение @@ -51,7 +53,14 @@ делать то же самое. - **Собранная статика неизменяема и адресуется хешем в имени.** Имена придумывает Vite, руками их не задаём: от этого зависит обновление установленного - приложения. + приложения. Сервис на это правило опирается, но проверить его не может — имён + он не выбирает, — поэтому дом правила здесь, а не в спеке: долгий срок + хранения он ставит **по каталогу** сборщика, и файл, положенный туда без + отпечатка в имени, останется в хранилище браузера навсегда. +- **Зависимости ставятся из файла замка командой, которая его не правит.** Иначе + набор проверок пачкает рабочее дерево, обновление зависимости приезжает в + коммит без чьего-либо решения, а собранное в наборе проверок перестаёт + совпадать с собранным в образе. - **Шаг сборки входит в `task gate` и в сборку образа.** Красная сборка статики роняет гейт наравне с `go build`. @@ -70,7 +79,14 @@ `/_/` у панели, — плюс `/health` и `/metrics` отдельными адресами. Приложение уехало из общего `/api/` решением владельца 2026-08-15: пространство принадлежит хранилищу, и обновление библиотеки вправе занять там имя рядом с - нашим. + нашим. Перечень корней сервису не описывают, а из него **порождают** + регистрацию маршрутов: описанный порознь, он разошёлся бы с ними молча. +- **Несовпавший ресурс разметкой не подменяется.** Путь под каталогом сборщика, + которому не нашлось файла, отвечает `404`. Правило — вторая половина + предыдущего: разметка прежней сборки называет ресурсы прежней сборки, и + подменить их разметкой значит ответить `200` на то, чего нет. Браузер отвергнет + такой ответ по типу содержимого, человек увидит пустой экран, а в кодах + ответов сервиса не останется ничего. - **Экран не знает, как он открыт.** Данные экран берёт по своему адресу, а не получает от предыдущего: приложение открывают по ссылке и обновляют страницу посередине. @@ -107,6 +123,13 @@ ## Что не решено +- **Инструмент статического анализа проверен наполовину.** Biome взят решением + владельца 2026-08-15 и разбирает однофайловые компоненты; замены он потребует, + если перестанет их держать. Тогда это отдельное решение, а не подстановка по + ходу. +- **Проверка типов держится на пятой линии TypeScript.** С седьмой `vue-tsc` + не работает: новый компилятор не отдаёт точку входа, которую тот зовёт. + Проверено прогоном 2026-08-15. - **Набор компонентов и стили.** Своя разметка или готовый набор — не решено, а готовый способен удвоить собранный файл ([research/spa-framework.md](../research/spa-framework.md), «Чего разведка не diff --git a/docs/database.md b/docs/database.md index 669f623..23e63a4 100644 --- a/docs/database.md +++ b/docs/database.md @@ -328,6 +328,7 @@ capability, и третий смысл развёл бы одно слово п | Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт | | Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла | | Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем | +| Срок хранения ресурса приложения | 1 год | `controller/http.assetMaxAgeSeconds` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново | | Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих | | Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем | | Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было | diff --git a/docs/passport.md b/docs/passport.md index 4cb2a5e..18af77c 100644 --- a/docs/passport.md +++ b/docs/passport.md @@ -82,7 +82,9 @@ Telegram. ## Типовые сценарии -Первые два — основные, и сегодня не работает ни один: приложения нет. +Первые два — основные, и сегодня не работает ни один. Приложение с 2026-08-15 +есть, но экранов у него пока нет: оно открывается и показывает вошедшего, а +загрузку и список заводят `upload-and-status-screen` и `records-list-screen`. 1. **Семейный архив.** Человек открывает приложение на телефоне, выбирает до десяти записей разом — диктофонные дорожки и видео, — и закрывает его. diff --git a/docs/research/README.md b/docs/research/README.md index 267a5d8..bf6f3cd 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -22,6 +22,7 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт | Дата | Запись | О чём | | --- | --- | --- | +| 2026-08-15 | [Раздача приложения: что делают за нас библиотека и сборщик](webapp-serving.md) | Раскодированный путь у маршрутизатора, второй журнал у PocketBase, нулевое время у вшитого файла, зависание установщика без сети | | 2026-08-13 | [Разбор TOML: какое семейство отказов несёт значения из файла](toml-decode-errors.md) | Значения только в `ParseError.Message`, врущее поле `Line`, отказ значением в BurntSushi/toml v1.5.0 | | 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки | | 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 | diff --git a/docs/research/webapp-serving.md b/docs/research/webapp-serving.md new file mode 100644 index 0000000..b7dffc5 --- /dev/null +++ b/docs/research/webapp-serving.md @@ -0,0 +1,69 @@ +# Раздача приложения: что делают за нас библиотека и сборщик + +Отвечает на вопросы, возникшие по ходу задачи `spa-skeleton`, — какие свойства +раздачи приходят не из нашего кода, а из стандартной библиотеки, из PocketBase и +из инструментов приложения. Наблюдения понадобились потому, что ревью нашло три +места, где записанное намерение расходилось с тем, что на деле делает чужой код. + +## Как снималось + +Прогонами на живом бинарнике (свой конфиг с выдуманными ключами, свой каталог +данных вне репозитория) и чтением исходников зависимостей, зафиксированных в +`go.mod`: `github.com/pocketbase/pocketbase` версии **v0.39.10** и стандартной +библиотеки Go. Отдельно — прогоны установщика и сборщика приложения в контейнере. +Числа ниже сняты 2026-08-15 на этом прогоне, а не взяты из чужих записок. + +## Что выяснилось + +- **Маршрутизатор стандартной библиотеки сравнивает сегменты пути после + раскодирования.** Поэтому `/%5f/` попадает туда же, куда `/_/`, а `/%68ealth` + — туда же, куда `/health`: ответы совпадают байт в байт. Исходная форма + остаётся в `URL.RawPath`, и решение, принимаемое **вне** сервиса по сырому пути + — правилом обратного прокси, — такой формы не видит. Цена записана в + [security.md](../security.md), «Периметр»: барьер перед панелью владельца + обходится подменой одного знака. + +- **PocketBase пишет каждый запрос в свою таблицу журнала**, а не только в вывод + контейнера: слой `activityLogger` подключён ко всем маршрутам и кладёт путь + целиком + (до 3000 знаков), адрес отправителя, источник перехода и клиент. Умолчания — + хранить пять суток, адрес записывать. Готовая раздача статики + (`apis.Static`) первой же строкой ставит признак «успех не записывать»; своя + раздача этого признака не наследует, и его надо ставить руками. Отсюда правило + в [review.md](../review.md): журналов **два**, и говорить надо про оба. + +- **Вшитая файловая система не несёт времени правки.** `embed.FS` отдаёт нулевое + время у любого файла, поэтому отдача файла стандартной библиотекой никогда не + отвечает подтверждением «не менялось» — всякая проверка приходит полным телом. + Заголовок, обещающий дешёвую проверку, без метки ответа обещает то, чего код не + делает. + +- **Сборщик приложения чистит выходной каталог перед каждой сборкой.** Метка, + положенная рядом с собранным ради того, чтобы каталог существовал в git, + уезжает первым же прогоном. Живёт она только этажом выше выходного каталога. + +- **Проверка типов однофайловых компонентов не работает с седьмой линией + TypeScript.** `vue-tsc` версии 3.3.10 зовёт у компилятора точку входа, которой + новый компилятор не отдаёт, и сборка падает на этапе проверки типов. Рабочая + пара — пятая линия TypeScript. + +- **Установщик пакетов без сети не отказывает, а виснет.** Он уходит в повторы с + нарастающей паузой **на каждом пакете**, и набор проверок вместо кода отказа + просто стоит. Пределы у отдельных обращений положения не спасают: их сумма и + даёт зависание. Помогает короткое обращение-проба перед установкой. + +- **Вес приложения в бинарнике равен весу собранного.** Замер: две сборки, с + собранным приложением и с пустым каталогом, разница — 86 072 байта, то есть + ровно `index.html` плюс единственный ресурс. Собранное приложение на четыре + экрана в разведке `spa-framework` весило того же порядка. + +## Чего эта записка не узнала + +- **Во что ступень сборки обходится образу по времени.** Прогон до конца не + доходит: из контейнеров этой машины нет исходящей сети при рабочем разрешении + имён. По весу вопрос закрыт иначе — ступень в рабочий слой не копируется, и + финальный образ от неё не растёт вовсе. +- **Как поведёт себя раздача под настоящим потоком.** Ограничителя частоты на + корневом маршруте нет, а профиля нагрузки у проекта нет тоже. +- **Что делает настоящий браузер** с этими заголовками: проверено кодами ответов + и заголовками, а не браузером. diff --git a/docs/review.md b/docs/review.md index cb96cf6..83ca04d 100644 --- a/docs/review.md +++ b/docs/review.md @@ -58,6 +58,26 @@ - переводит доменную ошибку в свой ответ, а не отдаёт сырой текст; - закрывает то, что открыл, на всех ветках выхода. +**Раздача собранного приложения и шаг его сборки** (`controller/http/webapp.go`, +шаг `front`): + +- путь, принадлежащий корню сервиса, разметку не отдаёт никогда, а перечень + корней порождает регистрацию маршрутов, а не описывает её; +- несовпавший ресурс под каталогом сборщика отвечает `404`, а не разметкой с + кодом `200`; +- раздача ставит долгий неотзываемый срок хранения **только** файлу из каталога + сборщика: отозвать его у браузера сервису нечем; +- отсутствие сборки громкое — код ответа, страница и строка журнала; «сборки + нет» отличается от «файла нет»; +- вшито то, что собрано этим прогоном, а не то, что осталось от прошлого; +- шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он + **отказывает, а не висит**; +- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала — и + журналов **два**: свой, в вывод контейнера, и журнал хранилища, куда + библиотека кладёт путь целиком вместе с адресом отправителя. Второй молчит + только на успехе и только потому, что признак отказа от записи поставлен + руками: готовая раздача статики ставит его сама, своя — нет. + **Клиент внешнего сервиса** (`adapter/recognizer/yandex`): - имеет таймаут и не виснет, когда внешний сервис не отвечает; @@ -292,6 +312,26 @@ API и имя не откатываются обратной правкой по истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не оракул, и выдумывать оракул задним числом нельзя. +## 2026-08-15 — своя раздача статики потеряла отказ от записи успеха [пойман ревью] + +- **Где:** `internal/controller/http/webapp.go`, регистрация корневого маршрута; + задача `spa-skeleton` +- **Симптом:** каждый успешный ответ разметкой и ресурсом клал в журнал + хранилища выбранный анонимом путь вместе с его адресом и держал строку пять + суток. При этом строка `docs/review.md`, добавленная той же задачей, + утверждала, что путь анонима в журнал не идёт +- **Причина:** готовая раздача статики библиотеки первой же строкой ставит + признак «успех не записывать». Своя написана мимо неё — и не зря, подстановка + разметки у готовой не отличает отсутствующий ресурс от неизвестного пути, — но + признак при переписывании не перенесён. + Журналов у сервиса два, а сделанная защита закрыла один +- **Почему не поймали раньше:** свойство было записано **утверждением**, а + проверялось только против журнала контейнера. Второй журнал живёт в базе, и ни + один тест туда не смотрел +- **Что меняем:** утверждение о журнале называет оба журнала поимённо. Оракул — + чтение таблицы журнала после прогона: три успешных запроса не оставляют строк, + два отказа оставляют + ## 2026-08-15 — единая форма отказа не покрывала то, что рождается не в обработчике [пойман ревью] - **Где:** `internal/controller/http/errors.go`, слой `OneErrorForm`; задача diff --git a/docs/security.md b/docs/security.md index 766362f..24ee416 100644 --- a/docs/security.md +++ b/docs/security.md @@ -4,9 +4,19 @@ **Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через обратный прокси, а приём записи, чтение её карточки и текста и файл записи требуют входа -через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Открыты -без входа только проба здоровья и метрики. Находки строятся против этого — -сегодняшнего — периметра. +через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Без +входа открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton` — +**само приложение**: его разметка и её ресурсы, а вместе с ними всякий путь, не +принадлежащий ни одному корню сервиса. Иначе не вошедший не дошёл бы до входа +вовсе: закрытая сессией разметка отдала бы ему отказ вместо экрана. Данных +открытость не касается — всякий адрес под корнем приложения сессии по-прежнему +требует. Находки строятся против этого — сегодняшнего — периметра. + +**Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения**, +а каталог его лежит в `.gitignore` и не судится ничем: всё, что окажется там у +собирающего, уезжает в бинарник и раздаётся. Под каталогом ресурсов оно ещё и +отдаётся с годовым сроком хранения и пометкой «неизменяемо» — отозвать выданное +браузеру сервису нечем. Целевой периметр добавляет к нему отдельный вход для программ по личным токенам и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и @@ -37,6 +47,17 @@ Telegram — связи чата с учётной записью сервис администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а она вне модели («Что вне модели», строка про контур). +**И этот барьер обходится подменой одного знака.** Маршрутизатор сравнивает +сегменты пути **после** раскодирования, поэтому `/%5f/` попадает в ту же группу, +что и `/_/`, а правило прокси написано на литерал и такой формы не видит. +Проверено прогоном 2026-08-15 ревью задачи `spa-skeleton`: обе формы отвечают +байт в байт, и весь клиент панели грузится анониму. Вход в приложение при этом +не обходится — `/%61pp/me` отвечает `401`. Дефект старше задачи, которая его +нашла, и **сегодня не закрыт**: лечение — приведение пути к канонической форме на +стороне сервиса, и глухая проверка тут не годится, потому что сломает скачивание +файлов с пробелами и не-латиницей в имени. Половину пути проверить нечем: правило +прокси живёт в `pet-project-server`, вне этого репозитория. + **Четвёртый сдвиг — секрет клиента поселился в базе.** Задача `oidc-login` 2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в настройки коллекции пользователей, приводя их к конфигу при каждом подъёме @@ -186,6 +207,11 @@ Storage, оттуда его читает SpeechKit. Третий путь — её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного прокси — работа выкладки, и сервис на неё не полагается: содержимого записей эти адреса не несут. +- **Приложение** — его разметка и ресурсы открыты без сессии, и ограничителя + частоты на них нет: правило заведено под корень приложения, а раздача стоит + вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы + для всех и собраны до всякого запроса. По ответу нельзя узнать, вошёл ли + кто-то, — вошедшему и не вошедшему отдаётся одно и то же. Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла есть владелец. Знание идентификатора задачи правом её читать больше не является diff --git a/internal/controller/http/auth.go b/internal/controller/http/auth.go index 12e0744..e296530 100644 --- a/internal/controller/http/auth.go +++ b/internal/controller/http/auth.go @@ -93,8 +93,14 @@ func (h *AuthHandler) Register(r *router.Router[*core.RequestEvent]) { // своим, и перехватить его можно только слоем. r.Bind(BlockSessionRefresh()) - r.GET("/auth/login", h.Login) - r.GET("/auth/callback", h.Callback) + // Адреса вешаются группой корня, а не литералами: корень объявлен единой + // точкой адресного пространства, и порознь записанный он разошёлся бы с ней + // молча — раздача приложения начала бы отдавать разметку на возврат от + // провайдера, без единой ошибки в журнале. + auth := r.Group(AuthRoot) + + auth.GET("/login", h.Login) + auth.GET("/callback", h.Callback) // Выход берёт POST намеренно: по GET его срабатывание уносится переходом по // чужой ссылке. // @@ -102,7 +108,7 @@ func (h *AuthHandler) Register(r *router.Router[*core.RequestEvent]) { // обесценивать, — он убрал бы куку и отчитался успехом, оставив унесённое // значение годным. Требования сессии при этом нет: выход без неё убирает // куку и молчит. - r.POST("/auth/logout", h.Logout).Bind(SessionFromCookie()) + auth.POST("/logout", h.Logout).Bind(SessionFromCookie()) } // Login уводит человека к провайдеру, запомнив состояние и проверочный код diff --git a/internal/controller/http/webapp.go b/internal/controller/http/webapp.go new file mode 100644 index 0000000..a60eeec --- /dev/null +++ b/internal/controller/http/webapp.go @@ -0,0 +1,339 @@ +package http + +import ( + "crypto/sha256" + "encoding/hex" + "io/fs" + "log/slog" + "net/http" + "path" + "strconv" + "strings" + + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/router" +) + +// Корни адресного пространства и отдельные адреса наблюдения. +// +// `/api` и `/_` принадлежат хранилищу: первый — его наборам адресов, второй — +// панели владельца. Поменять их нельзя, это литералы библиотеки. +const ( + StorageRoot = "/api" + PanelRoot = "/_" + AuthRoot = "/auth" + + HealthPath = "/health" + MetricsPath = "/metrics" +) + +// assetsDir — каталог, который наполняет сборщик приложения. +// +// Имена в нём строит он же и несёт в них отпечаток содержимого, поэтому +// изменившийся ресурс приезжает под новым именем. Отсюда два правила разом: +// такой ресурс отдаётся с долгим сроком хранения, а не совпавший с файлом путь +// под этим каталогом отвечает отсутствием, а не разметкой. +const assetsDir = "assets" + +// Срок хранения ресурса сборщика — год. +const assetMaxAgeSeconds = 31536000 + +// Mount — часть адресного пространства, принадлежащая сервису. +// +// Перечень этих частей — **единственное** описание того, что сервису +// принадлежит, и он не описывает регистрацию, а порождает её: корень, +// заведённый мимо перечня, не получит обработчика вовсе. Прежде такой перечень +// был бы вторым описанием таблицы маршрутов, которую ведут три места, и корень, +// забытый в нём, молча отдавал бы разметку там, где программа ждёт отказ +// контракта. +type Mount struct { + // Path — корень либо точный адрес. + Path string + + // Exact — путь является точным адресом, а не корнем: `/health` накрывает + // только сам себя, а `/app` — всё, что под ним. + Exact bool + + // Bind вешает обработчики этой части. Пусто у того, что вешает библиотека. + Bind func(r *router.Router[*core.RequestEvent]) +} + +// Covers говорит, принадлежит ли путь этой части адресного пространства. +// +// Условий два, и оба обязательны: точное совпадение либо префикс **вместе с +// косой чертой**. По одному префиксу корню `/app` достался бы посторонний +// `/apple`; по одному префиксу с косой чертой голый `/api` не достался бы +// никому и уехал бы разметкой приложения. +func (m Mount) Covers(requestPath string) bool { + if m.Exact { + return requestPath == m.Path + } + + return requestPath == m.Path || strings.HasPrefix(requestPath, m.Path+"/") +} + +// ServiceMounts перечисляет адресное пространство сервиса целиком. +func ServiceMounts( + appHandler *AppHandler, + authHandler *AuthHandler, + metricsHandler http.Handler, +) []Mount { + return []Mount{ + {Path: StorageRoot}, + {Path: PanelRoot}, + {Path: AppRoot, Bind: appHandler.Register}, + {Path: AuthRoot, Bind: authHandler.Register}, + {Path: HealthPath, Exact: true, Bind: bindHealth}, + {Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)}, + } +} + +// RegisterServiceRoutes вешает всё, что сервис вешает сам. +func RegisterServiceRoutes(r *router.Router[*core.RequestEvent], mounts []Mount) { + for _, mount := range mounts { + if mount.Bind != nil { + mount.Bind(r) + } + } +} + +// IsServiceAddress говорит, принадлежит ли путь сервису хоть какой-то частью. +func IsServiceAddress(mounts []Mount, requestPath string) bool { + for _, mount := range mounts { + if mount.Covers(requestPath) { + return true + } + } + + return false +} + +// IsObservationAddress говорит, что путь — адрес наблюдения. +// +// Опрос здоровья и метрик идёт постоянно и полезного не несёт, поэтому уровень +// журнала у него свой. Перечень при этом тот же самый: второе перечисление этих +// адресов разошлось бы с первым молча. +func IsObservationAddress(mounts []Mount, requestPath string) bool { + for _, mount := range mounts { + if mount.Exact && mount.Covers(requestPath) { + return true + } + } + + return false +} + +func bindHealth(r *router.Router[*core.RequestEvent]) { + r.GET(HealthPath, func(e *core.RequestEvent) error { + return e.JSON(http.StatusOK, map[string]string{ + "status": "ok", + "message": "Transcriber service is running", + }) + }) +} + +func bindMetrics(handler http.Handler) func(r *router.Router[*core.RequestEvent]) { + return func(r *router.Router[*core.RequestEvent]) { + r.GET(MetricsPath, func(e *core.RequestEvent) error { + handler.ServeHTTP(e.Response, e.Request) + return nil + }) + } +} + +// notBuiltPage — что видит человек у бинарника без собранного приложения. +// +// Состояние возможно только у собранного мимо набора проверок: и набор +// проверок, и сборка образа собирают приложение раньше бинарника. +const notBuiltPage = ` + +Приложение не собрано +

Приложение не собрано

+

Бинарник собран без приложения. Соберите его и соберите бинарник заново.

+ +` + +// Исход раздачи для журнала. Сам путь в журнал не идёт: множеством его значений +// распоряжается спрашивающий, а до появления раздачи такой путь ловил отказ +// маршрутизатора и успешным ответом не был. +const ( + OutcomeMarkup = "markup" + OutcomeAsset = "asset" + OutcomeFailure = "failure" +) + +// journalOutcomeKey — под каким ключом раздача оставляет исход слою журнала. +const journalOutcomeKey = "transcriberWebappOutcome" + +// WebappOutcome отдаёт исход, оставленный раздачей, либо пустую строку, если +// запрос до неё не дошёл. +func WebappOutcome(e *core.RequestEvent) string { + outcome, ok := e.Get(journalOutcomeKey).(string) + if !ok { + return "" + } + + return outcome +} + +// WebappHandler раздаёт собранное приложение и держит правило неизвестного пути. +type WebappHandler struct { + dist fs.FS + built bool + mounts []Mount + fingerprint string + logger *slog.Logger +} + +// NewWebappHandler собирает раздачу приложения. +// +// Файловая система приходит параметром, а не тянется пакетом: так тест +// подставляет свою сборку, не собирая приложение. +func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logger) *WebappHandler { + if logger == nil { + logger = slog.Default() + } + + if !built { + logger.Warn("Webapp is not built, service will answer with a placeholder page") + return &WebappHandler{dist: dist, built: built, mounts: mounts, logger: logger} + } + + // Отпечаток вшитой сборки — единственное, чем «не та сборка» отличается от + // «той». Вне набора проверок порядок шагов ничем не задан: `go run .` + // вшивает то, что лежит с прошлого раза, а приложение при этом открывается и + // ведёт себя как прежняя версия. Он же уходит меткой ответа с разметкой. + fingerprint := buildFingerprint(dist) + logger.Info("Webapp is embedded", "webapp.build", fingerprint) + + return &WebappHandler{ + dist: dist, + built: built, + mounts: mounts, + fingerprint: fingerprint, + logger: logger, + } +} + +// buildFingerprint — короткий отпечаток разметки вшитой сборки. +// +// Считается по самой разметке, а не по файлу, который положил бы сборщик: файл +// пришлось бы заводить настройкой сборки, а разметка меняется вместе с именами +// ресурсов, то есть при всякой пересборке с изменениями. +func buildFingerprint(dist fs.FS) string { + markup, err := fs.ReadFile(dist, "index.html") + if err != nil { + return "unknown" + } + + sum := sha256.Sum256(markup) + + return hex.EncodeToString(sum[:])[:12] +} + +// Register вешает раздачу на корень. +// +// Маршрут стоит ровно на `/`, а не на `/{path...}`: сборка маршрутов сама +// вешает на `/` отказ «ничего не совпало», если такого маршрута ещё нет, и +// второй всепокрывающий образец рядом с ним спорил бы с ним за путь. +func (h *WebappHandler) Register(r *router.Router[*core.RequestEvent]) { + // Успешная раздача в журнал хранилища не пишется. + // + // Журнал хранилища — второй, помимо журнала контейнера, и в него библиотека + // кладёт путь запроса целиком вместе с адресом отправителя, храня строки + // пять суток. Путь здесь выбирает спрашивающий, и без этого отказа всякое + // открытие приложения оставляло бы там его текст и его адрес. Готовая + // раздача статики ставит тот же признак первой строкой; своя написана мимо + // неё, и признак перенесён руками. + // + // Отказы записываются по-прежнему: признак снимает только успех. + r.Any("/", h.Serve).Bind(apis.SkipSuccessActivityLog()) +} + +// Serve отдаёт приложение либо отказ — по порядку, объявленному дизайном. +func (h *WebappHandler) Serve(e *core.RequestEvent) error { + requestPath := e.Request.URL.Path + + // Путь принадлежит сервису — значит под этим корнем такого адреса просто + // нет. Отказ уходит формой библиотеки, то есть тем же, чем этот корень + // отвечает и сегодня: своя форма здесь была бы второй. + if IsServiceAddress(h.mounts, requestPath) { + e.Set(journalOutcomeKey, OutcomeFailure) + return router.NewNotFoundError("", nil) + } + + if e.Request.Method != http.MethodGet && e.Request.Method != http.MethodHead { + e.Set(journalOutcomeKey, OutcomeFailure) + return router.NewApiError(http.StatusMethodNotAllowed, "", nil) + } + + if !h.built { + e.Set(journalOutcomeKey, OutcomeFailure) + return e.HTML(http.StatusServiceUnavailable, notBuiltPage) + } + + name := strings.TrimPrefix(path.Clean(requestPath), "/") + if name == "" || name == "." { + return h.serveIndex(e) + } + + if info, err := fs.Stat(h.dist, name); err == nil && !info.IsDir() { + e.Set(journalOutcomeKey, OutcomeAsset) + h.setCacheHeader(e, name) + return e.FileFS(h.dist, name) + } + + // Разметка прежней сборки называет ресурсы прежней сборки. Подменить их + // разметкой значит ответить `200` на то, чего нет: браузер отвергнет её по + // типу содержимого, человек увидит пустой экран, а в кодах ответов сервиса + // не останется ничего. + if underAssets(name) { + e.Set(journalOutcomeKey, OutcomeFailure) + return router.NewNotFoundError("", nil) + } + + return h.serveIndex(e) +} + +func (h *WebappHandler) serveIndex(e *core.RequestEvent) error { + e.Set(journalOutcomeKey, OutcomeMarkup) + h.setCacheHeader(e, "index.html") + + // Отпечаток идёт метке ответа: `no-cache` обещает дешёвую проверку — + // «спроси заново и получи подтверждение», — а вшитый файл не несёт времени + // правки вовсе, и без метки браузеру каждый раз отдаётся полное тело вместо + // подтверждения. Отпечаток уже посчитан при подъёме, второго счёта не надо. + if h.fingerprint != "" { + e.Response.Header().Set("ETag", `"`+h.fingerprint+`"`) + } + + return e.FileFS(h.dist, "index.html") +} + +// setCacheHeader назначает срок хранения по каталогу, а не по виду файла. +// +// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами — +// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не +// отзывается со стороны сервиса ничем. Запросов он больше не увидит. +// underAssets говорит, ведёт ли путь в каталог сборщика. +// +// Условий два, и оба обязательны — та же пара, что у принадлежности корню: сам +// каталог и всё, что под ним. По одному префиксу с косой чертой голый `assets` +// не попал бы никуда и уехал бы разметкой с кодом `200` — то есть ровно тем +// ответом, который правило запрещает. +func underAssets(name string) bool { + return name == assetsDir || strings.HasPrefix(name, assetsDir+"/") +} + +func (h *WebappHandler) setCacheHeader(e *core.RequestEvent, name string) { + if underAssets(name) { + e.Response.Header().Set( + "Cache-Control", + "public, max-age="+strconv.Itoa(assetMaxAgeSeconds)+", immutable", + ) + return + } + + e.Response.Header().Set("Cache-Control", "no-cache") +} diff --git a/internal/controller/http/webapp_test.go b/internal/controller/http/webapp_test.go new file mode 100644 index 0000000..60af3e8 --- /dev/null +++ b/internal/controller/http/webapp_test.go @@ -0,0 +1,253 @@ +package http + +import ( + "io/fs" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + "time" + + "github.com/pocketbase/pocketbase/apis" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" + pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" + "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/service" +) + +const testMarkup = `приложение` + +// builtDist — собранное приложение, каким его видит раздача. +func builtDist() fs.FS { + return fstest.MapFS{ + "index.html": {Data: []byte(testMarkup)}, + "assets/index-abc123.js": {Data: []byte("console.log(1)")}, + "favicon.ico": {Data: []byte("значок")}, + } +} + +// webappEnv — окружение проверок раздачи. Роутер собирается тем же способом, +// что и боевой: маршруты порождает перечень, а не перечисление в проверке. +type webappEnv struct { + mux http.Handler + journal *journalBuffer + session string +} + +func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv { + t.Helper() + + app := newTestStorage(t) + pbrepo.BindPanelRules(app) + + recordRepo := pbrepo.NewAudioRecordRepository(app) + textRepo := pbrepo.NewTextRepository(app) + structureRepo := pbrepo.NewStructureRepository(app) + + journal := &journalBuffer{} + logger := slog.New(slog.NewTextHandler(journal, nil)) + + trsService := service.NewTranscribeService( + service.Repositories{ + Records: recordRepo, + Files: pbrepo.NewFileRepository(app), + Texts: textRepo, + Structures: structureRepo, + Recognitions: pbrepo.NewRecognitionRepository(app), + Events: pbrepo.NewRecordEventRepository(app), + }, + readableMetaViewer(), + &stubConverter{}, + &recognizer.MemoryAudioRecognizer{}, + entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour}, + logger, + ) + + appHandler := NewAppHandler(recordRepo, textRepo, structureRepo, trsService, logger) + authHandler := NewAuthHandler(app, AuthHandlerConfig{ + AuthURL: "https://provider.example/authorize", + RedirectURL: "https://service.example/auth/callback", + ClientID: "client", + }, logger) + + mounts := ServiceMounts(appHandler, authHandler, http.NotFoundHandler()) + + r, err := apis.NewRouter(app) + require.NoError(t, err) + + RegisterServiceRoutes(r, mounts) + NewWebappHandler(dist, built, mounts, logger).Register(r) + + mux, err := r.BuildMux() + require.NoError(t, err) + + _, session := newTestAccount(t, app) + + return &webappEnv{mux: mux, journal: journal, session: session} +} + +func (e *webappEnv) get(path string) *httptest.ResponseRecorder { + return e.do(http.MethodGet, path, false) +} + +func (e *webappEnv) do(method, path string, withSession bool) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, nil) + if withSession { + req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: e.session}) + } + + rec := httptest.NewRecorder() + e.mux.ServeHTTP(rec, req) + + return rec +} + +// Обновление страницы посреди приложения открывает тот же экран: адреса у +// приложения обычные, а не после решётки, и сервер обязан отдать разметку. +func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) { + env := setupWebappEnv(t, builtDist(), true) + + for _, path := range []string{"/", "/records", "/records/abc123def456ghi"} { + res := env.get(path) + + assert.Equal(t, http.StatusOK, res.Code, path) + assert.Contains(t, res.Body.String(), "приложение", path) + } +} + +// Путь внутри корня сервиса в приложение не проваливается никогда: иначе +// программа, ошибшаяся адресом, приняла бы разметку с кодом 200 за ответ. +func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) { + env := setupWebappEnv(t, builtDist(), true) + + for _, path := range []string{"/api/nope", "/_/nope", "/auth/nope"} { + res := env.get(path) + + assert.NotContains(t, res.Body.String(), "приложение", path) + } +} + +// Под корнем приложения отказ уходит его собственной формой, и код отвечает +// причине: без сессии — «предъяви себя», с сессией — «такого адреса нет». +func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) { + env := setupWebappEnv(t, builtDist(), true) + + anonymous := env.do(http.MethodGet, "/app/nope", false) + assert.Equal(t, http.StatusUnauthorized, anonymous.Code) + assert.NotContains(t, anonymous.Body.String(), "приложение") + + signedIn := env.do(http.MethodGet, "/app/nope", true) + assert.Equal(t, http.StatusNotFound, signedIn.Code) + assert.Contains(t, signedIn.Body.String(), CodeNotFound) +} + +// Принадлежность корню — два условия разом. По одному префиксу корню досталось +// бы постороннее имя, по одному префиксу с косой чертой голый корень не достался +// бы никому и уехал бы разметкой. +func TestWebappRootMatchNeedsExactOrSlash(t *testing.T) { + env := setupWebappEnv(t, builtDist(), true) + + bare := env.get(StorageRoot) + assert.NotContains(t, bare.Body.String(), "приложение", "голый корень разметкой не подменяется") + + neighbour := env.get(AppRoot + "le") + assert.Equal(t, http.StatusOK, neighbour.Code) + assert.Contains(t, neighbour.Body.String(), "приложение", "посторонний путь остаётся приложению") +} + +// Разметка прежней сборки называет ресурсы прежней сборки. Подменить их +// разметкой значит ответить 200 на то, чего нет. +func TestWebappMissingAssetAnswersNotFound(t *testing.T) { + env := setupWebappEnv(t, builtDist(), true) + + res := env.get("/assets/index-obsolete.js") + + assert.Equal(t, http.StatusNotFound, res.Code) + assert.NotContains(t, res.Body.String(), "приложение") +} + +// Сам каталог сборщика — тот же случай, что и файл под ним: правило держится +// парой условий, и по одному префиксу с косой чертой голый `assets` уехал бы +// разметкой с кодом `200`. +func TestWebappAssetsDirItselfAnswersNotFound(t *testing.T) { + env := setupWebappEnv(t, builtDist(), true) + + for _, path := range []string{"/assets", "/assets/"} { + res := env.get(path) + + assert.Equal(t, http.StatusNotFound, res.Code, path) + assert.NotContains(t, res.Body.String(), "приложение", path) + } +} + +// Метка ответа даёт `no-cache` его смысл: без неё вшитый файл не несёт времени +// правки, и браузер получает полное тело вместо подтверждения. +func TestWebappMarkupCarriesEntityTag(t *testing.T) { + env := setupWebappEnv(t, builtDist(), true) + + tag := env.get("/").Result().Header.Get("ETag") + + assert.NotEmpty(t, tag) +} + +// Адреса наблюдения приложением не подменяются. +func TestWebappLeavesObservationAddresses(t *testing.T) { + env := setupWebappEnv(t, builtDist(), true) + + res := env.get(HealthPath) + + assert.Equal(t, http.StatusOK, res.Code) + assert.Contains(t, res.Body.String(), "ok") +} + +// Открывающими страницу считаются GET и HEAD, и только они. +func TestWebappMethods(t *testing.T) { + env := setupWebappEnv(t, builtDist(), true) + + head := env.do(http.MethodHead, "/records", false) + assert.Equal(t, http.StatusOK, head.Code) + + post := env.do(http.MethodPost, "/records", false) + assert.Equal(t, http.StatusMethodNotAllowed, post.Code) + assert.NotContains(t, post.Body.String(), "приложение") +} + +// Долгий срок хранения ставится по каталогу, а не по виду файла: отозвать его у +// браузера сервису нечем. +func TestWebappCacheHeaders(t *testing.T) { + env := setupWebappEnv(t, builtDist(), true) + + asset := env.get("/assets/index-abc123.js").Result().Header.Get("Cache-Control") + assert.Contains(t, asset, "immutable") + assert.Contains(t, asset, "max-age=31536000") + + markup := env.get("/").Result().Header.Get("Cache-Control") + assert.Equal(t, "no-cache", markup) + + // Файл с постоянным именем лежит в сборке, но вне каталога сборщика: + // «неизменяемо» ему не достаётся. + permanent := env.get("/favicon.ico").Result().Header.Get("Cache-Control") + assert.Equal(t, "no-cache", permanent) +} + +// «Приложения нет» отличается от «адреса нет»: первое чинится сборкой, а не +// поиском опечатки в адресе. Проба здоровья при этом остаётся зелёной — сервис +// принимает записи и расшифровывает их, не работает только показ. +func TestWebappNotBuilt(t *testing.T) { + env := setupWebappEnv(t, fstest.MapFS{}, false) + + res := env.get("/") + assert.Equal(t, http.StatusServiceUnavailable, res.Code) + assert.Contains(t, res.Body.String(), "не собрано") + + health := env.get(HealthPath) + assert.Equal(t, http.StatusOK, health.Code) + + // Владелец сервиса узнаёт об этом журналом подъёма, а не от человека, + // открывшего страницу. + assert.Contains(t, env.journal.String(), "Webapp is not built") +} diff --git a/journal_route_test.go b/journal_route_test.go index f8179a4..bd43ff0 100644 --- a/journal_route_test.go +++ b/journal_route_test.go @@ -1,11 +1,27 @@ package main import ( + "strings" "testing" "github.com/stretchr/testify/assert" + + httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http" ) +// Перечень адресного пространства для проверок журнала. Обработчиков он здесь +// не вешает: журналу нужны только границы, а не то, что стоит за ними. +func journalMounts() []httpcontroller.Mount { + return []httpcontroller.Mount{ + {Path: httpcontroller.StorageRoot}, + {Path: httpcontroller.PanelRoot}, + {Path: httpcontroller.AppRoot}, + {Path: httpcontroller.AuthRoot}, + {Path: httpcontroller.HealthPath, Exact: true}, + {Path: httpcontroller.MetricsPath, Exact: true}, + } +} + // Имя файла в хранилище в журнал не идёт: оно последняя часть ссылки // `/api/files/...`, и строка журнала вместе с идентификатором записи собрала бы // ссылку целиком. Инвариант проекта, critical. @@ -44,7 +60,7 @@ func TestJournalRouteHidesStoredFileName(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { - assert.Equal(t, c.want, journalRoute(c.path)) + assert.Equal(t, c.want, journalRoute(c.path, journalMounts())) }) } } @@ -54,8 +70,24 @@ func TestJournalRouteHidesStoredFileName(t *testing.T) { func TestJournalRouteDropsNameEntirely(t *testing.T) { const stored = "0f7b8dd3-d1cc-424c.mp3" - route := journalRoute("/api/files/files/rec0000000000000/" + stored) + route := journalRoute("/api/files/files/rec0000000000000/"+stored, journalMounts()) assert.NotContains(t, route, stored, "имя файла в хранилище не доезжает до журнала") assert.Contains(t, route, "rec0000000000000", "идентификатор записи остаётся: по нему прослеживается путь") } + +// Путь, не принадлежащий сервису, уходит приложению и в журнал дословно не +// идёт: множеством его значений распоряжается спрашивающий. +func TestJournalRouteHidesWebappPath(t *testing.T) { + cases := []string{ + "/", + "/records/abc123def456ghi", + "/" + strings.Repeat("a", 1024), + } + + for _, path := range cases { + route := journalRoute(path, journalMounts()) + + assert.Equal(t, webappRoute, route) + } +} diff --git a/main.go b/main.go index 18674a0..7898cae 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,7 @@ import ( "git.vakhrushev.me/av/transcriber/internal/controller/worker" "git.vakhrushev.me/av/transcriber/internal/metrics" "git.vakhrushev.me/av/transcriber/internal/service" + "git.vakhrushev.me/av/transcriber/web" "github.com/joho/godotenv" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" @@ -167,6 +168,15 @@ func main() { SecureCookie: cfg.Auth.SecureCookie, }, logger) + // Адресное пространство сервиса объявлено одним перечнем, и он порождает + // регистрацию, а не описывает её: корень, заведённый мимо перечня, не + // получит обработчика вовсе. Отсюда же уровень журнала для адресов + // наблюдения и правило неизвестного пути у раздачи приложения. + mounts := httpcontroller.ServiceMounts(appHandler, authHandler, promhttp.Handler()) + + dist, appBuilt := web.Dist() + webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, mounts, logger) + // Сервер приезжает каналом, а не общей переменной: хук исполняется в // горутине сервера, а читает его горутина остановки, и связи «произошло // раньше» между ними иначе нет. @@ -186,17 +196,29 @@ func main() { err := e.Next() level := slog.LevelInfo - if e.Request.URL.Path == "/health" || e.Request.URL.Path == "/metrics" { + if httpcontroller.IsObservationAddress(mounts, e.Request.URL.Path) { // Опрос здоровья и метрик идёт постоянно и полезного не несёт. level = slog.LevelDebug } - logger.Log(e.Request.Context(), level, "Incoming request", + attrs := []any{ "http.method", e.Request.Method, - "http.route", journalRoute(e.Request.URL.Path), + "http.route", journalRoute(e.Request.URL.Path, mounts), "http.status_code", e.Status(), "duration_ms", time.Since(start).Milliseconds(), - "transport", "http") + "transport", "http", + } + + // Путь, отданный приложению, в журнал не идёт — вместо него исход + // и длина: по ним видно, что происходит, а множеством значений + // самого пути распоряжается спрашивающий. + if outcome := httpcontroller.WebappOutcome(e); outcome != "" { + attrs = append(attrs, + "webapp.outcome", outcome, + "http.path_length", len(e.Request.URL.Path)) + } + + logger.Log(e.Request.Context(), level, "Incoming request", attrs...) return err }) @@ -222,20 +244,11 @@ func main() { return fmt.Errorf("failed to apply app rate limit: %w", err) } - authHandler.Register(se.Router) - appHandler.Register(se.Router) + httpcontroller.RegisterServiceRoutes(se.Router, mounts) - se.Router.GET("/health", func(e *core.RequestEvent) error { - return e.JSON(http.StatusOK, map[string]string{ - "status": "ok", - "message": "Transcriber service is running", - }) - }) - - se.Router.GET("/metrics", func(e *core.RequestEvent) error { - promhttp.Handler().ServeHTTP(e.Response, e.Request) - return nil - }) + // Раздача приложения вешается последней: она занимает корень, и всё, + // что не совпало ни с одним адресом сервиса, доходит до неё. + webappHandler.Register(se.Router) return se.Next() }) @@ -313,6 +326,9 @@ func main() { // сегмент такого пути и есть имя файла в хранилище. const filesPathPrefix = "/api/files/" +// webappRoute — чем в журнале обозначается всякий путь, отданный приложению. +const webappRoute = "<приложение>" + // journalRoute готовит путь запроса к записи в журнал. // // Инвариант проекта запрещает имени файла в хранилище попадать в журнал: имя — @@ -323,7 +339,16 @@ const filesPathPrefix = "/api/files/" // // Срезается только имя: маршрут остаётся различимым, и наблюдаемость от этого не // теряется. -func journalRoute(path string) string { +// +// Путь, не принадлежащий сервису, в журнал не идёт вовсе. До появления раздачи +// приложения такой путь ловил отказ маршрутизатора, а теперь получает разметку +// с кодом `200`: множеством его значений распоряжается спрашивающий, и +// дословная строка сделала бы журнал местом, куда аноним пишет свой текст. +func journalRoute(path string, mounts []httpcontroller.Mount) string { + if !httpcontroller.IsServiceAddress(mounts, path) { + return webappRoute + } + if !strings.HasPrefix(path, filesPathPrefix) { return path } diff --git a/openspec/changes/archive/2026-08-15-spa-skeleton/.openspec.yaml b/openspec/changes/archive/2026-08-15-spa-skeleton/.openspec.yaml new file mode 100644 index 0000000..0c73c8f --- /dev/null +++ b/openspec/changes/archive/2026-08-15-spa-skeleton/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-15 diff --git a/openspec/changes/archive/2026-08-15-spa-skeleton/design.md b/openspec/changes/archive/2026-08-15-spa-skeleton/design.md new file mode 100644 index 0000000..bec4d64 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-spa-skeleton/design.md @@ -0,0 +1,257 @@ +# Дизайн: каркас приложения + +## Контекст + +Фреймворк и способ раздачи выбраны разведкой `spa-framework-choice` и записаны +решением [ADR-2026-08-11-spa-on-vue](../../../docs/adr/ADR-2026-08-11-spa-on-vue.md): +Vue 3 с роутером пятой версии, сборка Vite, статика внутри бинарника, шаг сборки +в наборе проверок и в образе. Отвергнутые кандидаты — Svelte и React — названы +там же с ценой отказа и здесь не пересматриваются. Правила кода приложения лежат +в [conventions/web-ui.md](../../../docs/conventions/web-ui.md). + +Эта задача решает не «на чём писать», а «чем сервис отдаёт написанное и что он +отвечает на пути, которого не знает». + +## Что человек увидит иначе + +Сегодня браузер, открывший адрес сервиса, получает отказ: сервис отвечает только +на своих адресах, и ни один из них страницы не отдаёт. После изменения тот же +адрес открывает приложение, а обновление страницы посреди приложения возвращает +тот же экран, а не ошибку. Вошедший видит своё имя, не вошедший — предложение +войти. + +## Решения + +### Один корневой маршрут вместо готовой раздачи библиотеки + +У библиотеки есть готовый обработчик статики с подстановкой `index.html` на +непопавший путь. Он **не берётся**, и причин две. + +Первая — устройство роутера. Сборка маршрутов сама вешает на путь `/` отказ +«ничего не совпало», если такого маршрута ещё нет. Образец `/` в стандартном +мультиплексоре покрывает все пути, и второй всепокрывающий образец рядом с ним — +`/{path...}` — либо конфликтует с ним, либо разрешается правилом, которое мы не +задаём. Поэтому маршрут ставится ровно на `/`: тем самым он занимает место +библиотечного отказа, а не спорит с ним. + +Вторая — подстановка `index.html` слепа. Она не различает «человек обновил +страницу приложения» и «программа ошиблась адресом под корнем хранилища»: оба +случая непопавшие, и оба получили бы разметку с кодом `200`. Перечень корней +знает только сервис, и проверять его обязан он. + +Отвергнуто: `apis.Static(fsys, true)` на `/{path...}` — по обеим причинам выше. + +### Корни сервиса перечислены одним местом + +Правило неизвестного пути обязано перечислять **все** корни (`archive`, «Адреса +приложения живут своим пространством»), а корней сегодня четыре плюс два +отдельных адреса наблюдения. Перечень заводится единой точкой в пакете +транспорта: разложенный по обработчикам, он теряет корень молча, и потерянный +корень означает разметку вместо отказа — то есть ровно ту поломку, от которой +правило и поставлено. + +Что делает обработчик по порядку: + +1. путь принадлежит корню сервиса или совпадает с адресом наблюдения — отказ + `404` формой библиотеки, то есть **тем же, чем он отвечает сегодня**. Своей + формы отказа здесь не заводится: под корнем приложения она уже есть и её даёт + собственный перехват `/app/`, а под чужими корнями наша форма была бы второй; +2. метод не `GET` и не `HEAD` — отказ `405`; +3. путь ведёт под каталог ресурсов, а файла там нет — отказ `404`; +4. путь совпал с файлом собранного приложения — файл; +5. прочее — разметка приложения. + +**Принадлежность корню — два условия, и оба обязательны:** точное совпадение либо +префикс вместе с косой чертой. По одному префиксу корню `/app` достался бы +посторонний `/apple`; по одному префиксу с косой чертой голый `/api` не достался +бы никому и уехал бы разметкой. Проверено прогоном мультиплексора на раскладке +этого дизайна. + +**Шаг 3 закрывает случай, ради которого писано правило кэширования.** Разметка +прежней сборки называет ресурсы прежней сборки; после выкладки их имена другие, и +без этого шага браузер получил бы на несуществующий ресурс `200` с разметкой, +отверг бы её по типу содержимого и показал пустой экран — а в кодах ответов +сервиса не осталось бы ничего. + +**Перечень не описывает регистрацию, а порождает её.** Наши маршруты — проба +здоровья, метрики, группа приложения и группа входа — вешаются из перечня; +корни библиотеки (`/api`, `/_`) остаются её литералами и держатся своим тестом. +Иначе перечень был бы вторым описанием таблицы маршрутов, которую сегодня ведут +три места, и разошлись бы они молча: корень, заведённый регистрацией и забытый в +перечне, отдаёт разметку с кодом `200` там, где программа ждёт отказ контракта, а +компилятор не видит ничего. Заодно уходит второе перечисление адресов +наблюдения — сегодня уровень журнала называет пробу здоровья и метрики отдельно. + +Цена названа: задача трогает сборку маршрутов сверх первоначальных границ. +Принято решением владельца на чекпоинте 2026-08-15; отвергнуты правило-сканер +(ловит только литералы и добавляет шаг проверок) и перечень описанием +(расхождение обнаружил бы клиент). + +**В журнал этот маршрут пишет исход, а не путь.** Путь здесь целиком задаёт +спрашивающий, и до изменения он ловился отказом маршрутизатора, а теперь +становится успешным ответом: дословная строка сделала бы журнал местом, куда +аноним пишет свой текст. Пишется исход — разметка, ресурс, отказ — и длина пути. + +### Приложение живёт каталогом `web/`, а вшивается своим пакетом Go + +Исходники, зависимости и настройка сборки — `web/`; вшиваемый каталог — +`web/embed`; собранное сборщик кладёт в `web/embed/dist`. Вшивает его файл +`web/embed.go` пакетом `web`. Транспорт получает готовую файловую систему +параметром, а не тянет пакет сам: так тест подставляет свою сборку, не собирая +приложение. + +Отвергнуто: держать исходники приложения под `internal/`. Каталог означает +«внутреннее для Go», а приложение к сборке Go отношения не имеет; сборщик +фронтенда пришлось бы учить писать результат в чужое дерево. + +### Собранного приложения в git нет, а сборка Go без него не ломается + +Собранное не коммитится: это производное, и его версия в git разошлась бы с +исходниками молча. Но вшивание требует, чтобы каталог существовал в момент сборки +Go, иначе `go build ./...` отказывает у всякого, кто приложение не собирал. + +Отсюда раскладка в два этажа. В git лежит **пустой каталог с меткой** — +`web/embed/.gitkeep`, — а сборщик пишет в `web/embed/dist`, то есть **этажом +ниже метки**. Так очистка выходного каталога, которую сборщик делает перед каждой +сборкой, метки не касается: положенная рядом с собранным, она уехала бы первым же +прогоном, а следом — удалённой в ближайший коммит, и `go build ./...` отказал бы +у всякого, кто клонировал заново. + +Обработчик, не нашедший разметки, отвечает `503` и честной страницей «приложение +не собрано» — плюс строка в журнале при подъёме. Молча отдавать `404` он не +вправе: инвариант о неслучившемся молчании относится и к этому случаю. + +**Что за сборка вшита, видно снаружи.** При подъёме в журнал уходит отпечаток +вшитой разметки. Иначе бинарник молча несёт вчерашнюю сборку — вне набора +проверок порядок шагов ничем не задан, и `go run .` вшивает то, что лежит с +прошлого раза, — а отличить «не та сборка» от «та» нечем: приложение открывается +и ведёт себя как прежняя версия. + +Отвергнуто: **коммитить собранное** — производное в git, расходится с +исходниками. Отвергнуто: **ронять старт** без собранного приложения — сломало бы +`go test ./...` и `go run .` у того, кто правит только Go, а команды эти +объявлены в `CLAUDE.md` работающими. + +### Заголовки кэширования назначаются, а не достаются умолчанию + +Отдача файла в библиотеке заголовка кэширования не ставит вовсе, а вшитый файл не +несёт и времени правки: браузер остаётся с собственной догадкой о том, сколько +хранить копию. Догадка эта разная у разных браузеров, и проверить её со стороны +сервиса нечем. + +Поэтому: **признаком служит каталог, а не вид файла.** Ресурс из каталога, +который наполняет сборщик, отдаётся с долгим сроком и пометкой «неизменяемо» — +имена там строит сборщик и несёт в них отпечаток содержимого, поэтому устареть +такой ответ не может. Всё прочее, включая разметку, — с требованием спрашивать +заново. + +Признак назван каталогом потому, что вид файла его не даёт: в сборке лежат и +файлы с постоянными именами — иконка, манифест, а после `installable-pwa` ещё и +service worker, у которого отпечатка не бывает по устройству. Такой файл, +однажды отданный как «неизменяемый», не отзывается со стороны сервиса ничем: +запросов он больше не увидит, а у установленного на телефон приложения залипший +service worker означает, что новая сборка не доходит вовсе. + +**Отпечаток в именах здесь не нормируется.** Его делает сборщик, сервис имён не +выбирает и их нарушения не заметит — значит это правило конвенции приложения, а +не спеки: спеки нормируют поведение сервиса, а инструмент, которым его собирают, +не нормируют вовсе (`architecture.md`, преамбула). + +Долгий срок — год. Число живёт настройкой в [database.md](../../../docs/database.md), +«Настройки с числовым значением», как и прочие числа проекта. + +### Node приходит отдельной ступенью образа + +Ступеней в образе становится три: сборка приложения, сборка бинарника, рабочий +слой. Приложение собирается **до** бинарника, потому что вшивание требует +готового каталога. Рабочего слоя это не касается: Node в нём не остаётся. + +Цена названа решением ADR и здесь только подтверждается: во что слой обходится +образу по времени и по весу, не замерялось — замер делает эта задача. + +### Node не ставится на машину, а зовётся контейнером + +Шаг сборки приложения гоняет установщик пакетов и сборщик **внутри контейнера**, +а не вызывает их из `PATH`. Требованием к машине разработчика становится docker, +которым и так собирается образ, — второго устанавливаемого окружения сверх +`ffmpeg` не появляется вовсе. + +Так снимается расхождение, которое иначе завелось бы молча: версия Node на машине +разработчика и версия в образе — два разных числа, и собранное ими приложение +различается ровно тогда, когда различаются они. Обёртка берёт **тот же** образ, +которым собирает ступень `Dockerfile`. + +Отсюда три частности, каждая из которых иначе кусается на первом же прогоне: + +- **имя образа берётся из `Dockerfile`**, а не объявляется вторым числом в + `Taskfile.yml`: второй дом версии разошёлся бы с первым, и сверять их было бы + нечем — своего шага сверки у него, в отличие от версий Go, нет; +- **контейнер ходит под тем же пользователем, что и вызвавший**, иначе каталог + зависимостей и собранное лягут от `root`, и убрать их с машины обычными + средствами не выйдет; +- **кэш установщика уводится наружу контейнера**, в каталог под `/tmp`: под + чужим пользователем домашнего каталога у контейнера нет, и установка иначе + отказывает, а кэш внутри контейнера не пережил бы прогон — каждый набор + проверок тянул бы зависимости заново, включая задачи, приложения не + касающиеся. В git каталог не попадает по построению: он вне дерева проекта. + +**Зависимости ставятся из файла замка командой, которая его не правит.** Иначе +шаг набора проверок правит отслеживаемый файл: обновление зависимости приезжает в +коммит без чьего-либо решения, а собранное в наборе проверок перестаёт совпадать +с собранным в образе. Обе половины молчаливы — набор проверок зелёный в обоих +случаях. Ту же команду зовёт и ступень `Dockerfile`. + +**Шаг ходит в сеть, и это второй такой шаг в наборе проверок.** Установщик +обращается к реестру пакетов, а docker — к реестру образов; без сети шаг +краснеет. Отказ сети и реестра — **отказ окружения, код 3**, отдельно от красной +сборки, у которой код 1. Утверждение `CLAUDE.md` «шагу `vulns` нужна сеть, и он +один такой» перестаёт быть верным и правится этой же задачей: иначе следующий +читатель раздела «Гейт» получит неверную посылку. + +Решение правит **последствие** [ADR-2026-08-11-spa-on-vue](../../../docs/adr/ADR-2026-08-11-spa-on-vue.md): +там записано, что Node и его установщик становятся нужны на машине +разработчика. Нужен docker; сам ADR при этом не пересматривается — выбор +фреймворка и наличие шага сборки остаются прежними. + +Отвергнуто: **ставить Node на машину** — вводит второе устанавливаемое окружение +и разъезжается с версией в образе. Отвергнуто: **дать выбор — контейнер или +локальный Node** — это второй способ делать одно и то же, и собранное ими +различалось бы в зависимости от того, у кого что стоит. + +### Код приложения проверяется машиной, а не глазами + +Проверок у приложения две, и обе входят в набор проверок наравне со сборкой: +**Biome** — форматирование и статический анализ, **юнит-тесты Vue** — поведение +экрана, включая показ вошедшего и три ветки ответа на вопрос «кто вошёл». +Гоняются они тем же контейнером, что и сборка, — второго окружения не заводится. + +Без них требование «приложение показывает вошедшего» — единственное, ради чего +задача заводится, — не проверял бы ни один шаг: набор проверок судил бы только +раздачу из Go и оставался зелёным на сломанном приложении. Ручная проверка эту +дыру не закрывает: следующая задача её не повторит. + +Оговорка на Biome одна и проверяется первым же прогоном: если он не покрывает +разметку однофайловых компонентов, инструмент меняется. Решением владельца на +чекпоинте 2026-08-15 взят он, а замена — отдельное решение, не подстановка по +ходу. + +### Шаг сборки приложения — первый в наборе проверок + +Он стоит **перед** сборкой Go по той же причине: вшивается то, что собрано. +Недостающий docker — отказ окружения, код 3 по общему словарю (`CLAUDE.md`, +«Гейт»), наравне с недостающим компилятором C у шага тестов; красная сборка +приложения — код 1, отказ проверки наравне с `go build`. + +## Границы + +Экранов, кроме показа вошедшего, не делается: их заводят `upload-and-status-screen` +и `records-list-screen`. Установка на телефон, service worker и версионирование +статики — `installable-pwa`. Готовый набор компонентов не берётся: он не выбран, +а способен удвоить собранный файл. + +## Открытые вопросы + +- **Набор компонентов и стили** — не решено, дом вопроса + [conventions/web-ui.md](../../../docs/conventions/web-ui.md), «Что не решено». +- **Во что слой Node обходится образу** — замеряется этой задачей, число уезжает + в решение ADR последствием. diff --git a/openspec/changes/archive/2026-08-15-spa-skeleton/proposal.md b/openspec/changes/archive/2026-08-15-spa-skeleton/proposal.md new file mode 100644 index 0000000..c061916 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-spa-skeleton/proposal.md @@ -0,0 +1,56 @@ +## Why + +Сервис отдаёт данные, но человеку их показать нечем: экранов нет вовсе, а +браузер, открывший адрес сервиса, получает поверхность хранилища вместо +приложения. Два основных сценария паспорта — «семейный архив» и «возвращение к +записи» — не работают ни один, и упираются они в одно: приложения не существует. + +Каркас сам по себе экранов не приносит. Он приносит то, на чём они стоят: +собранное приложение, которое сервис отдаёт браузером, открывается по любому +своему адресу и показывает вошедшего, спросив об этом сервис. + +## What Changes + +- Сервис начинает отдавать браузеру **приложение**: разметку и её ресурсы. Они + лежат внутри самого бинарника, отдельного каталога рядом с ним не требуется, и + внешнего веб-сервера под них не заводится. +- Неизвестный адрес **вне корней сервиса** отдаёт разметку приложения, а не + отказ. Так обновление страницы посреди приложения открывает тот же экран, а не + ошибку. Корни перечислены поимённо, и адрес **внутри** корня в приложение не + проваливается никогда: отказ контракта остаётся отказом контракта. +- Разметка и её ресурсы отдаются **без сессии**: приложение само спрашивает + сервис, кто вошёл, и уводит ко входу, когда не вошёл никто. Требование сессии + на самой разметке отдавало бы отказ вместо экрана входа. +- Открытое приложение показывает **вошедшего** — то, что сервис ответил о нём, — + и это единственное, что оно сегодня показывает. +- В сборку приходит **шаг сборки приложения**: он входит в набор проверок и в + сборку образа. Спеками это не нормируется — у сборки другой потребитель, тот, + кто собирает. + +## Capabilities + +### New Capabilities +- `webapp`: приложение в браузере — чем сервис его отдаёт, каким адресом оно + открывается, что делает обновление страницы посреди приложения и что человек + видит, открыв его. + +### Modified Capabilities +- `access`: разметка приложения и её ресурсы пополняют перечень адресов, + открытых без сессии; сегодня в нём только проба здоровья и метрики. + +## Impact + +- новый каталог приложения: исходники, зависимости, настройка сборки; +- `internal/controller/http` — маршрут, отдающий разметку на неизвестном пути вне + корней, и раздача ресурсов приложения из бинарника; +- `Taskfile.yml` — шаг сборки приложения, его место в наборе проверок и в сборке + образа; +- `Dockerfile` — приложение собирается внутри образа, чтобы выкладка не зависела + от машины разработчика; +- `.gitignore` — каталог зависимостей и собранное приложение; +- новая внешняя зависимость сборки: Node и его установщик пакетов. На машину + разработчика они не ставятся — шаг сборки зовёт их контейнером, поэтому + требованием к машине становится docker, которым и так собирается образ; +- `docs/conventions/web-ui.md` и `docs/architecture.md` — правила кода + приложения перестают быть выведенными из выбора, а компонент и зависимость + сборки появляются в описании устройства. diff --git a/openspec/changes/archive/2026-08-15-spa-skeleton/review/code-review.md b/openspec/changes/archive/2026-08-15-spa-skeleton/review/code-review.md new file mode 100644 index 0000000..e3f6a90 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-spa-skeleton/review/code-review.md @@ -0,0 +1,395 @@ +# Ревью кода: `spa-skeleton` + +## Сводка + +- **Режим прогона:** по графу, метка **`large`**. +- **Обоснование метки, размер и сложность:** до триажа **не доехали** — задание + передало только саму метку и режим. Строка деградации, а не оценка: сверить + метку с предметом мне нечем. Свой замер (не замер разметчика): изменение + рабочего дерева — 12 изменённых файлов, +270/−35 строк, плюс ~850 строк нового + кода в `internal/controller/http/webapp*.go` и `web/`. +- **Гейт:** зелёный целиком (14 шагов), унаследованных отказов нет. +- **Находок на входе:** 26 (пункты 1–19 поимённо, 4 подпункта в 20-м, 21–23). +- **Осталось в первых двух секциях:** 7 (3 + 4). Понижено — 5, в promote — 4, + срезано потолком — 6; срезанное поимённо названо в границах покрытия. + +### План с исходом по каждой теме + +| Тема | Дом | Глубина | Кто закрывает | Исход | +|---|---|---|---|---| +| requirements | дельта-спеки `specs/{webapp,access}` | разбор | specs | закрыта, 7 находок | +| autotests | `CLAUDE.md`, «Гейт» | — | autotests | закрыта, 2 находки | +| conventions | `docs/conventions/` | разбор | code | закрыта, 4 находки | +| architecture | `docs/architecture.md` + `passport.md` | доказательство | architecture | закрыта, 3 находки | +| security | `docs/security.md` | доказательство | adversary | закрыта, 7 находок | +| operations | `architecture.md` «Эксплуатация» + `database.md` | доказательство | ops | закрыта, 3 находки | +| темы проекта | своих нет | — | basics | **дома у темы нет**; проход не запускался, темы разобраны именными проходами | + +**Тем без отчёта нет** — все шесть заявленных домов закрыты своими проходами. + +**Сигнал о заниженной метке: не поступал ни от одного прохода.** `review-code` +его не подавал; `review-basics` не запускался и подать не мог. Это «возражений +нет» от одного из двух возможных источников, а не от обоих. + +**Ни один проход не объявил свой потолок и не приложил блок «Coverage of this +pass».** Это находка о прогоне, а не оформление: «находок больше нет» +неотличимо от «больше не поместилось», и ровно об этом стоит запись +`docs/review.md` от 2026-08-13 («Ни один проход не сообщает свой потолок»). +Повторяется прогон в прогон, механизации нет. + +--- + +## Блокирует мердж + +### Панель владельца со всеми записями и файлами открывается анониму из интернета по адресу `/%5f/` + +- Файл: `internal/controller/http/webapp.go:17-28,100-124`; `docs/security.md:29-38` +- Severity: major +- Confidence: high +- Оракул (снят триажем на этом прогоне): проба стандартного мультиплексора, + `/tmp/claude-1000/.../scratchpad/muxprobe`, `go run .`: + + ``` + /_/ -> 200 HIT /_/ path="/_/" raw="" + /%5f/ -> 200 HIT /_/ path="/_/" raw="/%5f/" + /%6detrics -> 200 HIT /metrics path="/metrics" raw="/%6detrics" + /%68ealth -> 200 HIT /health path="/health" raw="/%68ealth" + /%61pi/collections -> 200 HIT /api/ path="/api/collections" + /%61pp/me -> 200 HIT /app/ path="/app/me" + ``` + + Маршрутизатор сравнивает **раскодированный** путь, исходная форма остаётся в + `URL.RawPath`. Живой прогон враждебного прохода даёт то же: `/_/` и `/%5f/` + отвечают байт в байт, весь клиент панели (633 КБ JS, 275 КБ CSS) грузится + анониму. +- Последствие: правило Authelia на обратном прокси написано на литерал `/_/` + (`docs/security.md`, «Третий сдвиг»), поэтому закодированная форма проходит + мимо него и попадает в панель суперпользователя — все записи, все файлы, все + пользователи. Вход в само приложение при этом не обходится: `/%61pp/me` + отвечает `401`, разграничение живёт в обработчике, а не в маршруте. +- **Дефект унаследованный:** так вело себя адресное пространство и до задачи, + изменение его не вносило. В блокирующие он попал по ущербу, а не по авторству. +- **Половина пути не проверена:** правило прокси лежит в `pet-project-server`, + в этом репозитории его нет. Проверена только сторона сервиса — отсюда `major`, + а не `critical`. +- Предложение: слой, приводящий путь к канонической форме (отказ `400` при + `RawPath != ""`, если раскодированный путь попадает в перечень корней), рядом с + новым перечнем `ServiceMounts`; плюс правка `docs/security.md`. + **Осторожно:** глухая проверка `RawPath != ""` сломает скачивание файлов + записей с пробелами и не-латиницей в имени — они приходят закодированными + законно. +- Найдено проходом: adversary; оракул перепроверен триажем. +- **Действие: развилка.** + > Панель `/_/` доступна анониму по `/%5f/` мимо Authelia — дефект старше этой + > задачи, но именно она заводит перечень корней, куда лечение просится. + > Варианты: + > 1. чинить здесь: слой канонизации пути на перечне `ServiceMounts`, отказ + > `400` только на закодированной форме корня, тест на `/%5f/`, `/%6detrics` + > и на законный закодированный путь файла (цена — день, растёт scope задачи); + > 2. чинить отдельной задачей, а в этой задаче только записать дыру в + > `docs/security.md` и завести задачу в беклоге (цена — час, дыра живёт до + > выкладки); + > 3. закрыть на стороне выкладки — правило прокси на регулярном выражении по + > сырому пути (цена — вне репозитория, проверить прогоном нечем). + +### Образ собирается из дерева разработчика, а не из объявленных входов + +- Файл: `Dockerfile:12-16,37`; отсутствующий `.dockerignore`; `.gitignore:57-61` +- Severity: major +- Confidence: high +- Оракул: `ls -la .dockerignore` → `No such file or directory`; в `Dockerfile` + ступень `front-build` делает `COPY web/package.json web/package-lock.json ./`, + `RUN npm ci`, а следом `COPY web/ ./` — то есть локальный `web/node_modules` + ложится **поверх** результата `npm ci`; ступень `build-env` делает `COPY . .`. + `Taskfile.yml:163` монтирует `$PWD/web` в контейнер, поэтому каталог + зависимостей в дереве разработчика заводится при каждом `task front`, а + `.gitignore` прячет его от git, но не от docker. +- Последствие: артефакт, который едет на сервер, собирается из того, что лежит у + собравшего, а не из `package-lock.json`, — и **молча**: сборка при этом + зелёная. В промежуточный слой `build-env` при `COPY . .` уезжают также `.git` + (12 МБ), локальный `config.toml` и каталог `data/` того, кто запускал сервис + локально; в финальный образ они не попадают, но живут в слоях сборки. +- **Поправка к исходной находке:** заявленное падение ступени из-за чужих + платформенных пакетов маловероятно — зависимости в дерево кладёт тот же + `node:24-alpine`, что и ступень образа. Замер «193 МБ» сегодня не + воспроизводится: `web/node_modules` пуст. Существо находки от этого не + меняется — вход сборки не ограничен ничем. +- Предложение: `.dockerignore` в корне с `web/node_modules/`, `web/embed/dist/`, + `data/`, `.git/`, `config.toml`, `tmp/`. +- Найдено проходом: code/техника; оракул перепроверен триажем. +- **Действие: инлайн.** + +### Путь и адрес анонима уезжают в журнал хранилища, хотя записанное свойство прогона утверждает обратное + +- Файл: `internal/controller/http/webapp.go:231-284`; `docs/review.md:75` +- Severity: major +- Confidence: high +- Оракул (перепроверен триажем по исходникам библиотеки, + `pocketbase@v0.39.10`): `apis/base.go:117-120` — `apis.Static` первой строкой + ставит `requestEventKeySkipSuccessActivityLog`; `apis/middlewares.go:349-445` — + `activityLogger()` навешен на **все** маршруты и при `err == nil` без этого + признака пишет `url` (`RequestURI`, до 3000 знаков), `referer`, `userAgent`, а + при `Logs.LogIP` — `userIP` и `remoteIP`; `core/settings_model.go:158-159` — + умолчания `MaxDays: 5`, `LogIP: true`; в проекте настройки журнала не + трогаются вовсе (`grep 'Settings().Logs' → пусто`). Наша раздача написана мимо + `apis.Static` и признак не ставит. +- Последствие: каждый успешный ответ разметкой и ресурсом кладёт в таблицу + `_logs` выбранный анонимом путь и его адрес, и лежит это пять суток. Строка + `docs/review.md`, добавленная этой же задачей, — «путь, выбранный анонимом, не + уходит ни строкой журнала, ни меткой метрики» — верна для журнала контейнера и + неверна для журнала хранилища. Ложное записанное свойство хуже отсутствующего: + следующий прогон возьмёт его оракулом. + Частично унаследовано: отказы (`err != nil`) писались в `_logs` и раньше; + новое — успешные ответы, то есть основной поток. +- Предложение: привязать `apis.SkipSuccessActivityLog()` к корневому маршруту в + `WebappHandler.Register` и поправить строку в `docs/review.md`, назвав оба + журнала. +- Найдено проходом: architecture. +- **Действие: инлайн.** + +## Стоит исправить сейчас + +### `docs/security.md` описывает периметр уже кода: новая анонимная поверхность в нём не названа + +- Файл: `docs/security.md` (не тронут изменением); `internal/controller/http/webapp.go:236-284` +- Severity: major +- Confidence: high +- Оракул: `git status` — `docs/security.md` в изменениях рабочего дерева + отсутствует, тогда как раздача заведена; раздел «Периметр» перечисляет входы + без корневого маршрута, а таблица «Что разграничивает доступ» о раздаче не + знает. +- Последствие: анонимно открытым стал весь путь вне шести корней плюс всё + содержимое сборки, и документ этого не говорит. Следующие прогоны читают + периметр отсюда и не станут искать пути через раздачу вовсе — блиндаж, + который накапливается. +- Предложение: дописать в «Периметр» корневой маршрут (что отдаётся анониму, + что — нет) и строку в «Что разграничивает доступ» про раздачу. +- Найдено проходом: adversary. +- **Действие: инлайн.** + +### Расхождение раскладки сборки с ожиданиями кода не ловится ничем: гейт зелёный, приложение отвечает `503` либо теряет правила `404` и срока хранения + +- Файл: `web/embed.go:22-44`; `web/vite.config.ts:8-13`; + `internal/controller/http/webapp.go:36,262-275,291-301` +- Severity: minor +- Confidence: high +- Оракул: `grep` — ни один тест не зовёт `web.Dist()`, все проверки раздачи + получают `fstest.MapFS`; `outDir: 'embed/dist'` в сборщике и `distDir = + "embed/dist"` в Go связаны только совпадением строк; `build.assetsDir` в + `vite.config.ts` не задан вовсе, а Go держит `const assetsDir = "assets"` — + сегодня они совпадают по умолчанию сборщика. +- Последствие: одна причина, два исхода. Правка `outDir` даёт `built == false` + и `503` на каждом пути показа при зелёном гейте; смена умолчания `assetsDir` + сборщиком молча выключает и правило `404` под каталогом ресурсов, и годовой + срок хранения — тесты остаются зелёными, потому что подставная сборка + называет каталог сама. +- Предложение: `web/embed_test.go` на оба состояния `Dist()` (собрано / пусто), + проверяющий именно вшитое дерево — наличие `index.html` и каталога ресурсов; + плюс `build.assetsDir: 'assets'` явной строкой в `vite.config.ts`. +- Найдено проходами: autotests (п. 1), specs (пп. 3, 4), architecture (п. 14) — + одна причина в четырёх формулировках; оракула у согласия нет, приоритет + подняло само совпадение. +- **Действие: инлайн.** + +### Каталог ресурсов сборщика сам по себе отвечает разметкой с кодом `200` + +- Файл: `internal/controller/http/webapp.go:262-277` +- Severity: minor +- Confidence: high +- Оракул: чтение кода воспроизводит путь целиком — `fs.Stat("assets")` даёт + каталог, ветка ресурса пропускается по `!info.IsDir()`, а + `strings.HasPrefix("assets", "assets/")` — ложь, поэтому запрос уходит в + `serveIndex`. Враждебный проход подтвердил падающим тестом и живым прогоном: + `/assets` и `/assets/` → `200` с разметкой. +- Последствие: нарушено записанное свойство узла + (`docs/review.md:66-67`, «несовпавший ресурс под каталогом сборщика отвечает + `404`, а не разметкой с кодом `200`»). Ровно от этой ошибки — префикс без + точного совпадения — в соседних строках защищается `Mount.Covers`, и здесь + осталось одно условие из двух. +- Предложение: `name == assetsDir || strings.HasPrefix(name, assetsDir+"/")` в + обеих ветках — и в отказе, и в `setCacheHeader`. +- Найдено проходом: adversary. +- **Действие: инлайн.** + +### Три решения раздачи не имеют дома: следующий автор снимет их, не нарушив ни одного требования + +- Файл: `internal/controller/http/webapp.go:156-163,201-224`; `main.go:347-362`; + `openspec/changes/spa-skeleton/specs/webapp/spec.md`; `docs/architecture.md`; + `openspec/changes/spa-skeleton/tasks.md` (задачи 2.8, 5.2) +- Severity: minor +- Confidence: high +- Оракул: правило «путь в журнал не идёт» живёт в комментарии и тесте, но не в + дельта-спеке; у отпечатка вшитой сборки (`buildFingerprint`) нет ни + требования, ни теста, а задача 2.8 отмечена выполненной; capability `webapp` + не объявлена в перечне `docs/architecture.md`, задача 5.2 не отмечена; + сценарий «Отсутствие сборки видно в журнале» оракула не имеет — буфер журнала + в `TestWebappNotBuilt` заведён, но не читается. +- Последствие: решение без дома снимается следующей задачей бесплатно и молча. + Ближайший пример уже виден: вернуть путь анонима в журнал можно, не нарушив ни + одного записанного требования, — и это ровно то свойство, которое блокирующая + находка выше показала уже нарушенным. Capability без объявления даёт второе + описание раздачи у следующего автора. +- Предложение: дописать в дельту `webapp` требования «путь в журнал не идёт» и + «отпечаток вшитой сборки в журнале подъёма»; строку capability `webapp` в + `docs/architecture.md`; дочитать буфер журнала в `TestWebappNotBuilt`; отметить + задачи 2.8 и 5.2. +- Найдено проходами: specs (пп. 5, 6, 7, 9). +- **Действие: инлайн.** + +## Гипотезы без доказательства + +- **Человек, ошибшийся адресом, видит пустой экран с кодом `200`** + (`web/src/router.ts:9-12`). Поведение подтверждено чтением — в таблице один + маршрут `/`, catch-all нет, — а **дефектность не подтверждена**: требования на + этот счёт нет ни в дельте, ни в `web-ui.md`. Решает владелец: маршрут «такого + экрана нет» либо запись границей. Найдено: specs (п. 8). +- **Разметка уходит без `Content-Security-Policy`**, тогда как панель на том же + порту его получает. Пути нет: ближайший вход в разметку — ответ языковой + модели из ещё не сделанной `llm-insights-adapter`. Понижено до `minor`, + переведено в promote. Найдено: adversary (п. 20). +- **Состав вшитого никем не судится** — что именно уехало в бинарник, не + проверяет ни тест, ни шаг. Ущерб не построен, оракула нет. Найдено: adversary + (п. 20). +- **У корневого маршрута нет потолка частоты** (ограничитель заведён только на + `/app/`). Замера нагрузки нет, ущерб не построен; проект работает на единицах + записей в день (`docs/review.md`, «Недоступно проверке»). Найдено: ops (п. 23). +- **`POST /api/collections/users/request-verification` отвечает `204` анониму.** + Предмет старше изменения, пути к ущербу проход не построил. Найдено: adversary + (п. 20). + +## Promote candidates + +- **Зависимости приложения не сканируются на уязвимости** (`Taskfile.yml`, шаг + `front`; `web/package.json`). `govulncheck` смотрит только Go, ручной + `npm audit` сегодня чист. Это не находка о коде, а **пробел в правиле**: + `CLAUDE.md`, «Чего в гейте намеренно нет», перечисляет исключения поимённо, а + новая экосистема не попала ни в проверки, ни в этот перечень. Кандидат: + шаг `npm audit` в гейт (словарь кодов общий) **либо** строка исключения с + причиной. Решение владельца, не оркестратора. Найдено: autotests (п. 2). +- **Копия перечня корней в тесте расходится с перечнем молча** + (`journal_route_test.go:15-24` против `webapp.go:76-88`). Тест стережёт + инвариант `critical` про имя файла и сверяется с рукописной копией: новый + корень в копию не попадёт, тест останется зелёным. Кандидат: правило в + `internal/archrules` — тем же способом, каким проект уже стережёт перечень + колонок и дескриптор рубежа. Найдено: architecture (п. 16). +- **Заголовки безопасности разметки** — где объявляется `Content-Security-Policy` + и кто за него отвечает; сегодня дома у правила нет + (`docs/conventions/web-ui.md` о заголовках не говорит). +- **Поле `Exact` в `Mount` несёт два смысла** — «точный адрес» и «адрес + наблюдения». Сегодня они совпадают, завтра разойдутся. Кандидат в конвенцию + описания адресного пространства, а не правка этого кода. Найдено: architecture + (п. 16). + +## Границы покрытия + +### План: темы, дома, глубины + +Воспроизведён таблицей в сводке выше. Тема **«темы проекта» дома не имеет** — +своих тем у проекта не заведено, поэтому `review-basics` не запускался, и это +не пропуск, а отсутствие предмета. + +### Проходы + +- Запускались на метке `large`, режим «по графу»: `specs`, `autotests`, `code`, + `architecture`, `adversary`, `ops`. Отчёт пришёл от каждого. +- Не запускался: `review-basics` — все темы разобраны именными проходами. +- **Что каждый проход не мог проверить в принципе — назвать нечем: ни один + проход не приложил блок «Coverage of this pass» и ни один не объявил свой + потолок.** Сколько находок осталось за срезом у `specs` (7 показанных) и у + `adversary` (7 показанных) — неизвестно. Это находка о прогоне, повторяющая + запись `docs/review.md` от 2026-08-13. +- **Потолок триажа сработал.** Срезано шесть проверенных находок, поимённо: + 1. `Taskfile.yml`, шаг `front`: рассинхронизованный `package-lock.json` + роняет гейт кодом 3 после **удавшегося** `npm ping`, то есть при заведомо + живой сети, — словарь `CLAUDE.md` («Гейт») велит здесь код 1, дрейф. Правка + однострочная (code, п. 11). + 2. `main.go:212-219`, `webapp.go:159-166`: два новых поля журнала заведены мимо + словаря `docs/conventions/logging.md`, домен `webapp.*` не объявлен (code, + п. 12). + 3. `webapp.go:24` против `auth.go:96-105`: корень `/auth` объявлен константой + `AuthRoot` и оставлен литералом в трёх регистрациях. Правка `AuthRoot` даст + разметку на `/auth/callback` — возврат от провайдера сломается без единой + ошибки (code, п. 13). + 4. `webapp.go:280-301`: разметка с `no-cache` никогда не подтверждается `304` — + `embed.FS` отдаёт нулевой `ModTime`, `ETag` не выставляется, каждая + ревалидация тянет полное тело. Готовый `buildFingerprint` под `ETag` уже + посчитан (ops, п. 22). + 5. `webapp.outcome=failure` доезжает с `http.status_code=0` — предмет уже + заведён задачей `response-code-in-journal` (adversary, п. 20). + 6. Задача 3.6 (`tasks.md`) закрыть на этом окружении нечем — см. ниже. +- **Задача 3.6 остаётся человеку.** `docker build --target front-build` виснет + на `npm ci` и в обычной сети, и с `--network=host`: из контейнера нет + исходящей сети вовсе при рабочем DNS. Замерено то, что измеримо: вшитое + приложение добавляет к бинарнику 86 072 байта, Node в финальный слой не + попадает структурно, `task front` без сети отвечает за 11,3 с кодом 3. Число + размера образа снимается на машине с сетью (ops, п. 21). + +### Что осталось целиком на человеке + +**Не проверит ни один проход** (`docs/review.md`, «Недоступно проверке»): + +- поведение внешних сервисов под нагрузкой и на границах — SpeechKit и Object + Storage поднять в тесте нечем; +- реальный профиль нагрузки: проект работает на единицах записей в день; +- стойкость `ffmpeg` к вредоносному входу; +- поведение настоящей Authelia и её правило на нашего клиента — настройка + выкладки вне репозитория. **На этом прогоне это стоило половины оракула у + первой блокирующей находки**: сторона сервиса проверена, сторона прокси — нет; +- поведение браузера с куками (`SameSite`, приём `Set-Cookie` при переходе с + чужого сайта). + +**Перестали проверять сознательно** (тот же раздел, отдельным списком): + +- разбор вывода настоящего `ffprobe` — своего теста у `adapter/metaviewer/ffmpeg` + нет ([ADR-2026-08-11-stub-adapters-in-tests](../../../../docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md)); +- работа сервиса с настоящими внешними собеседниками: живой прогон доступен и + покрывает подъём, маршруты, панель, журнал, метрики и остановку, но за + настоящие SpeechKit и Object Storage не отвечает — ключи выдуманные, + распознавание подменяется в коде; вход через живого провайдера OIDC тоже + недоступен. + +Общее, что не проверяет ни один прогон в принципе: история инцидентов; +поведение под реальным потоком; поведение внешних систем в их версиях; завязка +потребителей на текущее поведение; и вопрос «нужна ли эта функциональность +вообще». + +### Каких документов и данных не хватило + +- **Разметка не передала размер, сложность и обоснование метки** — в задании + триажу только метка и режим. Сверить `large` с предметом нечем; сигнала о + заниженной метке при этом не подавал никто. +- **Правила обратного прокси нет в репозитории** — оно живёт в + `pet-project-server`. Из-за этого путь к панели через `/%5f/` подтверждён + только на стороне сервиса, и находка держит `major`, а не `critical`. +- **`docs/conventions/web-ui.md` не говорит о заголовках безопасности** — + документ есть, предмета в нём нет; отсюда `Content-Security-Policy` уехал в + promote, а не в находки. +- **`openspec/changes/spa-skeleton/specs/webapp/spec.md` не покрывает три + решения раздачи** — дельта есть, требований на предмет в ней нет (пункт 4 + второй секции). +- **`docs/review.md`, «Типовые ложноположительные», прочитан и применён**: + четыре записи, ни одна к находкам этого прогона не подошла. Отсев вкусовщины + шёл по общим критериям и по этому разделу. + +### Четыре строки, которых не принесёт ни один проход + +1. **Решения проекта не сверялись.** `docs/adr.*` — процессный документ, прогон + его не открывает. Расхождение изменения с записанным решением ловит + `av-dev:doc-healthcheck`, а не ревью. У этой задачи ADR есть + (`ADR-2026-08-11-spa-on-vue.md`, тронут), и сверен он не был. +2. **Записанные наблюдения проекта не использовались.** `docs/research.*` не + открывался. Все числа в отчёте сняты на этом прогоне: размер `.git` (`du`), + умолчания журнала PocketBase (исходники модуля), 86 072 байта прироста + бинарника (замер прохода `ops`), таблица ответов мультиплексора (`go run`). +3. **Поимённая сверка с руководствами по стилю Go, TypeScript и Vue не + задавалась ни одним проходом.** Различение «идиоматично против + распространено» не спрашивает никто — прохода про идиоматичность в конвейере + нет. Для этой задачи это ощутимее обычного: `web/` — новая для проекта + экосистема, и её первый код никем на идиоматичность не смотрен. +4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера + нет.** Проход независимой реализации снят по стоимости, а не по замеру; + «не знаю, чего не знаю» здесь не достаёт никто. + +Формулировка «критичных проблем не обнаружено» к этому отчёту неприменима: три +находки блокируют мердж, и ещё шесть проверенных срезаны потолком и названы +поимённо выше. diff --git a/openspec/changes/archive/2026-08-15-spa-skeleton/specs/access/spec.md b/openspec/changes/archive/2026-08-15-spa-skeleton/specs/access/spec.md new file mode 100644 index 0000000..c1d8004 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-spa-skeleton/specs/access/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Приложение отдаётся без сессии + +Сервис SHALL отдавать разметку приложения и её ресурсы без сессии. Перечень +адресов, открытых анонимно, пополняется ими: прежде в нём стояли только проба +здоровья и метрики. + +Причина в самом входе: человек, ещё не вошедший, дошёл бы до входа только через +приложение, а закрытая сессией разметка отдала бы ему отказ вместо экрана. Цена +открытости названа здесь же и невелика — ни разметка, ни ресурсы содержимого +записей не несут: они одинаковы для всех и собираются до всякого запроса. + +Открытость MUST не касаться данных: всякий адрес под корнем приложения +по-прежнему требует сессии, и приложение, открытое анонимно, не получает ни +одной записи. + +#### Scenario: Разметка доступна анонимно + +- **WHEN** запрос приходит на корень сервиса без сессии +- **THEN** ответ имеет код `200` +- **AND** тело ответа — разметка приложения + +#### Scenario: Ресурс приложения доступен анонимно + +- **WHEN** запрос приходит на ресурс приложения без сессии +- **THEN** ответ имеет код `200` + +#### Scenario: Данные анонимно не отдаются + +- **GIVEN** приложение открыто без сессии +- **WHEN** оно спрашивает список записей +- **THEN** ответ имеет код `401` diff --git a/openspec/changes/archive/2026-08-15-spa-skeleton/specs/webapp/spec.md b/openspec/changes/archive/2026-08-15-spa-skeleton/specs/webapp/spec.md new file mode 100644 index 0000000..9804038 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-spa-skeleton/specs/webapp/spec.md @@ -0,0 +1,271 @@ +## ADDED Requirements + +### Requirement: Приложение отдаётся самим бинарником + +Сервис SHALL отдавать разметку приложения и её ресурсы из самого бинарника. +Каталога с собранными файлами рядом с бинарником MUST не требоваться, и внешнего +веб-сервера под раздачу MUST не заводиться. + +Причина в выкладке: сервис едет на сервер одним образом, и второй разворачиваемый +артефакт рядом с ним завёл бы вторую точку, где выкладка расходится с собранным. +Бинарник, которому нужен каталог рядом, отдаёт пустую страницу молча — каталог +либо забыли положить, либо положили не тот, и различить это снаружи нечем. + +#### Scenario: Приложение открывается у бинарника без каталога рядом + +- **GIVEN** бинарник запущен в каталоге, где нет ничего, кроме его настроек +- **WHEN** браузер спрашивает корень сервиса +- **THEN** ответ имеет код `200` +- **AND** тело ответа — разметка приложения + +#### Scenario: Ресурс приложения отдаётся оттуда же + +- **GIVEN** разметка приложения названа своим ресурсом +- **WHEN** браузер спрашивает этот ресурс +- **THEN** ответ имеет код `200` +- **AND** тело ответа — содержимое ресурса + +### Requirement: Сервис, поднятый без собранного приложения, говорит об этом + +Сервис SHALL отвечать кодом `503` на всяком пути, где он отдал бы разметку, если +собранного приложения в нём нет, и MUST писать об этом строку в журнал при +подъёме. Отвечать `404` и молчать он MUST не вправе: «приложения нет» и «такого +адреса нет» — разные состояния, и первое чинится сборкой, а не поиском опечатки в +адресе. + +Состояние это возможно только у собранного мимо набора проверок: и набор +проверок, и сборка образа собирают приложение раньше бинарника. Проба здоровья +при этом остаётся зелёной: она отвечает за то, работает ли сервис, а сервис в +этом состоянии принимает записи и расшифровывает их — не работает только показ. + +#### Scenario: Пустая сборка отвечает отказом, а не отсутствием адреса + +- **GIVEN** бинарник собран без собранного приложения +- **WHEN** браузер спрашивает корень сервиса +- **THEN** ответ имеет код `503` + +#### Scenario: Отсутствие сборки видно в журнале + +- **GIVEN** бинарник собран без собранного приложения +- **WHEN** сервис поднимается +- **THEN** в журнале есть строка о том, что приложение не собрано + +#### Scenario: Проба здоровья остаётся зелёной + +- **GIVEN** бинарник собран без собранного приложения +- **WHEN** запрос приходит на пробу здоровья +- **THEN** ответ имеет код `200` + +### Requirement: Неизвестный путь вне корней открывает приложение + +Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит +ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни +перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/auth` у входа, +`/_` у панели, — отдельными адресами стоят `/health` и `/metrics`. + +Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с +косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app` +достался бы посторонний `/apple`, а по одному лишь префиксу с косой чертой голый +`/api` не достался бы никому и уехал бы разметкой. + +Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ +контракта остаётся отказом контракта и уходит той формой, которой этот корень +отвечает и сегодня. Иначе программа, ошибшаяся адресом под корнем приложения, +получила бы разметку с кодом `200` вместо отказа с машиночитаемым кодом — и +приняла бы её за ответ. + +Путь **под каталогом ресурсов** — тем, который наполняет сборщик, — разметкой не +подменяется: не совпавший с файлом, он MUST отвечать `404`. Иначе разметка +прежней сборки, назвавшая ресурс, которого в новой сборке уже нет, получает на +него `200` и разметку вместо ресурса: браузер отвергнет её по типу содержимого, +человек увидит пустой экран, а в кодах ответов сервиса не останется ничего. + +Открывающими страницу считаются `GET` и `HEAD`, и только они; прочие методы MUST +отвечать `405`. + +#### Scenario: Обновление страницы посреди приложения открывает тот же экран + +- **GIVEN** приложение открыто на своём маршруте +- **WHEN** браузер спрашивает этот путь заново +- **THEN** ответ имеет код `200` +- **AND** тело ответа — разметка приложения + +#### Scenario: Голый корень разметкой не подменяется + +- **WHEN** запрос приходит на путь, совпадающий с корнем сервиса точно и без + косой черты +- **THEN** тело ответа — не разметка приложения + +#### Scenario: Посторонний путь, начинающийся именем корня, открывает приложение + +- **WHEN** запрос приходит на путь, который начинается именем корня, но не + отделён от него косой чертой +- **THEN** ответ имеет код `200` +- **AND** тело ответа — разметка приложения + +#### Scenario: Неизвестный путь под корнем приложения отвечает отказом + +- **GIVEN** человек вошёл и предъявил сессию +- **WHEN** он спрашивает неизвестный путь под корнем приложения +- **THEN** ответ имеет код `404` +- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка + +#### Scenario: Неизвестный путь под корнем приложения без сессии отвечает как все прочие его адреса + +- **WHEN** запрос приходит на неизвестный путь под корнем приложения без сессии +- **THEN** ответ имеет код `401` +- **AND** тело ответа — не разметка приложения + +#### Scenario: Неизвестный путь под корнем хранилища отвечает отказом + +- **WHEN** запрос приходит на неизвестный путь под корнем хранилища +- **THEN** тело ответа — не разметка приложения + +#### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой + +- **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет +- **THEN** ответ имеет код `404` +- **AND** тело ответа — не разметка приложения + +#### Scenario: Наблюдение приложением не подменяется + +- **WHEN** запрос приходит на пробу здоровья +- **THEN** отвечает проба здоровья, а не приложение + +#### Scenario: Чужой метод отвечает отказом + +- **WHEN** на неизвестный путь вне корней приходит запрос методом, которым + страницу не открывают +- **THEN** ответ имеет код `405` + +#### Scenario: Проверка доступности разметку получает + +- **WHEN** разметку спрашивают методом `HEAD` +- **THEN** ответ имеет код `200` + +### Requirement: Обновлённое приложение доходит до браузера + +Сервис SHALL отдавать **ресурс из каталога, который наполняет сборщик**, с долгим +сроком хранения и пометкой «неизменяемо», а всё прочее, включая разметку, — с +требованием спрашивать заново. Признак — каталог, а не вид файла: имена в нём +строит сборщик и несёт в них отпечаток содержимого, поэтому изменившийся ресурс +приезжает под новым именем и прежний ответ устареть не может. + +Долгий срок MUST не доставаться файлу вне этого каталога. Разметка, иконка, +манифест и всякий файл с постоянным именем меняются под тем же именем, и +отозвать у браузера выданное «неизменяемо» нечем: выложенное обновление не дойдёт +до того, кто уже открывал приложение, пока он не почистит хранилище браузера +руками. Заметить это со стороны сервиса нечем — запросов он больше не увидит. + +Отпечаток в именах — свойство сборки, а не сервиса, и нормой его держит конвенция +приложения, а не эта спека: сервис имён не выбирает и их нарушения не заметит. +Здесь названо только то, что делает с ними сам сервис. + +#### Scenario: Ресурс сборщика отдаётся с долгим сроком + +- **WHEN** браузер спрашивает ресурс из каталога, который наполняет сборщик +- **THEN** ответ несёт долгий срок хранения и пометку «неизменяемо» + +#### Scenario: Разметка спрашивается заново + +- **WHEN** браузер спрашивает разметку приложения +- **THEN** ответ несёт требование спрашивать её заново +- **AND** пометки «неизменяемо» в ответе нет + +#### Scenario: Файл с постоянным именем долгого срока не получает + +- **WHEN** браузер спрашивает файл сборки, лежащий вне каталога ресурсов +- **THEN** ответ несёт требование спрашивать его заново + +### Requirement: Путь, отданный приложению, в журнал не идёт + +Сервис SHALL записывать о таком запросе **исход из закрытого перечня** — +разметка, ресурс, отказ — и длину пути, а самого пути MUST не записывать ни в +один свой журнал. То же относится к меткам метрик. + +Причина в том, кто этот путь выбирает. До появления раздачи путь вне корней +ловил отказ маршрутизатора; теперь он успешный ответ, и множеством его значений +распоряжается спрашивающий — дословная запись сделала бы журнал местом, куда +аноним пишет свой текст произвольной длины. Правило того же рода у сервиса уже +есть: причина отказа, пришедшая от провайдера строкой запроса, приводится к +перечню известных. + +Журналов при этом **два**: свой и журнал хранилища, куда библиотека кладёт путь +целиком вместе с адресом отправителя. Требование относится к обоим. + +#### Scenario: Путь не доезжает до журнала + +- **WHEN** приходит запрос на путь вне корней сервиса +- **THEN** записи о нём не несут этого пути +- **AND** несут исход и длину пути + +#### Scenario: Длинный путь журнал не наполняет + +- **WHEN** приходит запрос на путь длиной в тысячу знаков +- **THEN** записи о нём не растут вместе с длиной пути + +### Requirement: Сервис объявляет, какая сборка приложения в нём вшита + +Сервис SHALL писать при подъёме отпечаток вшитой сборки. Он же MUST уходить +меткой ответа с разметкой: сама разметка отдаётся с требованием спрашивать её +заново, и без метки браузер получает полное тело вместо подтверждения — вшитый +файл не несёт времени правки вовсе. + +Без отпечатка «не та сборка» неотличима от «той»: вне набора проверок порядок +шагов ничем не задан, и бинарник собирается с тем, что лежало в каталоге с +прошлого раза. Приложение при этом открывается и ведёт себя как прежняя версия, +а искать причину человек идёт в код сервиса. + +#### Scenario: Отпечаток виден при подъёме + +- **WHEN** сервис поднимается с собранным приложением +- **THEN** в журнале есть отпечаток вшитой сборки + +#### Scenario: Разметка несёт метку ответа + +- **WHEN** браузер спрашивает разметку приложения +- **THEN** ответ несёт метку, по которой её можно спросить заново + +### Requirement: Открытое приложение показывает вошедшего + +Приложение SHALL спрашивать сервис, кто вошёл, и показывать его имя. Отказ +`401` MUST уводить ко входу: человек, ещё не вошедший, получает его на всяком +адресе данных, и это его штатное состояние, а не поломка. + +Всякий **иной** отказ и сорванный запрос MUST ко входу не уводить, а показываться +строкой о неудаче. Трактовка «любой отказ значит не вошёл» замкнула бы круг: +приложение ушло бы ко входу, вход вернул бы человека в приложение, и на отказе +сервиса или ограничителя частоты круг пошёл бы заново. + +Имя вошедшего берётся ответом сервиса, а не кукой сессии: кука недоступна +скриптам страницы, и другого способа узнать вошедшего у приложения нет. Имени в +ответе может не быть вовсе — тогда приложение MUST показать, что вход выполнен, +и MUST не подставлять вместо имени адрес почты: его в ответе нет по норме +`access`. + +#### Scenario: Вошедший виден + +- **GIVEN** человек вошёл и получил куку сессии +- **WHEN** он открывает приложение +- **THEN** приложение показывает его имя + +#### Scenario: Не вошедшему предлагается вход + +- **GIVEN** сессии у человека нет +- **WHEN** он открывает приложение +- **THEN** приложение ведёт его ко входу + +#### Scenario: Отказ сервиса ко входу не уводит + +- **GIVEN** сервис отвечает на вопрос о вошедшем отказом, который не является + отсутствием сессии +- **WHEN** человек открывает приложение +- **THEN** приложение показывает строку о неудаче +- **AND** ко входу оно не уводит + +#### Scenario: Учётная запись без имени + +- **GIVEN** человек вошёл, а имени у его учётной записи нет +- **WHEN** он открывает приложение +- **THEN** приложение показывает, что вход выполнен +- **AND** адреса почты на экране нет diff --git a/openspec/changes/archive/2026-08-15-spa-skeleton/tasks.md b/openspec/changes/archive/2026-08-15-spa-skeleton/tasks.md new file mode 100644 index 0000000..3c1f462 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-spa-skeleton/tasks.md @@ -0,0 +1,147 @@ +## 1. Каталог приложения и его сборка + +- [x] 1.1 Завести `web/` с `package.json`, файлом замка, настройкой Vite и + `index.html`; выходной каталог сборщика — `web/embed/dist` +- [x] 1.2 Команда сборки приложения гоняет проверку типов и сборку одним вызовом + (`vue-tsc` входит в команду сборки, а не стоит отдельным шагом) +- [x] 1.3 В git лежит `web/embed/.gitkeep` — этажом выше выходного каталога, чтобы + очистка перед сборкой его не уносила; собранное и каталог зависимостей внесены + в `.gitignore` +- [x] 1.4 Написан экран-заглушка: показывает имя вошедшего, на `401` ведёт ко + входу, на прочий отказ показывает строку о неудаче; одна таблица маршрутов + через `createRouter`, адреса обычные (`createWebHistory`) +- [x] 1.5 Обращение к API идёт через одну свою обёртку над `fetch`; она же + единственное место, где читается код ответа +- [x] 1.6 Заведён Biome: форматирование и статический анализ кода приложения. + Не покрывает разметку однофайловых компонентов — сказать строкой и остановиться, + замена инструмента решается человеком +- [x] 1.7 Заведены юнит-тесты Vue: показ вошедшего, увод ко входу на `401`, + строка о неудаче на прочем отказе, учётная запись без имени + +## 2. Раздача приложения из бинарника + +- [x] 2.1 `web/embed.go` вшивает `web/embed` целиком (`all:`), включая метку + пустого каталога +- [x] 2.2 Перечень корней сервиса заведён единой точкой в пакете транспорта: + `/api`, `/app`, `/auth`, `/_` и отдельные адреса `/health`, `/metrics` +- [x] 2.2.1 Наши маршруты вешаются **из перечня**: проба здоровья, метрики, + группа приложения, группа входа. Корни библиотеки остаются её литералами и + держатся своим тестом; второе перечисление адресов наблюдения в уровне журнала + снято +- [x] 2.3 Принадлежность корню — точное совпадение либо префикс вместе с косой + чертой; оба условия проверяются +- [x] 2.4 Обработчик приложения зарегистрирован маршрутом `/` (тем самым занимает + место библиотечного отказа) и принимает файловую систему параметром +- [x] 2.5 Порядок обработчика: корень сервиса или адрес наблюдения — отказ формой + библиотеки; метод не `GET` и не `HEAD` — `405`; несовпавший путь под каталогом + ресурсов — `404`; совпавший файл — файл; прочее — разметка +- [x] 2.6 Заголовки кэширования назначены по каталогу: ресурсы сборщика — год и + «неизменяемо», всё прочее, включая разметку, — спрашивать заново +- [x] 2.7 Отсутствие собранного приложения даёт `503` с честной страницей и + строку в журнале при подъёме, а не `404` молча; проба здоровья остаётся зелёной +- [x] 2.8 При подъёме в журнал уходит отпечаток вшитой разметки +- [x] 2.9 Строка журнала корневого маршрута несёт исход и длину пути, но не сам + путь +- [x] 2.10 Разметка и ресурсы отдаются без сессии; адреса данных под корнем + приложения сессии по-прежнему требуют + +## 3. Сборка и выкладка + +- [x] 3.1 Шаг сборки приложения заведён в `Taskfile.yml` и стоит **первым** в + наборе проверок, до сборки Go; рядом — шаги Biome и юнит-тестов приложения +- [x] 3.1.1 Проверки приложения гоняются тем же контейнером, что и сборка; + второго окружения не заводится +- [x] 3.2 Шаг зовёт установщик и сборщик контейнером, а не из `PATH`: имя образа + берётся из `Dockerfile`, контейнер ходит под вызвавшим пользователем, кэш + установщика уведён в каталог под `/tmp` +- [x] 3.3 Зависимости ставятся из файла замка командой, которая его не правит; ту + же команду зовёт ступень `Dockerfile` +- [x] 3.4 Недостающий docker и отказ сети или реестра — отказ окружения, код 3; + красная сборка приложения — код 1 +- [x] 3.5 В `Dockerfile` добавлена ступень сборки приложения перед сборкой + бинарника; в рабочий слой Node не попадает +- [x] 3.6 Вес выяснен, время замера снято с задачи решением владельца + 2026-08-15: финальный образ от ступени приложения не растёт вовсе (Node в него + не копируется), вшитое добавляет к бинарнику 86 072 байта. Время сборки + остаётся неизвестным сознательно +- [x] 3.7 Последствие ADR-2026-08-11-spa-on-vue поправлено: требованием к машине + разработчика становится docker, а не Node + +## 4. Проверки + +- [x] 4.1 Тест: неизвестный путь вне корней отдаёт разметку с кодом `200` +- [x] 4.2 Тест: неизвестный путь под каждым корнем сервиса разметки не отдаёт +- [x] 4.3 Тест: голый корень без косой черты разметки не отдаёт, а посторонний + путь, начинающийся именем корня без косой черты, — отдаёт +- [x] 4.4 Тест: неизвестный путь под корнем приложения отвечает `404` с сессией и + `401` без неё +- [x] 4.5 Тест: несуществующий файл под каталогом ресурсов отвечает `404` +- [x] 4.6 Тест: проба здоровья и метрики приложением не подменяются +- [x] 4.7 Тест: чужой метод отвечает `405`, а `HEAD` на разметку — `200` +- [x] 4.8 Тест: разметка и ресурс отдаются без сессии, а список записей — нет +- [x] 4.9 Тест: у разметки стоит требование спрашивать заново, у ресурса из + каталога сборщика — год и «неизменяемо» +- [x] 4.10 Тест: пустая сборка даёт `503`, а не `404`, и проба здоровья при этом + зелёная +- [x] 4.11 Юнит-тесты приложения краснеют на сломанном экране: намеренная поломка + показа вошедшего роняет набор проверок +- [x] 4.12 `task gate` зелёный целиком + +## 5. Документы + +- [x] 5.1 `docs/conventions/web-ui.md` — снята оговорка «кода приложения ещё + нет»; дописаны правила: отпечаток в именах ресурсов, несовпавший ресурс отвечает + `404`, зависимости ставятся из файла замка +- [x] 5.2 `docs/architecture.md` — компонент приложения, внешняя зависимость + сборки, новая capability и перечень корней в единых точках +- [x] 5.3 `docs/database.md` — срок хранения ресурсов в настройках с числовым + значением +- [x] 5.4 `CLAUDE.md` — команды и требования к машине разработчика, состав гейта + вместе с проверками приложения, второй сетезависимый шаг названный поимённо +- [x] 5.5 `docs/review.md` — заведён род узла «раздача собранной статики и шаг + сборки приложения» в типовых узлах +- [x] 5.6 `docs/conventions/go-linters.md` либо соседняя запись — чем проверяется + код приложения: Biome и юнит-тесты, что каждый ловит и где настроен + +## Критерии приёмки + +Дословно из записи задачи `spa-skeleton`: + +- Статика собирается одной командой и вшивается в бинарник: запущенный бинарник + отдаёт приложение без каталога рядом. Оракул — сборка, запуск бинарника из + пустого каталога, запрос корня возвращает разметку приложения. +- Образ собирается на чистой машине без предустановленного окружения фронтенда. + Оракул — `task image` без локально установленных зависимостей фронтенда. +- Приложение показывает, кто вошёл, и обращается к API живого сервера. Оракул — + тест либо ручная проверка на запущенном сервере с сессией. +- Шаг сборки статики входит в `task gate` и краснеет при ошибке сборки. Оракул — + намеренно сломанный исходник роняет `task gate`. +- Обновление страницы на любом маршруте приложения открывает тот же экран, а + адрес внутри корня сервиса в приложение не проваливается. Оракул — тест на три + запроса: неизвестный путь вне корней отдаёт разметку, неизвестный путь внутри + `/api/` и внутри `/app/` отдаёт ошибку контракта. + +Рубрика ревью дизайна, тем же списком: + +- Перечень корней живёт одной точкой, и добавление корня не требует правки в двух + местах. +- Запрос несуществующего файла под каталогом ресурсов отвечает `404`, а не + разметкой с `200`. +- После прогона сборки приложения `git status` чист по `web/`. +- После `task gate` `git status` чист по файлу замка зависимостей. +- Отсутствие сборки громкое: код ответа, страница и строка журнала; «сборки нет» + отличается от «файла нет». +- Долгий неотзываемый срок ставится только файлу из каталога ресурсов сборщика. +- Разметка и ресурсы отдаются анонимно, данные под корнем приложения — нет; по + анонимному ответу нельзя узнать, вошёл ли кто-то. +- В бинарник попадает только собранное: ни карт исходников, ни каталога + зависимостей, ни файлов вне выходного каталога. +- Отпечаток вшитой сборки виден строкой журнала при подъёме. +- Шаг сборки следует словарю кодов: недостающий инструмент и отказ сети — 3, + красная сборка — 1; молчаливого пропуска нет. +- Ступень сборки не протекает в рабочий слой образа, а версия сборочного + окружения объявлена одним местом. +- Файловая система строится один раз, а не на каждый запрос; поведение на `HEAD` + определено. +- Запрос на путь длиной в килобайт не добавляет значения ни метке метрики, ни + строке журнала. diff --git a/openspec/specs/access/spec.md b/openspec/specs/access/spec.md index 6d17ad0..0c56f34 100644 --- a/openspec/specs/access/spec.md +++ b/openspec/specs/access/spec.md @@ -446,3 +446,34 @@ MUST отвечать отказом `401`, когда сессии нет. Св - **WHEN** приложение спрашивает, кто вошёл - **THEN** адреса почты в ответе нет +### Requirement: Приложение отдаётся без сессии + +Сервис SHALL отдавать разметку приложения и её ресурсы без сессии. Перечень +адресов, открытых анонимно, пополняется ими: прежде в нём стояли только проба +здоровья и метрики. + +Причина в самом входе: человек, ещё не вошедший, дошёл бы до входа только через +приложение, а закрытая сессией разметка отдала бы ему отказ вместо экрана. Цена +открытости названа здесь же и невелика — ни разметка, ни ресурсы содержимого +записей не несут: они одинаковы для всех и собираются до всякого запроса. + +Открытость MUST не касаться данных: всякий адрес под корнем приложения +по-прежнему требует сессии, и приложение, открытое анонимно, не получает ни +одной записи. + +#### Scenario: Разметка доступна анонимно + +- **WHEN** запрос приходит на корень сервиса без сессии +- **THEN** ответ имеет код `200` +- **AND** тело ответа — разметка приложения + +#### Scenario: Ресурс приложения доступен анонимно + +- **WHEN** запрос приходит на ресурс приложения без сессии +- **THEN** ответ имеет код `200` + +#### Scenario: Данные анонимно не отдаются + +- **GIVEN** приложение открыто без сессии +- **WHEN** оно спрашивает список записей +- **THEN** ответ имеет код `401` diff --git a/openspec/specs/webapp/spec.md b/openspec/specs/webapp/spec.md new file mode 100644 index 0000000..fa3ad7d --- /dev/null +++ b/openspec/specs/webapp/spec.md @@ -0,0 +1,282 @@ +# webapp Specification + +## Purpose + +Приложение в браузере: чем сервис его отдаёт, каким адресом оно открывается, что +делает обновление страницы посреди него и что человек видит, открыв его. + +Спека отвечает за **сервис**, а не за сборщик: правило неизвестного пути, срок +хранения ответов, поведение при несобранном приложении и то, что уходит в журнал. +Отпечаток в именах ресурсов — свойство сборки, и его дом — конвенция приложения. + +## Requirements + +### Requirement: Приложение отдаётся самим бинарником + +Сервис SHALL отдавать разметку приложения и её ресурсы из самого бинарника. +Каталога с собранными файлами рядом с бинарником MUST не требоваться, и внешнего +веб-сервера под раздачу MUST не заводиться. + +Причина в выкладке: сервис едет на сервер одним образом, и второй разворачиваемый +артефакт рядом с ним завёл бы вторую точку, где выкладка расходится с собранным. +Бинарник, которому нужен каталог рядом, отдаёт пустую страницу молча — каталог +либо забыли положить, либо положили не тот, и различить это снаружи нечем. + +#### Scenario: Приложение открывается у бинарника без каталога рядом + +- **GIVEN** бинарник запущен в каталоге, где нет ничего, кроме его настроек +- **WHEN** браузер спрашивает корень сервиса +- **THEN** ответ имеет код `200` +- **AND** тело ответа — разметка приложения + +#### Scenario: Ресурс приложения отдаётся оттуда же + +- **GIVEN** разметка приложения названа своим ресурсом +- **WHEN** браузер спрашивает этот ресурс +- **THEN** ответ имеет код `200` +- **AND** тело ответа — содержимое ресурса + +### Requirement: Сервис, поднятый без собранного приложения, говорит об этом + +Сервис SHALL отвечать кодом `503` на всяком пути, где он отдал бы разметку, если +собранного приложения в нём нет, и MUST писать об этом строку в журнал при +подъёме. Отвечать `404` и молчать он MUST не вправе: «приложения нет» и «такого +адреса нет» — разные состояния, и первое чинится сборкой, а не поиском опечатки в +адресе. + +Состояние это возможно только у собранного мимо набора проверок: и набор +проверок, и сборка образа собирают приложение раньше бинарника. Проба здоровья +при этом остаётся зелёной: она отвечает за то, работает ли сервис, а сервис в +этом состоянии принимает записи и расшифровывает их — не работает только показ. + +#### Scenario: Пустая сборка отвечает отказом, а не отсутствием адреса + +- **GIVEN** бинарник собран без собранного приложения +- **WHEN** браузер спрашивает корень сервиса +- **THEN** ответ имеет код `503` + +#### Scenario: Отсутствие сборки видно в журнале + +- **GIVEN** бинарник собран без собранного приложения +- **WHEN** сервис поднимается +- **THEN** в журнале есть строка о том, что приложение не собрано + +#### Scenario: Проба здоровья остаётся зелёной + +- **GIVEN** бинарник собран без собранного приложения +- **WHEN** запрос приходит на пробу здоровья +- **THEN** ответ имеет код `200` + +### Requirement: Неизвестный путь вне корней открывает приложение + +Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит +ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни +перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/auth` у входа, +`/_` у панели, — отдельными адресами стоят `/health` и `/metrics`. + +Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с +косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app` +достался бы посторонний `/apple`, а по одному лишь префиксу с косой чертой голый +`/api` не достался бы никому и уехал бы разметкой. + +Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ +контракта остаётся отказом контракта и уходит той формой, которой этот корень +отвечает и сегодня. Иначе программа, ошибшаяся адресом под корнем приложения, +получила бы разметку с кодом `200` вместо отказа с машиночитаемым кодом — и +приняла бы её за ответ. + +Путь **под каталогом ресурсов** — тем, который наполняет сборщик, — разметкой не +подменяется: не совпавший с файлом, он MUST отвечать `404`. Иначе разметка +прежней сборки, назвавшая ресурс, которого в новой сборке уже нет, получает на +него `200` и разметку вместо ресурса: браузер отвергнет её по типу содержимого, +человек увидит пустой экран, а в кодах ответов сервиса не останется ничего. + +Открывающими страницу считаются `GET` и `HEAD`, и только они; прочие методы MUST +отвечать `405`. + +#### Scenario: Обновление страницы посреди приложения открывает тот же экран + +- **GIVEN** приложение открыто на своём маршруте +- **WHEN** браузер спрашивает этот путь заново +- **THEN** ответ имеет код `200` +- **AND** тело ответа — разметка приложения + +#### Scenario: Голый корень разметкой не подменяется + +- **WHEN** запрос приходит на путь, совпадающий с корнем сервиса точно и без + косой черты +- **THEN** тело ответа — не разметка приложения + +#### Scenario: Посторонний путь, начинающийся именем корня, открывает приложение + +- **WHEN** запрос приходит на путь, который начинается именем корня, но не + отделён от него косой чертой +- **THEN** ответ имеет код `200` +- **AND** тело ответа — разметка приложения + +#### Scenario: Неизвестный путь под корнем приложения отвечает отказом + +- **GIVEN** человек вошёл и предъявил сессию +- **WHEN** он спрашивает неизвестный путь под корнем приложения +- **THEN** ответ имеет код `404` +- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка + +#### Scenario: Неизвестный путь под корнем приложения без сессии отвечает как все прочие его адреса + +- **WHEN** запрос приходит на неизвестный путь под корнем приложения без сессии +- **THEN** ответ имеет код `401` +- **AND** тело ответа — не разметка приложения + +#### Scenario: Неизвестный путь под корнем хранилища отвечает отказом + +- **WHEN** запрос приходит на неизвестный путь под корнем хранилища +- **THEN** тело ответа — не разметка приложения + +#### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой + +- **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет +- **THEN** ответ имеет код `404` +- **AND** тело ответа — не разметка приложения + +#### Scenario: Наблюдение приложением не подменяется + +- **WHEN** запрос приходит на пробу здоровья +- **THEN** отвечает проба здоровья, а не приложение + +#### Scenario: Чужой метод отвечает отказом + +- **WHEN** на неизвестный путь вне корней приходит запрос методом, которым + страницу не открывают +- **THEN** ответ имеет код `405` + +#### Scenario: Проверка доступности разметку получает + +- **WHEN** разметку спрашивают методом `HEAD` +- **THEN** ответ имеет код `200` + +### Requirement: Обновлённое приложение доходит до браузера + +Сервис SHALL отдавать **ресурс из каталога, который наполняет сборщик**, с долгим +сроком хранения и пометкой «неизменяемо», а всё прочее, включая разметку, — с +требованием спрашивать заново. Признак — каталог, а не вид файла: имена в нём +строит сборщик и несёт в них отпечаток содержимого, поэтому изменившийся ресурс +приезжает под новым именем и прежний ответ устареть не может. + +Долгий срок MUST не доставаться файлу вне этого каталога. Разметка, иконка, +манифест и всякий файл с постоянным именем меняются под тем же именем, и +отозвать у браузера выданное «неизменяемо» нечем: выложенное обновление не дойдёт +до того, кто уже открывал приложение, пока он не почистит хранилище браузера +руками. Заметить это со стороны сервиса нечем — запросов он больше не увидит. + +Отпечаток в именах — свойство сборки, а не сервиса, и нормой его держит конвенция +приложения, а не эта спека: сервис имён не выбирает и их нарушения не заметит. +Здесь названо только то, что делает с ними сам сервис. + +#### Scenario: Ресурс сборщика отдаётся с долгим сроком + +- **WHEN** браузер спрашивает ресурс из каталога, который наполняет сборщик +- **THEN** ответ несёт долгий срок хранения и пометку «неизменяемо» + +#### Scenario: Разметка спрашивается заново + +- **WHEN** браузер спрашивает разметку приложения +- **THEN** ответ несёт требование спрашивать её заново +- **AND** пометки «неизменяемо» в ответе нет + +#### Scenario: Файл с постоянным именем долгого срока не получает + +- **WHEN** браузер спрашивает файл сборки, лежащий вне каталога ресурсов +- **THEN** ответ несёт требование спрашивать его заново + +### Requirement: Путь, отданный приложению, в журнал не идёт + +Сервис SHALL записывать о таком запросе **исход из закрытого перечня** — +разметка, ресурс, отказ — и длину пути, а самого пути MUST не записывать ни в +один свой журнал. То же относится к меткам метрик. + +Причина в том, кто этот путь выбирает. До появления раздачи путь вне корней +ловил отказ маршрутизатора; теперь он успешный ответ, и множеством его значений +распоряжается спрашивающий — дословная запись сделала бы журнал местом, куда +аноним пишет свой текст произвольной длины. Правило того же рода у сервиса уже +есть: причина отказа, пришедшая от провайдера строкой запроса, приводится к +перечню известных. + +Журналов при этом **два**: свой и журнал хранилища, куда библиотека кладёт путь +целиком вместе с адресом отправителя. Требование относится к обоим. + +#### Scenario: Путь не доезжает до журнала + +- **WHEN** приходит запрос на путь вне корней сервиса +- **THEN** записи о нём не несут этого пути +- **AND** несут исход и длину пути + +#### Scenario: Длинный путь журнал не наполняет + +- **WHEN** приходит запрос на путь длиной в тысячу знаков +- **THEN** записи о нём не растут вместе с длиной пути + +### Requirement: Сервис объявляет, какая сборка приложения в нём вшита + +Сервис SHALL писать при подъёме отпечаток вшитой сборки. Он же MUST уходить +меткой ответа с разметкой: сама разметка отдаётся с требованием спрашивать её +заново, и без метки браузер получает полное тело вместо подтверждения — вшитый +файл не несёт времени правки вовсе. + +Без отпечатка «не та сборка» неотличима от «той»: вне набора проверок порядок +шагов ничем не задан, и бинарник собирается с тем, что лежало в каталоге с +прошлого раза. Приложение при этом открывается и ведёт себя как прежняя версия, +а искать причину человек идёт в код сервиса. + +#### Scenario: Отпечаток виден при подъёме + +- **WHEN** сервис поднимается с собранным приложением +- **THEN** в журнале есть отпечаток вшитой сборки + +#### Scenario: Разметка несёт метку ответа + +- **WHEN** браузер спрашивает разметку приложения +- **THEN** ответ несёт метку, по которой её можно спросить заново + +### Requirement: Открытое приложение показывает вошедшего + +Приложение SHALL спрашивать сервис, кто вошёл, и показывать его имя. Отказ +`401` MUST уводить ко входу: человек, ещё не вошедший, получает его на всяком +адресе данных, и это его штатное состояние, а не поломка. + +Всякий **иной** отказ и сорванный запрос MUST ко входу не уводить, а показываться +строкой о неудаче. Трактовка «любой отказ значит не вошёл» замкнула бы круг: +приложение ушло бы ко входу, вход вернул бы человека в приложение, и на отказе +сервиса или ограничителя частоты круг пошёл бы заново. + +Имя вошедшего берётся ответом сервиса, а не кукой сессии: кука недоступна +скриптам страницы, и другого способа узнать вошедшего у приложения нет. Имени в +ответе может не быть вовсе — тогда приложение MUST показать, что вход выполнен, +и MUST не подставлять вместо имени адрес почты: его в ответе нет по норме +`access`. + +#### Scenario: Вошедший виден + +- **GIVEN** человек вошёл и получил куку сессии +- **WHEN** он открывает приложение +- **THEN** приложение показывает его имя + +#### Scenario: Не вошедшему предлагается вход + +- **GIVEN** сессии у человека нет +- **WHEN** он открывает приложение +- **THEN** приложение ведёт его ко входу + +#### Scenario: Отказ сервиса ко входу не уводит + +- **GIVEN** сервис отвечает на вопрос о вошедшем отказом, который не является + отсутствием сессии +- **WHEN** человек открывает приложение +- **THEN** приложение показывает строку о неудаче +- **AND** ко входу оно не уводит + +#### Scenario: Учётная запись без имени + +- **GIVEN** человек вошёл, а имени у его учётной записи нет +- **WHEN** он открывает приложение +- **THEN** приложение показывает, что вход выполнен +- **AND** адреса почты на экране нет diff --git a/web/biome.json b/web/biome.json new file mode 100644 index 0000000..1b641d6 --- /dev/null +++ b/web/biome.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json", + "files": { + "includes": ["src/**", "*.ts", "*.json"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "asNeeded" + } + } +} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..af1e3f9 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,44 @@ +// Package web несёт собранное приложение внутри бинарника. +// +// Каталога рядом с бинарником не требуется, и внешнего веб-сервера под раздачу +// не заводится: сервис едет на сервер одним образом, и второй разворачиваемый +// артефакт рядом с ним завёл бы вторую точку, где выкладка расходится с +// собранным. +package web + +import ( + "embed" + "io/fs" +) + +// Вшивается каталог `embed`, а не `embed/dist`: собранное приложение лежит +// этажом ниже метки `embed/.gitkeep`, и очистка выходного каталога перед каждой +// сборкой метки не касается. Без метки каталога не было бы в git вовсе, и +// `go build ./...` отказывал бы у всякого, кто приложение ни разу не собирал. +// +// Приставка `all:` нужна ради самой метки: без неё файлы, чьё имя начинается с +// точки, не вшиваются. +// +//go:embed all:embed +var embedded embed.FS + +// distDir — где внутри вшитого лежит собранное приложение. +const distDir = "embed/dist" + +// Dist отдаёт собранное приложение файловой системой. +// +// Второе значение говорит, собрано ли оно вообще: пустой каталог — законное +// состояние сборки, прошедшей мимо набора проверок, и сервис обязан сказать о +// нём вслух, а не отдавать пустую страницу. +func Dist() (fs.FS, bool) { + dist, err := fs.Sub(embedded, distDir) + if err != nil { + return nil, false + } + + if _, err := fs.Stat(dist, "index.html"); err != nil { + return dist, false + } + + return dist, true +} diff --git a/web/embed/.gitkeep b/web/embed/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/embed_test.go b/web/embed_test.go new file mode 100644 index 0000000..7e1acc9 --- /dev/null +++ b/web/embed_test.go @@ -0,0 +1,46 @@ +package web + +import ( + "io/fs" + "testing" +) + +// Стык вшивания с раздачей проверяется здесь, и только здесь: проверки самой +// раздачи получают файловую систему параметром и подставляют свою, поэтому +// расхождение объявленной раскладки с настоящей им недоступно по построению. +// +// Раскладка объявлена дважды — выходным каталогом сборщика и `distDir` здесь, — +// и связаны они только совпадением строк. Разойдясь, они дают `503` на каждом +// пути показа при зелёном наборе проверок. +func TestDistMatchesEmbeddedLayout(t *testing.T) { + dist, built := Dist() + + if dist == nil { + t.Fatal("вшитое поддерево недоступно: distDir разошёлся с директивой go:embed") + } + + _, err := fs.Stat(dist, "index.html") + + // Оба исхода законны и различаются одним: собрано приложение или нет. + // Незаконно третье — «собрано, но `Dist` этого не видит». + if built && err != nil { + t.Fatalf("приложение объявлено собранным, а разметки в поддереве нет: %v", err) + } + + if !built && err == nil { + t.Fatal("разметка есть, а приложение объявлено несобранным") + } + + if !built { + t.Skip("приложение не собрано: прогон без шага сборки, проверять нечего") + } +} + +// Метка пустого каталога вшивается вместе с прочим: без неё каталога не было бы +// в git, и сборка Go отказывала бы у всякого, кто приложение ни разу не собирал. +// Приставка `all:` заведена ровно ради неё. +func TestMarkerIsEmbedded(t *testing.T) { + if _, err := fs.Stat(embedded, "embed/.gitkeep"); err != nil { + t.Fatalf("метка пустого каталога не вшита: %v", err) + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..6e464d3 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Расшифровка записей + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..bbfa150 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2967 @@ +{ + "name": "transcriber-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "transcriber-web", + "dependencies": { + "vue": "^3.5.41", + "vue-router": "^5.2.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.8", + "@vitejs/plugin-vue": "^6.0.8", + "@vue/test-utils": "^2.4.11", + "happy-dom": "^20.11.2", + "typescript": "^5.9.3", + "vite": "^8.2.1", + "vitest": "^4.1.10", + "vue-tsc": "^3.3.10" + } + }, + "node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.8.tgz", + "integrity": "sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.8", + "@biomejs/cli-darwin-x64": "2.5.8", + "@biomejs/cli-linux-arm64": "2.5.8", + "@biomejs/cli-linux-arm64-musl": "2.5.8", + "@biomejs/cli-linux-x64": "2.5.8", + "@biomejs/cli-linux-x64-musl": "2.5.8", + "@biomejs/cli-win32-arm64": "2.5.8", + "@biomejs/cli-win32-x64": "2.5.8" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.8.tgz", + "integrity": "sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.8.tgz", + "integrity": "sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.8.tgz", + "integrity": "sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.8.tgz", + "integrity": "sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.8.tgz", + "integrity": "sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.8.tgz", + "integrity": "sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.8.tgz", + "integrity": "sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.8.tgz", + "integrity": "sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", + "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.4.tgz", + "integrity": "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz", + "integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.2.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", + "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", + "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.10.tgz", + "integrity": "sha512-CR7ByBbgPHqhxrioKPOcZBqttaozzLNwtkCzXQ+uF8gLPHnUe03srPnGpdtHD3zp+bq5iyVkZ1WNx7W564RPwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.11.tgz", + "integrity": "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz", + "integrity": "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@babel/types": "^7.29.0", + "ast-kit": "^2.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/happy-dom": { + "version": "20.11.2", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.2.tgz", + "integrity": "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "devOptional": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/nostics": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nostics/-/nostics-1.2.0.tgz", + "integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==", + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz", + "integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.4", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz", + "integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.10.tgz", + "integrity": "sha512-t7IQivQ3oD4D01b7s7a9AWzHAcr3DGBIa/1jZREsFQJcFgSL92gqUkqNiHTYgzTt7QDuJa0I9wCeDwEtZICoBQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.2.0.tgz", + "integrity": "sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^8.0.0", + "@vue-macros/common": "^3.1.3", + "@vue/devtools-api": "^8.1.5", + "ast-walker-scope": "^0.9.0", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.2.1", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "muggle-string": "^0.4.1", + "nostics": "^1.1.4", + "pathe": "^2.0.3", + "picomatch": "^4.0.5", + "scule": "^1.3.0", + "tinyglobby": "^0.2.17", + "unplugin": "^3.3.0", + "unplugin-utils": "^0.3.2", + "yaml": "^2.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", + "pinia": "^3.0.4 || ^4.0.2", + "vite": "^7.3.0 || ^8.0.0", + "vue": "^3.5.34 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.10.tgz", + "integrity": "sha512-YaDVxcW+CGtaOt3pZahMG5jYPx0hsUTxyEoPOTSMebcGUXP9lIBabQ14vfKMORb2CqqK5CxsNo/d1d+4IQwiKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.10" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..a3b10a1 --- /dev/null +++ b/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "transcriber-web", + "private": true, + "type": "module", + "scripts": { + "build": "vue-tsc -b && vite build", + "check": "biome check .", + "test": "vitest run" + }, + "dependencies": { + "vue": "^3.5.41", + "vue-router": "^5.2.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.8", + "@vitejs/plugin-vue": "^6.0.8", + "@vue/test-utils": "^2.4.11", + "happy-dom": "^20.11.2", + "typescript": "^5.9.3", + "vite": "^8.2.1", + "vitest": "^4.1.10", + "vue-tsc": "^3.3.10" + } +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..7c2aa3f --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..2f2eff9 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,77 @@ +// Единственное место, где приложение читает код ответа и разбирает тело отказа. +// Экран получает готовый текст, а не `Response`: второй разборщик кодов на +// клиенте разошёлся бы с первым. + +const appRoot = '/app' + +/** Ответ сервиса на вопрос «кто вошёл». */ +export interface Me { + id: string + name: string +} + +/** Единая форма тела отказа на адресах приложения. */ +interface ErrorBody { + error_code?: string + message?: string +} + +/** + * Исход запроса. Отсутствие сессии — свой исход, а не разновидность неудачи: + * экран уводит ко входу только на нём. Всякий прочий отказ ко входу не ведёт, + * иначе отказ сервиса или ограничителя частоты замкнул бы круг «приложение → + * вход → приложение». + */ +export type ApiResult = + | { status: 'ok'; value: T } + | { status: 'unauthorized' } + | { status: 'failed'; message: string } + +const noConnectionMessage = 'Связи нет' +const unknownFailureMessage = 'Сервис не отвечает как надо' + +async function failureMessage(response: Response): Promise { + try { + const body = (await response.json()) as ErrorBody + if (body.message) { + return body.message + } + } catch { + // Тело не разобралось — своего текста под код ответа не сочиняем. + } + return unknownFailureMessage +} + +async function request(path: string): Promise> { + let response: Response + try { + response = await fetch(appRoot + path, { + headers: { Accept: 'application/json' }, + }) + } catch { + // Сорванный запрос — состояние, а не ошибка: показываем строкой. + return { status: 'failed', message: noConnectionMessage } + } + + if (response.status === 401) { + return { status: 'unauthorized' } + } + + if (!response.ok) { + return { status: 'failed', message: await failureMessage(response) } + } + + try { + return { status: 'ok', value: (await response.json()) as T } + } catch { + return { status: 'failed', message: unknownFailureMessage } + } +} + +/** Спрашивает сервис, кто вошёл. Куку сессии браузер шлёт сам. */ +export function fetchMe(): Promise> { + return request('/me') +} + +/** Адрес, которым сервис уводит человека ко входу. */ +export const loginPath = '/auth/login' diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..55a83ce --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import { router } from './router' + +createApp(App).use(router).mount('#app') diff --git a/web/src/router.ts b/web/src/router.ts new file mode 100644 index 0000000..7b34318 --- /dev/null +++ b/web/src/router.ts @@ -0,0 +1,12 @@ +import { createRouter, createWebHistory } from 'vue-router' +import HomeScreen from './screens/HomeScreen.vue' + +// Одна таблица маршрутов. Маршруты по файлам не включаем: сборочная надстройка +// роутера на нашем числе экранов не окупается. +// +// Адреса обычные, а не после решётки. Отсюда требование к серверу: неизвестный +// путь вне корней сервиса отдаёт разметку, а не отказ. +export const router = createRouter({ + history: createWebHistory(), + routes: [{ path: '/', name: 'home', component: HomeScreen }], +}) diff --git a/web/src/screens/HomeScreen.test.ts b/web/src/screens/HomeScreen.test.ts new file mode 100644 index 0000000..b18f91a --- /dev/null +++ b/web/src/screens/HomeScreen.test.ts @@ -0,0 +1,86 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import HomeScreen from './HomeScreen.vue' + +// Экран судится по тому, что видит человек, а не по внутреннему состоянию: +// переписанная реализация обязана остаться зелёной. + +const assign = vi.fn() + +vi.stubGlobal('window', { location: { assign } }) + +function answerWith(response: Partial & { json?: () => Promise }) { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)) +} + +function failWith(error: Error) { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(error)) +} + +afterEach(() => { + vi.restoreAllMocks() + assign.mockReset() +}) + +describe('экран показывает вошедшего', () => { + it('показывает имя, когда сессия есть', async () => { + answerWith({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'u1', name: 'Антон' }), + }) + + const screen = mount(HomeScreen) + await flushPromises() + + expect(screen.text()).toContain('Антон') + expect(assign).not.toHaveBeenCalled() + }) + + it('ведёт ко входу, когда сессии нет', async () => { + answerWith({ ok: false, status: 401, json: () => Promise.resolve({}) }) + + mount(HomeScreen) + await flushPromises() + + expect(assign).toHaveBeenCalledWith('/auth/login') + }) + + it('на прочий отказ показывает строку и ко входу не ведёт', async () => { + answerWith({ + ok: false, + status: 500, + json: () => Promise.resolve({ error_code: 'internal', message: 'Внутренняя ошибка сервиса' }), + }) + + const screen = mount(HomeScreen) + await flushPromises() + + expect(screen.text()).toContain('Внутренняя ошибка сервиса') + expect(assign).not.toHaveBeenCalled() + }) + + it('на сорванный запрос показывает, что связи нет', async () => { + failWith(new Error('network down')) + + const screen = mount(HomeScreen) + await flushPromises() + + expect(screen.text()).toContain('Связи нет') + expect(assign).not.toHaveBeenCalled() + }) + + it('учётная запись без имени: вход выполнен, почты на экране нет', async () => { + answerWith({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'u1', name: '' }), + }) + + const screen = mount(HomeScreen) + await flushPromises() + + expect(screen.text()).toContain('Вы вошли') + expect(screen.text()).not.toContain('@') + }) +}) diff --git a/web/src/screens/HomeScreen.vue b/web/src/screens/HomeScreen.vue new file mode 100644 index 0000000..7600763 --- /dev/null +++ b/web/src/screens/HomeScreen.vue @@ -0,0 +1,46 @@ + + + diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..ab2de04 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..86e5e71 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,23 @@ +import vue from '@vitejs/plugin-vue' +// Настройка сборки и настройка проверок живут одним файлом: второй разошёлся бы +// с первым в путях и в наборе подключаемого. +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [vue()], + build: { + // Выходной каталог лежит этажом ниже метки `embed/.gitkeep`: очистка перед + // сборкой метки не касается, и `go build ./...` не отказывает у того, кто + // приложение ни разу не собирал. + outDir: 'embed/dist', + emptyOutDir: true, + // Имя каталога задано явно, а не оставлено умолчанию: на него опираются два + // правила раздачи — отказ вместо разметки на несовпавшем ресурсе и долгий + // срок хранения, — и смена умолчания сборщика выключила бы оба молча. + // Второй дом имени — `controller/http.assetsDir`. + assetsDir: 'assets', + }, + test: { + environment: 'happy-dom', + }, +})