diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..67ca9c07 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +# context for docker/production/Dockerfile (built from the repository +# root). Everything heavy or secret stays out; the builders re-install and +# re-build from source. +.git +**/node_modules +**/.output +**/.nuxt +**/.cache +**/dist +**/coverage +e2e +datagen +server/core/bin +# gitignored local configuration and credentials must never enter the +# build context. +docker/env/*.local.env +docker/github +web/.env diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7c8bd391..24c552a5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -177,6 +177,36 @@ updates: - "minor" - "patch" + - package-ecosystem: "npm" + directory: "/docker/prod/launcher" + schedule: + interval: "weekly" + day: "monday" + commit-message: + prefix: "deps(launcher)" + ignore: + # typescript-eslint caps typescript at <6.1.0 and vue-tsc has not + # caught up with the 7.x native port, so 6.x is the ceiling until + # both support it. + - dependency-name: "typescript" + versions: [">=7.0.0"] + # the runtime is node 24 everywhere (both Dockerfiles and CI), and + # types ahead of it compile against apis that are not there. + - dependency-name: "@types/node" + versions: [">=25.0.0"] + groups: + breaking: + patterns: + - "*" + update-types: + - "major" + safe: + patterns: + - "*" + update-types: + - "minor" + - "patch" + - package-ecosystem: "github-actions" directory: "/" schedule: @@ -206,6 +236,7 @@ updates: - "/server/core" - "/server/auth-realtime" - "/datagen" + - "/docker/prod" schedule: interval: "weekly" day: "monday" @@ -236,6 +267,7 @@ updates: - package-ecosystem: "docker-compose" directories: - "/docker" + - "/docker/prod" - "/e2e" schedule: interval: "weekly" diff --git a/.github/workflows/qa-e2e.yml b/.github/workflows/qa-e2e-dev.yml similarity index 93% rename from .github/workflows/qa-e2e.yml rename to .github/workflows/qa-e2e-dev.yml index 6ccd8d2e..da6dfe25 100644 --- a/.github/workflows/qa-e2e.yml +++ b/.github/workflows/qa-e2e-dev.yml @@ -14,13 +14,13 @@ on: - web/** - server/** - docker/** - - .github/workflows/qa-e2e.yml + - .github/workflows/qa-e2e-dev.yml pull_request: branches: - main paths: - e2e/** - - .github/workflows/qa-e2e.yml + - .github/workflows/qa-e2e-dev.yml # a burst of pushes would otherwise start a full run each, in parallel. # Grouping per branch means a newer push supersedes the older run and @@ -68,7 +68,7 @@ jobs: working-directory: e2e test: - name: Testing e2e + name: Testing e2e (dev stack) runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -103,7 +103,7 @@ jobs: # a runner starts with no layers, so without this the nuxt image is # rebuilt from scratch every run. The cache config itself lives in - # e2e/docker-compose.ci.yaml, applied below. + # e2e/docker-compose.dev.ci.yaml, applied below. - name: Set up Docker buildx uses: docker/setup-buildx-action@v4 @@ -116,7 +116,7 @@ jobs: working-directory: e2e - name: Run e2e tests - run: make e2e E2E_COMPOSE_EXTRA="-f e2e/docker-compose.ci.yaml" + run: make e2e-dev E2E_DEV_COMPOSE_EXTRA="-f e2e/docker-compose.dev.ci.yaml" - name: Upload playwright report if: failure() diff --git a/.github/workflows/qa-e2e-prod.yml b/.github/workflows/qa-e2e-prod.yml new file mode 100644 index 00000000..eef2fba4 --- /dev/null +++ b/.github/workflows/qa-e2e-prod.yml @@ -0,0 +1,101 @@ +name: QA + +# the same suite as qa-e2e-dev.yml, driven against the all-in-one image +# instead of the dev stack's four containers, plus the trust-boundary tests +# that only mean something there. +# +# **It runs on tags, not on branches.** The dev suite is the one that +# guards day-to-day work; this one answers a different question — does the +# artifact we are about to publish actually serve the product, and is its +# trust boundary intact — and that question is only worth its minutes when +# something is being released. Building the image means building nuxt, +# auth-realtime and the launcher from scratch. +# +# The release workflow itself does not exist yet. When it does, this should +# become a gate it depends on rather than a second run triggered by the same +# tag. +on: + push: + tags: + - "v*" + # until there is a tag to push, and for re-verifying an image without + # cutting a release, this is the only way to reach the suite from CI. + workflow_dispatch: + +# unlike the branch workflows, a superseded run here is not stale work: a +# tag is verified once and the answer is wanted, so a later run must not +# cancel it. The group carries a hardcoded per-file prefix because every QA +# workflow shares the display name "QA", so github.workflow would put them +# all in one group. +concurrency: + group: qa-e2e-prod-${{ github.ref }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + test: + name: Testing e2e (prod image) + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Check out the code + uses: actions/checkout@v7 + + - name: Set up pnpm + uses: pnpm/action-setup@v6 + with: + package_json_file: e2e/package.json + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: e2e/pnpm-lock.yaml + + # the image bakes core's binary in, and that binary has to come from + # goreleaser — a plain `go build` is not supported, and the + # Dockerfile's COPY fails without the staged artifact. + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: server/core/go.mod + cache-dependency-path: server/core/go.sum + + - name: Set up goreleaser + uses: goreleaser/goreleaser-action@v7 + with: + install-only: true + + # a runner starts with no layers, and this image builds the nuxt app, + # the auth-realtime bundle and the launcher bundle from scratch. The + # cache flags go on the docker build itself, which compose has no part + # in, so they reach it through the Makefile rather than through a + # compose override as the dev suite's do. + - name: Set up Docker buildx + uses: docker/setup-buildx-action@v4 + + - name: Installing dependencies + run: pnpm install --frozen-lockfile + working-directory: e2e + + - name: Install playwright browsers + run: pnpm exec playwright install --with-deps chromium + working-directory: e2e + + - name: Run e2e tests against the prod image + run: | + make e2e-dev-prod PROD_BUILD_EXTRA="--load \ + --cache-from type=gha,scope=e2e-prod \ + --cache-to type=gha,mode=max,scope=e2e-prod" + + - name: Upload playwright report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-report-prod + path: e2e/playwright-report + retention-days: 7 diff --git a/.github/workflows/qa-prod-launcher.yml b/.github/workflows/qa-prod-launcher.yml new file mode 100644 index 00000000..5048db72 --- /dev/null +++ b/.github/workflows/qa-prod-launcher.yml @@ -0,0 +1,83 @@ +name: QA + +on: + push: + branches: + - main + paths: + - docker/prod/launcher/** + - .github/workflows/qa-prod-launcher.yml + pull_request: + branches: + - main + paths: + - docker/prod/launcher/** + - .github/workflows/qa-prod-launcher.yml + +# a newer push supersedes the still-running workflow of the stale commit +# and cancels it. The group carries a hardcoded per-file prefix because +# every QA workflow shares the display name "QA", so github.workflow +# would put them all in one group and let a run of one workflow cancel +# an in-flight run of another. +concurrency: + group: qa-prod-launcher-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + lint: + name: Linting prod launcher + runs-on: ubuntu-latest + steps: + - name: Check out the code + uses: actions/checkout@v7 + + - name: Set up pnpm + uses: pnpm/action-setup@v6 + with: + package_json_file: docker/prod/launcher/package.json + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: docker/prod/launcher/pnpm-lock.yaml + + - name: Installing dependencies + run: pnpm install --frozen-lockfile + working-directory: docker/prod/launcher + + - name: Run check-lint + run: pnpm run check-lint + working-directory: docker/prod/launcher + + test: + name: Testing prod launcher + runs-on: ubuntu-latest + steps: + - name: Check out the code + uses: actions/checkout@v7 + + - name: Set up pnpm + uses: pnpm/action-setup@v6 + with: + package_json_file: docker/prod/launcher/package.json + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: docker/prod/launcher/pnpm-lock.yaml + + - name: Installing dependencies + run: pnpm install --frozen-lockfile + working-directory: docker/prod/launcher + + - name: Run tests + run: pnpm run test + working-directory: docker/prod/launcher diff --git a/.gitignore b/.gitignore index 48d5a355..8a715b58 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ lerna-debug.log* docker/env/*.local.env docker/github/*.pem +# the goreleaser binary staged for the production image build +docker/prod/.build/ + # dev .yarn/ !.yarn/releases diff --git a/CLAUDE.md b/CLAUDE.md index f11577c1..a7a05ff0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,15 @@ When your changes create orphans: - Remove imports/variables/functions that YOUR changes made unused. - Don't remove pre-existing dead code unless asked. +**Leave no containers running.** If you start a stack or a container while +working — `make start`, `make dev`, `make prod-run`, a bare `docker compose +up`, a one-off `docker run` — stop it before you finish, including when the +task failed or you are interrupted. The `make e2e-*` targets tear their own +stack down; anything you started by hand is yours to stop. A stack left up +holds ports, burns battery and silently changes what the next command sees. +The exception is a stack that was already running when you started, or one +the user asked you to leave up. + The test: Every changed line should trace directly to the user's request. ## 4. Goal-Driven Execution @@ -75,9 +84,10 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - `server/core/` — Go API server (`oxynote-core`). Go module `github.com/oxynote/oxynote/server/core`. Details: [server/CLAUDE.md](server/CLAUDE.md). - `server/auth-realtime/` — Node service (`@oxynote/auth-realtime`, **pnpm**) running Better Auth and a Hocuspocus (Yjs) server in one Hono process. Details: [server/auth-realtime/CLAUDE.md](server/auth-realtime/CLAUDE.md). - `datagen/` — demo-data generator; separate Go module `github.com/oxynote/oxynote/datagen`. Demo/testing only. -- `e2e/` — Playwright end-to-end suite (`@oxynote/e2e`, **pnpm**) plus the docker-compose stack it drives. Not shipped; it exercises the composed product through a real backend built from this repo. Details: [e2e/CLAUDE.md](e2e/CLAUDE.md). +- `e2e/` — Playwright end-to-end suite (`@oxynote/e2e`, **pnpm**) plus the two docker-compose stacks it drives: the dev stack built from this repo, and the all-in-one image `docker/prod/` produces. Not shipped; it exercises the composed product through a real backend. Every file, script and make target belonging to one stack is named `dev` or `prod` — neither is the unnamed default. Details: [e2e/CLAUDE.md](e2e/CLAUDE.md). - `scripts/` — helpers the root Makefile calls. `run-quietly.sh` runs a build step with its output held back, replaying the log only if the step fails. - `docker/` — dev docker-compose stack, Caddyfile, `env/` (committed `*.example.env` templates; `make setup` copies them to the gitignored `*.local.env` files the compose stack reads, and `web.example.env` also to `web/.env` for the host dev server and electron builds), `demo/` (demo-data configs for mariadb/postgres). +- `docker/prod/` — the production all-in-one image: alpine-based Dockerfile (built via `make prod-build`; core comes from goreleaser), the image's Caddyfile (sibling of `docker/Caddyfile`), the TS launcher (`@oxynote/launcher`, **pnpm**) that validates the flat public `OXYNOTE_*` env, generates internal secrets, and supervises caddy + web + core + auth-realtime, plus the example compose deployment. Details: [docker/prod/CLAUDE.md](docker/prod/CLAUDE.md). **One `.gitignore`, and it lives at the repository root.** Components do not carry their own — a rule for a nested directory is written with its path (`e2e/test-results/`, `web/coverage/`), so every exclusion in the repository is readable in one file. The exception is `web/packages/lezer-promql/`, which is a vendored upstream fork and keeps the ignore file it ships with. @@ -93,15 +103,23 @@ make start # build images + run the dev stack in the background make dev # backend containers + web dev server on the host (hot reload, :3000) make stop # stop the dev stack -make lint # fix lint/format/type issues in web, auth-realtime, core, datagen, e2e +make lint # fix lint/format/type issues in web, auth-realtime, core, datagen, e2e, launcher make check-lint # the same gates, verification only -make e2e # one-shot: build, run playwright, tear the e2e stack down -make e2e-stack-build # build the e2e stack's images, then iterate with `pnpm test` -make e2e-stack-stop # stop the e2e stack and drop its data +make prod-build # goreleaser core binary + the all-in-one prod image +make prod-run # run the example prod compose against the local image +make prod-stop # stop it + +make e2e-dev # one-shot: build, run playwright, tear the dev stack down +make e2e-dev-stack-build # build that stack's images, then iterate with `pnpm test:dev` +make e2e-dev-stack-stop # stop it and drop its data + +make e2e-prod # the same suite against the all-in-one image +make e2e-prod-stack-build # build that image, then iterate with `pnpm test:prod` +make e2e-prod-stack-stop # stop it and drop its data ``` -The e2e stack listens on `:18080` (and mailpit on `:18025`), so it runs alongside the dev stack rather than fighting it for ports. +The e2e dev stack listens on `:18080` (and mailpit on `:18025`) and the prod one on `:19080` (`:19025`), so all three stacks run alongside each other rather than fighting for ports. Component build/test/qa commands are listed in the nested CLAUDE.md files. @@ -112,6 +130,7 @@ Component build/test/qa commands are listed in the nested CLAUDE.md files. - **Session validation**: core's auth middleware validates sessions by calling auth-realtime's `/api/auth/get-session`; auth-realtime owns the Better Auth schema. - **Yjs invariant**: the Hocuspocus `documentName` is `"-"`, split on the first `-`. Never seed or merge one Y.Doc from another with `Y.applyUpdate` — use `replaceYdocContent` (`server/auth-realtime/src/ydocument.ts`). Read the document-storage section of [server/CLAUDE.md](server/CLAUDE.md) before touching branch content anywhere. - **Env naming**: core reads `OXYNOTE_CORE_*` (via `buildinfo.Getenv`), auth-realtime reads `OXYNOTE_AUTH_REALTIME_*`, the frontend reads `NUXT_PUBLIC_*`. +- **`_DSN` vs `_URL`**: a variable holding a connection string with credentials in it ends in `_DSN` — `DB_DSN`, `VALKEY_DSN`, `SMTP_DSN`, `OBJECT_STORAGE_DSN` (public), `SENTRY_DSN`. A variable holding a plain address ends in `_URL`, and any credential it needs is its own variable — `MEILISEARCH_URL` + `_MASTER_KEY`, `CHANGE_DETECTION_URL`/`CHANGEDETECTION_API_URL` + its key, `OBJECT_STORAGE_URL` + key/secret on core's side, plus every address that is not a dependency at all (`PUBLIC_URL`, `BASE_APP_URL`, `TERMS_OF_SERVICE_URL`). The suffix is a claim about whether the value is a secret, so it holds across the trust boundary: the same dependency is never `_DSN` publicly and `_URL` internally. ## Code style (TS/JS — web and auth-realtime) diff --git a/Makefile b/Makefile index 153426cd..b85e07ce 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,21 @@ +# where CI layers its build cache, kept out of the definitions below so +# that config never leaks into a local build. The compose one is an extra +# file (e2e/docker-compose.dev.ci.yaml); the prod one is docker build +# flags, because compose has no part in that image. +E2E_DEV_COMPOSE_EXTRA ?= +PROD_BUILD_EXTRA ?= + +# one compose invocation per stack. Only the dev stack is a project name +# away from the others; the e2e stacks are named by their own files. COMPOSE := docker-compose -p oxynote -f docker/docker-compose.dev.yaml -# extra compose files, so CI can layer its build cache on top without -# that config leaking into local builds (see e2e/docker-compose.ci.yaml) -E2E_COMPOSE_EXTRA ?= -E2E_COMPOSE := docker compose -f e2e/docker-compose.yaml $(E2E_COMPOSE_EXTRA) +E2E_DEV_COMPOSE := docker compose -f e2e/docker-compose.dev.yaml $(E2E_DEV_COMPOSE_EXTRA) +E2E_PROD_COMPOSE := docker compose -f e2e/docker-compose.prod.yaml + +# a tag names the stack an image belongs to. The e2e targets override these +# to :e2e-dev and :e2e-prod so a run cannot replace the image `make start` +# is serving from. +CORE_IMAGE_TAG ?= dev +PROD_IMAGE_TAG ?= prod QUIET := scripts/run-quietly.sh @@ -36,23 +49,41 @@ stop: # what this target does, so this target only starts the run. The build # stays a make target because the hook cannot express it — goreleaser # produces core's image, which compose cannot build at all — and the hook -# shells back out to e2e-stack-build, which is also what keeps -# E2E_COMPOSE_EXTRA working from CI. +# shells back out to e2e-dev-stack-build, which is also what keeps +# E2E_DEV_COMPOSE_EXTRA working from CI. # # Every run is therefore a full build and teardown. To iterate without -# paying that, run e2e-stack-build once and drive the tests directly. -.PHONY: e2e -e2e: - @( cd e2e && pnpm run test ) +# paying that, run e2e-dev-stack-build once and drive the tests directly. +.PHONY: e2e-dev +e2e-dev: + @( cd e2e && pnpm run test:dev ) -.PHONY: e2e-stack-build -e2e-stack-build: - @$(QUIET) "building go images" $(MAKE) build-go - @$(QUIET) "building stack images" $(E2E_COMPOSE) build +.PHONY: e2e-dev-stack-build +e2e-dev-stack-build: + @$(QUIET) "building go images" $(MAKE) build-go CORE_IMAGE_TAG=e2e-dev + @$(QUIET) "building stack images" $(E2E_DEV_COMPOSE) build -.PHONY: e2e-stack-stop -e2e-stack-stop: - @$(QUIET) "stopping the stack" $(E2E_COMPOSE) down -v +.PHONY: e2e-dev-stack-stop +e2e-dev-stack-stop: + @$(QUIET) "stopping the stack" $(E2E_DEV_COMPOSE) down -v + +# the same suite against the production all-in-one image, plus the +# trust-boundary tests that only mean something there. It publishes its own +# ports, so it coexists with both the dev stack and the e2e stack. +.PHONY: e2e-prod +e2e-prod: + @( cd e2e && pnpm run test:prod ) + +# only the all-in-one image is built here; the backing services are pinned +# third-party images that `up` pulls on its own. Pulling them here would +# also try to pull the image just built, which has no registry to come from. +.PHONY: e2e-prod-stack-build +e2e-prod-stack-build: + @$(QUIET) "building the prod image" $(MAKE) prod-build PROD_IMAGE_TAG=e2e-prod + +.PHONY: e2e-prod-stack-stop +e2e-prod-stack-stop: + @$(QUIET) "stopping the stack" $(E2E_PROD_COMPOSE) down -v # lint gates of every component, from one place. Fixing and checking mirror # each component's own vocabulary: web, auth-realtime and e2e pair @@ -64,6 +95,7 @@ lint: @$(QUIET) "linting core" sh -c 'cd server/core && make lint' @$(QUIET) "linting datagen" sh -c 'cd datagen && make lint' @$(QUIET) "linting e2e" sh -c 'cd e2e && pnpm run lint' + @$(QUIET) "linting launcher" sh -c 'cd docker/prod/launcher && pnpm run lint' .PHONY: check-lint check-lint: @@ -72,11 +104,13 @@ check-lint: @$(QUIET) "checking core" sh -c 'cd server/core && make check-lint' @$(QUIET) "checking datagen" sh -c 'cd datagen && make check-lint' @$(QUIET) "checking e2e" sh -c 'cd e2e && pnpm run check-lint' + @$(QUIET) "checking launcher" sh -c 'cd docker/prod/launcher && pnpm run check-lint' .PHONY: setup setup: cd web && pnpm run setup cd server/auth-realtime && pnpm install + cd docker/prod/launcher && pnpm install cd e2e && pnpm run setup cd server/core && go mod download cd datagen && go mod download @@ -89,16 +123,36 @@ setup: deps: cd web && pnpm run deps cd server/auth-realtime && pnpm install + cd docker/prod/launcher && pnpm install cd e2e && pnpm install cd server/core && go mod download cd datagen && go mod download -# goreleaser snapshot builds the go binaries + their :dev images +# goreleaser snapshot builds the go binaries + their images .PHONY: build-go build-go: - cd server/core && make build + cd server/core && make build IMAGE_TAG=$(CORE_IMAGE_TAG) cd datagen && make build +# the all-in-one image. Core must come from goreleaser, so the binary is +# built first and staged where the Dockerfile's COPY expects it — a bare +# `docker build` is not supported. +.PHONY: prod-build +prod-build: + @$(QUIET) "building the core binary" sh -c 'cd server/core && make build' + mkdir -p docker/prod/.build + cp server/core/bin/oxynote-core docker/prod/.build/oxynote-core + docker build --platform linux/amd64 $(PROD_BUILD_EXTRA) -f docker/prod/Dockerfile -t ghcr.io/oxynote/oxynote:$(PROD_IMAGE_TAG) . + +# the example compose against the locally built image +.PHONY: prod-run +prod-run: + OXYNOTE_IMAGE_TAG=prod docker compose -f docker/prod/docker-compose.example.yaml up + +.PHONY: prod-stop +prod-stop: + docker compose -f docker/prod/docker-compose.example.yaml down + # changedetection.io generates its API key on first boot; start it alone and # copy the key into core.local.env before the rest of the stack (core reads # its env only at container creation). diff --git a/docker/Caddyfile b/docker/Caddyfile index 686a6911..2c7c126d 100644 --- a/docker/Caddyfile +++ b/docker/Caddyfile @@ -1,3 +1,6 @@ +# the dev/e2e stack's routing. Sibling of docker/prod/Caddyfile (the +# production image's front door): same routes, same blocks — only the +# upstreams differ. Change the two files together. { admin off # disable the admin API } diff --git a/docker/docker-compose.dev.yaml b/docker/docker-compose.dev.yaml index a160504e..bd728296 100644 --- a/docker/docker-compose.dev.yaml +++ b/docker/docker-compose.dev.yaml @@ -78,7 +78,7 @@ services: # docker/github/ and set OXYNOTE_CORE_GITHUB_APP_ID to enable the # GitHub App integration. - ./github:/etc/oxynote/core/github:ro - # uploaded files, which core keeps under OXYNOTE_CORE_STORAGE_PATH + # uploaded files, which core keeps under OXYNOTE_CORE_OBJECT_STORAGE_LOCAL_PATH # when no object store is configured. The mount is the home directory # rather than the storage path itself: core runs as a non-root uid # that owns only /home/nonroot in its image, and an empty volume diff --git a/docker/env/auth-realtime.example.env b/docker/env/auth-realtime.example.env index c362a6c3..6c555ee6 100644 --- a/docker/env/auth-realtime.example.env +++ b/docker/env/auth-realtime.example.env @@ -9,7 +9,11 @@ OXYNOTE_AUTH_REALTIME_DB_DSN=postgresql://devuser:devpass@postgres/devdb?sslmode # an empty URL disables valkey: better-auth runs with no secondary # storage, reading sessions from the database and counting rate limits # in memory. -OXYNOTE_AUTH_REALTIME_VALKEY_URL=redis://valkey:6379 +OXYNOTE_AUTH_REALTIME_VALKEY_DSN=redis://valkey:6379 + +# server +# the listen address as host:port; empty binds all interfaces on 8081. +OXYNOTE_AUTH_REALTIME_ADDRESS= # public URLs (the caddy front door) OXYNOTE_AUTH_REALTIME_BETTER_AUTH_BASE_URL=http://localhost:8080/auth-realtime diff --git a/docker/env/core.example.env b/docker/env/core.example.env index 54067edc..62fb01bd 100644 --- a/docker/env/core.example.env +++ b/docker/env/core.example.env @@ -7,33 +7,32 @@ OXYNOTE_CORE_DB_DSN=postgresql://devuser:devpass@postgres/devdb?sslmode=disable # an empty DSN disables search: documents are not indexed, the search # endpoint refuses, and the assistant loses its search tool. -OXYNOTE_CORE_MEILISEARCH_DSN=http://meilisearch:7700 +OXYNOTE_CORE_MEILISEARCH_URL=http://meilisearch:7700 OXYNOTE_CORE_MEILISEARCH_MASTER_KEY=devmasterkey -# an empty address disables valkey: the assistant keeps conversation -# history, checkpoints and pending confirmations in the core process -# instead -OXYNOTE_CORE_VALKEY_NETWORK=tcp -OXYNOTE_CORE_VALKEY_ADDRESS=valkey:6379 +# an empty URL disables valkey: the assistant keeps conversation history, +# checkpoints and pending confirmations in the core process instead. +# redis[s]://[username:password@]host:port[/db] +OXYNOTE_CORE_VALKEY_DSN=redis://valkey:6379 # an empty URL disables the object store: objects are kept in a directory # on the local filesystem instead, and the rest of the STORAGE_ values are # unread. They are then not shared between instances and are lost with the # container unless the storage path is a volume. The dev stack runs this # way; set a URL and the values below to point it at an object store. -OXYNOTE_CORE_STORAGE_URL= +OXYNOTE_CORE_OBJECT_STORAGE_URL= # an empty region signs requests for us-east-1, which suits object stores # that have no concept of regions; set it when the bucket lives in a real # region that would reject a mismatched signature. -OXYNOTE_CORE_STORAGE_REGION= -OXYNOTE_CORE_STORAGE_ACCESS_KEY= -OXYNOTE_CORE_STORAGE_SECRET_KEY= -OXYNOTE_CORE_STORAGE_BUCKET= +OXYNOTE_CORE_OBJECT_STORAGE_REGION= +OXYNOTE_CORE_OBJECT_STORAGE_ACCESS_KEY= +OXYNOTE_CORE_OBJECT_STORAGE_SECRET_KEY= +OXYNOTE_CORE_OBJECT_STORAGE_BUCKET= # where objects are kept when no object store URL is configured. Core has # no default for it: with neither a URL nor a path it refuses to boot # rather than put objects somewhere nobody chose. The process must be able # to create the directory, or write to it if it is already there — in the # core image /home/nonroot is the only directory owned by the uid it runs # as, and the dev stack mounts a volume there. -OXYNOTE_CORE_STORAGE_PATH=/home/nonroot/storage +OXYNOTE_CORE_OBJECT_STORAGE_LOCAL_PATH=/home/nonroot/storage OXYNOTE_CORE_SERVER_AUTH_BETTER_AUTH_URL=http://auth-realtime:8081/api/auth/get-session OXYNOTE_CORE_SERVER_MCP_SESSION_URL=http://auth-realtime:8081/api/internal/mcp/session OXYNOTE_CORE_AUTH_REALTIME_URL=http://auth-realtime:8081 @@ -44,6 +43,10 @@ OXYNOTE_CORE_CHANGEDETECTION_API_URL=http://changedetection:5000 # docker/sync-changedetection-key.sh) — no need to fill in manually. OXYNOTE_CORE_CHANGEDETECTION_API_KEY= +# server +# the listen address as host:port; empty binds all interfaces on 8080. +OXYNOTE_CORE_SERVER_ADDRESS= + # public URLs (the caddy front door) OXYNOTE_CORE_SERVER_PUBLIC_URL=http://localhost:8080/core OXYNOTE_CORE_BASE_APP_URL=http://localhost:8080 @@ -116,15 +119,21 @@ OXYNOTE_CORE_ASSISTANT_VERTEX_PROJECT_ID= OXYNOTE_CORE_ASSISTANT_VERTEX_REGION= OXYNOTE_CORE_ASSISTANT_VERTEX_SERVICE_ACCOUNT_JSON= -# document changelog retention. Snapshots pin the files they reference, so +# document history retention. Entries pin the files they reference, so # these bounds also decide how long a removed image survives. 0 disables # the respective bound. -OXYNOTE_CORE_DB_MAX_DOCUMENT_CHANGELOGS=100 -OXYNOTE_CORE_DB_DOCUMENT_CHANGELOG_RETENTION=2160h +OXYNOTE_CORE_DB_MAX_DOCUMENT_HISTORY_ENTRIES=100 +OXYNOTE_CORE_DB_DOCUMENT_HISTORY_RETENTION=2160h # secrets OXYNOTE_CORE_DB_DATA_SOURCE_CREDENTIALS_SIGNING_SECRET=devsigningsecret +# observability +# an empty DSN disables sentry. +OXYNOTE_CORE_SENTRY_DSN= +# slog level (DEBUG, INFO, WARN, ERROR); empty means INFO. +OXYNOTE_CORE_LOG_LEVEL= + # email OXYNOTE_CORE_EMAIL_SMTP_HOST=mailpit OXYNOTE_CORE_EMAIL_SMTP_PORT=1025 diff --git a/docker/prod/CLAUDE.md b/docker/prod/CLAUDE.md new file mode 100644 index 00000000..4a8087c7 --- /dev/null +++ b/docker/prod/CLAUDE.md @@ -0,0 +1,129 @@ +# CLAUDE.md + +Guidance for `docker/prod/` — the production all-in-one Docker image. +Shared working principles and the TS/JS comment/whitespace rules live in the +root [CLAUDE.md](../../CLAUDE.md). + +## What this is + +One public image (`ghcr.io/oxynote/oxynote`) containing Caddy, the web app +(Nuxt SSR), core, and auth-realtime, supervised by `launcher/` +(`@oxynote/launcher`, **pnpm**) — a TypeScript program run by node under +tini. Postgres is the only required external service; valkey, an +S3-compatible store, meilisearch, SMTP and changedetection.io are optional +and external too — without a store, objects are kept on the data volume; +`docker-compose.example.yaml` is the canonical deployment and `README.md` +the operator-facing reference. + +Build with **`make prod-build`** from the repository root: core's +binary must come from goreleaser (never a plain `go build`), so the target +builds it first and stages it into `.build/` where the Dockerfile's COPY +expects it. A bare `docker build` fails on that COPY by design. + +**`make e2e-prod`** runs the whole e2e suite against the built image, plus +the trust-boundary tests that only mean something here. It is the only thing +that exercises this image as an operator installs it — the dev stack shares +none of its assembly — so run it locally for a change to the Dockerfile, the +Caddyfile or the launcher. CI only runs it on a tag, so a change here gets no +pre-merge signal from it. Details in [e2e/CLAUDE.md](../../e2e/CLAUDE.md). + +## Invariants + +- **The trust boundary is the loopback bind.** Core's `/api/x/*` and + auth-realtime's `/api/internal/*` carry no authentication. Inside the + container core (8180), auth-realtime (8181), and web (3000) bind + `127.0.0.1`; only caddy listens on the wildcard (8080). The ports are + constants in `launcher/src/mapping.ts`, the internal address variables are + rejected by the launcher's unknown-variable guard, and the supervisor + dials the container's own external addresses at boot and refuses to serve + if anything accepts. Changing any of this is a security decision. + [e2e/tests/prod-trust-boundary.test.ts](../../e2e/tests/prod-trust-boundary.test.ts) + asserts it from the outside — a sibling container on the private network + dialling each internal port, and the front door refusing every spelling of + the blocked prefixes — so the boot gate is not the only thing standing + between a regression and a release. +- **`Caddyfile` is a sibling of [docker/Caddyfile](../Caddyfile)**: same + routes, same `/core/api/x/*` and `/auth-realtime/api/internal/*` 403 + blocks — only the upstreams differ. Change the two files together. +- **The external namespace is minimal and vendor-neutral.** Operators see + only the flat `OXYNOTE_*` variables declared in `launcher/src/env.ts`: + derive from shared values (one `OXYNOTE_PUBLIC_URL`, DSN-style URLs + carrying credentials) instead of adding variables, generate secrets + instead of exposing them, and never leak an internal dependency's name + into a public variable. Any undeclared `OXYNOTE_*` variable is a boot + error. +- **Secrets precedence** (`launcher/src/secrets.ts`): explicit env override + wins and is never persisted → existing volume file → generate + (`crypto.randomBytes`) + persist 0600. The data-source encryption key is + unrotatable — never weaken this path. +- **Sentry DSNs are build-time only** (esbuild `--define` from Dockerfile + ARGs). No runtime DSN variables; `OXYNOTE_CRASH_REPORTING_DISABLED` is the + only runtime switch. There are four: web, core and auth-realtime get + theirs passed down as child env, and the launcher keeps its own + (`bakedLauncherSentryDsn`) because it reports for itself. That fourth one + is not redundant — it covers what no child can report, a child killed + outright and a boot that fails before any child exists. The launcher reads + the disable switch straight from `process.env` rather than from `Config`, + since an invalid environment is one of the failures worth reporting and + `loadConfig` throws on it. +- **Crash policy: die, don't restart.** Any child exit tears the rest down + and exits the container nonzero; restarts belong to the container runtime. +- **Supply chain**: every `FROM` is pinned by digest; caddy and tini are + downloaded at pinned versions and checksum-verified. Caddy is Apache-2.0 + (its LICENSE ships in the image at `/oxynote/licenses/caddy/`); the name + "Caddy" is trademarked — bundle it unmodified and unrebranded. + +## launcher/ + +Follows [server/auth-realtime](../../server/auth-realtime/CLAUDE.md)'s +conventions: tabs-at-8 prettier, no semicolons, explicit `.js` import +extensions, type-aware eslint, knip, vitest with concurrent tests and the +context-local `expect`. `src/index.ts` is the only module with side effects +(env, filesystem, spawning, signals) and is excluded from coverage; +everything else is a factory taking its dependencies and is fully tested. + +```sh +cd docker/prod/launcher +pnpm run qa # check-lint + test +pnpm run build:bundle # esbuild bundle -> dist/launcher.mjs; the SENTRY_*_DSN + # env vars become baked defines +``` + +The runtime base is pinned alpine with alpine's own nodejs package — the +v24 line built against shared system libraries, about half the size of +the official node binary — plus `icu-data-full` so `Intl` formats every +locale during SSR. The image ships **no node_modules**: +auth-realtime and the launcher are single esbuild bundles (sentry is +folded into both — require-hook auto-instrumentation is lost, error +capture kept) and the web server is nitro's terser-minified +self-contained output (`NITRO_MINIFY=true`). With no native addons the +musl runtime cannot clash with anything, and the web builder fails the +build if a `*.node` file ever lands in the output. + +**Both bundles carry a `createRequire` banner**, and it is what makes +folding sentry in possible at all: its opentelemetry dependencies still +`require()` node builtins at runtime, and an `--format=esm` bundle has no +`require` to give them — esbuild's shim throws `Dynamic require of "util" +is not supported` on the first import, before any of this code runs. The +banner hands the shim a real `require` built from `import.meta.url`. +Removing it from either package breaks that package's container at +startup, and no unit test will tell you: the bundle is only built in the +image. + +## References + +Established practice this design follows: + +- Hoppscotch AIO — tini + node launcher spawning caddy/backend/frontend, + checksum-verified caddy: + (`aio_run.mjs`, `healthcheck.sh`, `prod.Dockerfile`) +- Baserow all-in-one — bundled Caddy, single public-URL variable, secret + files on the data volume: + (`deploy/all-in-one/`) +- Appsmith fat container — layered healthcheck, generated secrets on the + volume: (`deploy/docker/`) +- grist-omnibus — the anti-pattern reference (no tini, no SIGTERM handling): + +- tini: · Docker multi-process guidance: + +- Caddy (Apache-2.0, trademarked name): diff --git a/docker/prod/Caddyfile b/docker/prod/Caddyfile new file mode 100644 index 00000000..4347ad52 --- /dev/null +++ b/docker/prod/Caddyfile @@ -0,0 +1,76 @@ +# the production image's front door. Sibling of docker/Caddyfile (the dev/ +# e2e stack): same routes, same blocks — only the upstreams differ, since +# every service here lives on this container's loopback. Change the two +# files together. +{ + admin off # disable the admin API +} + +(common) { + request_body { + max_size 10MB # must be higher than file upload limit + } + + header -Server +} + +:8080 { + import common + + # /api/x/* is core's internal, auth-less service-to-service surface — + # it must never be reachable through the front door. /api/apps/* is the + # opposite: GitHub and Slack deliver their webhooks and OAuth callbacks + # there, so it stays open and is guarded by request signatures instead. + handle /core/api/x/* { + respond 403 + } + + # /api/internal/* is auth-realtime's unauthenticated service-to-service + # surface (core calls it over loopback) — it must never be reachable + # through the front door either. + handle /auth-realtime/api/internal/* { + respond 403 + } + + # OAuth discovery for the MCP surface. Spec-compliant MCP clients + # resolve these documents at the front-door origin root (RFC 9728 / + # RFC 8414 well-known paths), so they are rewritten onto better-auth's + # basePath'd endpoints regardless of any issuer path suffix the client + # appends. + # served by the mcp plugin's request hook, which matches the original + # root-anchored path — no rewrite. + handle /.well-known/oauth-protected-resource* { + reverse_proxy 127.0.0.1:8181 + } + + handle /.well-known/oauth-authorization-server* { + rewrite * /api/auth/.well-known/oauth-authorization-server + reverse_proxy 127.0.0.1:8181 + } + + handle /.well-known/openid-configuration* { + rewrite * /api/auth/.well-known/openid-configuration + reverse_proxy 127.0.0.1:8181 + } + + # better-auth builds its OAuth endpoint URLs from the bare origin plus + # its /api/auth basePath, so the authorization-server metadata points + # MCP clients at /api/auth/... on the front door. Proxy that prefix to + # auth-realtime as-is (no path strip — the service serves /api/auth + # natively). + handle /api/auth/* { + reverse_proxy 127.0.0.1:8181 + } + + handle_path /core/* { + reverse_proxy 127.0.0.1:8180 + } + + handle_path /auth-realtime/* { + reverse_proxy 127.0.0.1:8181 + } + + handle { + reverse_proxy 127.0.0.1:3000 + } +} diff --git a/docker/prod/Dockerfile b/docker/prod/Dockerfile new file mode 100644 index 00000000..a6fc32cc --- /dev/null +++ b/docker/prod/Dockerfile @@ -0,0 +1,156 @@ +# the production all-in-one image: caddy + web (nuxt SSR) + core + +# auth-realtime, supervised by the launcher under tini. Build it with +# `make prod-build` from the repository root — the core binary comes +# from goreleaser and is staged into docker/prod/.build/ first, so a +# bare `docker build` fails on that COPY by design. +# +# every FROM is pinned by digest, and the third-party binaries (caddy, +# tini) are downloaded at pinned versions and checksum-verified. +# +# the runtime ships no node_modules at all — auth-realtime and the +# launcher are single esbuild bundles and the web server is nitro's +# self-contained output — so the runtime libc (musl) cannot clash with +# anything; the web builder still guards against a native addon ever +# sneaking into the output. + +# mirrors web/Dockerfile.dev (the dev/e2e image); change them together. +FROM node:24.19.0-bookworm-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df AS web-builder + +RUN npm install -g pnpm + +WORKDIR /app + +# the vite client build exceeds node's default heap. +ENV NODE_OPTIONS="--max-old-space-size=4096" + +# dependency layer that survives ordinary source edits. Scripts are skipped +# because the root prepare builds the promql grammar and runs nuxt prepare, +# and neither has its sources yet. +COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./ +COPY web/packages/lezer-promql/package.json ./packages/lezer-promql/ +RUN pnpm install --frozen-lockfile --ignore-scripts + +COPY web/ . + +# the second install only runs the previously skipped scripts. The find +# guard fails the build if a native addon ever lands in the output — the +# runtime is musl and a glibc .node file would crash at boot instead. +RUN pnpm install --frozen-lockfile \ + && NITRO_PRESET=node-server NITRO_MINIFY=true pnpm run build:web \ + && test -z "$(find .output -name '*.node' -print -quit)" + +# the runtime ships the package's own single-file esbuild bundle (see +# build:bundle and src/bundle.ts — the same artifact Dockerfile.dev and +# the e2e stack run) instead of the ~83MB production node_modules tree. +FROM node:24.19.0-bookworm-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df AS auth-realtime-builder + +RUN npm install -g pnpm + +WORKDIR /server + +COPY server/auth-realtime/package.json server/auth-realtime/pnpm-lock.yaml server/auth-realtime/pnpm-workspace.yaml server/auth-realtime/tsconfig.json server/auth-realtime/tsconfig.build.json ./ +COPY server/auth-realtime/src ./src + +# check-types gates (esbuild does not look at types); the import proves +# the bundle loads — with no environment set it must reject with its own +# validation error, so an unresolvable import fails the build here rather +# than when the container starts. +RUN pnpm install --frozen-lockfile && pnpm run check-types && pnpm run build:bundle \ + && node -e "import('/server/dist/bundle.mjs').then(() => { console.error('bundle skipped env validation'); process.exit(1) }, (err) => { if (!String(err).includes('invalid environment')) { console.error(err); process.exit(1) } })" + +FROM node:24.19.0-bookworm-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df AS launcher-builder + +RUN npm install -g pnpm + +WORKDIR /launcher + +COPY docker/prod/launcher/package.json docker/prod/launcher/pnpm-lock.yaml docker/prod/launcher/pnpm-workspace.yaml docker/prod/launcher/tsconfig.json ./ +RUN pnpm install --frozen-lockfile + +COPY docker/prod/launcher/src ./src + +# the central telemetry DSNs, baked at build time only — empty for local +# builds (sentry disabled), set by the release pipeline. There is no +# runtime override; OXYNOTE_ERROR_REPORTING_DISABLED is the off switch. +# The args reach build:bundle as environment variables, where the +# package's own script turns them into esbuild defines. +ARG SENTRY_WEB_DSN="" +ARG SENTRY_CORE_DSN="" +ARG SENTRY_AUTH_REALTIME_DSN="" +ARG SENTRY_LAUNCHER_DSN="" + +RUN pnpm run check-types && pnpm run build:bundle + +# caddy and tini at pinned versions, checksum-verified before use. The +# caddy release tarball also carries the Apache-2.0 LICENSE the final image +# ships (the name "Caddy" is trademarked; the binary is bundled unmodified +# and unrebranded). +FROM node:24.19.0-bookworm-slim@sha256:a9f5f7c91a432850b2a8a7797adf5eadb6c733ceed61167806cee7ea7fbc29df AS fetch + +ARG CADDY_VERSION=2.11.4 +ARG CADDY_SHA512=8220d1f013b6f27510247b2360c9e0ca9f018feebd82515f07635318b34ff9777ccc8fd0b6e6f2486ce3a33fe389fbb7db12d05baa474f4587509fb4f5ebf1c9 +ARG TINI_VERSION=0.19.0 +ARG TINI_SHA256=c5b0666b4cb676901f90dfcb37106783c5fe2077b04590973b885950611b30ee + +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* + +WORKDIR /fetch + +RUN curl -fsSLo caddy.tar.gz "https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_amd64.tar.gz" \ + && echo "${CADDY_SHA512} caddy.tar.gz" | sha512sum -c - \ + && tar -xzf caddy.tar.gz caddy LICENSE \ + && curl -fsSLo tini "https://github.com/krallin/tini/releases/download/v${TINI_VERSION}/tini-static-amd64" \ + && echo "${TINI_SHA256} tini" | sha256sum -c - \ + && chmod 0755 caddy tini + +# the runtime node is alpine's own build — v24 linked against shared +# system libraries, roughly half the size of the official node binary. +# The base is digest-pinned; nodejs then tracks alpine 3.23's package +# repo, which only ships security bumps within the branch. +FROM alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 + +# the exact pins make the build fail — instead of silently shifting — +# when alpine's v3.23 branch rotates a package (they keep only the +# newest build per branch); bump the ARGs together with the base digest. +# icu-data-full replaces the English-only default so Intl formats every +# locale during SSR. +ARG NODE_APK_VERSION=24.18.1-r0 +ARG ICU_DATA_APK_VERSION=76.1-r1 + +# the uid is explicit and never changes: the data volume's files are owned +# by it, so a shift between releases would lock an existing deployment out +# of its own secrets. The three directories are the volume mount point +# (which the launcher writes generated secrets into), the mount point for +# a GitHub App private key, and caddy's scratch dir. +RUN apk add --no-cache "nodejs=${NODE_APK_VERSION}" "icu-data-full=${ICU_DATA_APK_VERSION}" \ + && adduser -D -H -u 65532 oxynote \ + && mkdir -p /oxynote/data /oxynote/github /tmp/caddy \ + && chown -R oxynote /oxynote /tmp/caddy + +COPY --from=fetch /fetch/tini /tini +COPY --from=fetch /fetch/caddy /usr/local/bin/caddy +COPY --from=fetch /fetch/LICENSE /oxynote/licenses/caddy/LICENSE + +# staged by `make prod-build` from goreleaser's output. +COPY docker/prod/.build/oxynote-core /oxynote/core/server + +COPY --from=web-builder /app/.output /oxynote/web +COPY --from=auth-realtime-builder /server/dist/bundle.mjs /oxynote/auth-realtime/index.mjs +COPY --from=launcher-builder /launcher/dist/launcher.mjs /oxynote/prod/launcher.mjs +COPY docker/prod/healthcheck.mjs /oxynote/prod/healthcheck.mjs +COPY docker/prod/Caddyfile /oxynote/prod/Caddyfile + +ENV NODE_ENV=production +ENV HOME=/oxynote + +USER oxynote + +# generated secrets persist here even when no volume is mounted explicitly. +VOLUME /oxynote/data + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=10s --retries=5 --start-period=180s \ + CMD ["/usr/bin/node", "/oxynote/prod/healthcheck.mjs"] + +ENTRYPOINT ["/tini", "--", "/usr/bin/node", "/oxynote/prod/launcher.mjs"] diff --git a/docker/prod/README.md b/docker/prod/README.md new file mode 100644 index 00000000..f2f3f119 --- /dev/null +++ b/docker/prod/README.md @@ -0,0 +1,115 @@ +# Oxynote production image + +One container running the whole product: Caddy (front door), the web app +(Nuxt SSR), the core API server, and the auth/realtime service, supervised by +a small launcher. PostgreSQL runs outside the image and is the only thing +it requires; Valkey, an S3-compatible object store, Meilisearch, an SMTP +relay and changedetection.io are optional and also external. + +## Quick start + +```sh +cp docker/prod/docker-compose.example.yaml my-deployment.yaml +# replace every change-me value, set OXYNOTE_PUBLIC_URL +docker compose -f my-deployment.yaml up -d +``` + +From a repository checkout, `make prod-build && make prod-run` +builds the image locally (goreleaser builds the core binary first — a bare +`docker build` is not supported) and runs the example as-is on +`http://localhost:8080`. + +The image serves plain HTTP on port **8080**. For a real domain, put a +TLS-terminating proxy in front and set `OXYNOTE_PUBLIC_URL` to the public +`https://` origin. + +## Configuration + +Everything is configured through flat `OXYNOTE_*` variables. Setting any +other `OXYNOTE_`-prefixed variable (a typo, or a component-internal name) +fails the boot with an error naming it. + +### Required + +| Variable | Meaning | +| --- | --- | +| `OXYNOTE_PUBLIC_URL` | the origin users open in the browser, e.g. `https://notes.example.com`. Scheme + host only — every public URL, the cookie domain, and the CORS rules derive from it. | +| `OXYNOTE_DB_DSN` | PostgreSQL DSN, e.g. `postgresql://user:pass@host/db?sslmode=disable`. One database serves the whole product; migrations run automatically at boot. | + +### Optional features + +Each feature is keyed on one variable; leaving it unset disables the feature +cleanly. An incomplete group fails the boot with the missing name. + +| Group | Variables | +| --- | --- | +| Valkey/Redis | `OXYNOTE_VALKEY_DSN` (`redis[s]://[user:pass@]host:port[/db]`, credentials supported). Without it the assistant keeps its conversations in the core process and sessions are not cached outside Postgres. | +| Object storage | `OXYNOTE_OBJECT_STORAGE_DSN` — an S3-compatible store as one URL: `http(s)://ACCESS_KEY:SECRET_KEY@host:port/bucket[?region=...]` (the bucket defaults to `oxynote` and is created if missing). Without it uploaded images are kept on the data volume, which is the right choice for a single-node deployment. | +| Search | `OXYNOTE_MEILISEARCH_URL`, `OXYNOTE_MEILISEARCH_MASTER_KEY` | +| Email | `OXYNOTE_SMTP_DSN` (`smtp[s]://[user:pass@]host:port[?tls=none\|starttls\|tls]`), `OXYNOTE_EMAIL_FROM_ADDRESS`. Without email, verification mails are logged instead of sent. | +| GitHub App | `OXYNOTE_GITHUB_APP_ID`, `OXYNOTE_GITHUB_APP_SLUG`, `OXYNOTE_GITHUB_APP_SIGNATURE_SECRET`; mount the app's private key at `/oxynote/github/private-key.pem` | +| Slack app | `OXYNOTE_SLACK_APP_CLIENT_ID`, `OXYNOTE_SLACK_APP_CLIENT_SECRET`, `OXYNOTE_SLACK_APP_SIGNATURE_SECRET` | +| Social login | `OXYNOTE_SOCIAL_LOGIN_{GITHUB,GOOGLE,SLACK}_CLIENT_ID` + `_CLIENT_SECRET` (both halves per provider) | +| AI assistant | `OXYNOTE_AI_ASSISTANT_PROVIDER` (`anthropic`, `openai`, `google`, `ollama`, `openrouter`) plus the vendor's credentials: `OXYNOTE_AI_ASSISTANT_API_KEY`, `_MODEL`, `_BASE_URL`, `_MAX_TOKENS`, `_REQUEST_TIMEOUT`, `_SUMMARY_MODEL`, `_AZURE_API_VERSION`, `_BEDROCK_{REGION,ACCESS_KEY,SECRET_ACCESS_KEY,SESSION_TOKEN}`, `_VERTEX_{PROJECT_ID,REGION,SERVICE_ACCOUNT_JSON}` | +| URL watching | `OXYNOTE_CHANGE_DETECTION_URL`, `OXYNOTE_CHANGE_DETECTION_API_KEY` (a changedetection.io instance) | + +### Tuning + +`OXYNOTE_MAX_ORGANIZATIONS`, `OXYNOTE_MAX_ORGANIZATION_MEMBERS`, +`OXYNOTE_RATE_LIMIT_DISABLED` (`true`/`false`; the built-in limiter buckets +by client IP — disable it behind a proxy that hides the original IP and rate +limit there instead), `OXYNOTE_MAX_DOCUMENT_HISTORY_ENTRIES`, +`OXYNOTE_DOCUMENT_HISTORY_RETENTION` (Go duration, e.g. `2160h`), +`OXYNOTE_LOG_LEVEL` (`DEBUG`/`INFO`/`WARN`/`ERROR`), +`OXYNOTE_TERMS_OF_SERVICE_URL`, `OXYNOTE_PRIVACY_POLICY_URL`. + +## Secrets and the data volume + +Internal secrets (session signing, data-source credential encryption, the +GitHub/Slack install-state keys) are generated on first boot and stored under +`/oxynote/data/secrets/` with owner-only permissions — keep that volume. + +- Unless an object store is configured, every uploaded image lives there + too, under `/oxynote/data/object-storage`. +- **Losing the volume signs everyone out, permanently orphans every stored + data-source credential** — the encryption key cannot be rotated — **and + takes the uploaded images with it.** +- Two advanced overrides exist for migrating an existing deployment in: + `OXYNOTE_AUTH_SECRET` (session/token signing) and + `OXYNOTE_DATA_SOURCE_ENCRYPTION_KEY` (exactly 16, 24, or 32 bytes). An + override always wins and is never written to disk. + +## Security model + +- Only port **8080** (Caddy) is reachable. Core, auth-realtime, and the web + server bind the container's loopback, so their unauthenticated internal + surfaces cannot be reached from any network — the launcher verifies this + at boot and refuses to serve otherwise. Caddy additionally blocks the + internal paths (`/core/api/x/*`, `/auth-realtime/api/internal/*`) at the + front door. +- Keep the backing services on a private network reachable only by this + container, and where Valkey is used enable its authentication + (`requirepass` + a credentialed `OXYNOTE_VALKEY_DSN`). +- The image runs as a non-root user and writes only to `/oxynote/data` and + `/tmp`, so it supports a read-only root filesystem + (`docker run --read-only --tmpfs /tmp`, Kubernetes + `readOnlyRootFilesystem: true`). + +## Error reporting + +Official images carry Sentry DSNs baked in at build time, so crashes in +self-hosted deployments can reach the Oxynote team. DSNs are write-only +ingest addresses — they expose nothing about your instance. Set +`OXYNOTE_CRASH_REPORTING_DISABLED=true` to switch reporting off entirely. +Locally built images carry no DSNs and report nothing. + +## Bundled software + +The image bundles [the Caddy web server](https://github.com/caddyserver/caddy) +unmodified (Apache-2.0; its license ships at `/oxynote/licenses/caddy/LICENSE`) +and [tini](https://github.com/krallin/tini) (MIT) as the init process. Both +are downloaded at pinned versions and checksum-verified during the image +build. Node.js comes from the pinned Alpine base (the v24 line, with full +ICU locale data). + +Currently the image is built for `linux/amd64`. diff --git a/docker/prod/docker-compose.example.yaml b/docker/prod/docker-compose.example.yaml new file mode 100644 index 00000000..586b626b --- /dev/null +++ b/docker/prod/docker-compose.example.yaml @@ -0,0 +1,85 @@ +# the canonical production deployment: the all-in-one image plus the +# backing services it needs. Copy this file, replace every change-me value, +# set OXYNOTE_PUBLIC_URL to the origin users will open in the browser, and +# put a TLS-terminating proxy in front for a real domain (the image itself +# speaks plain HTTP on 8080). +# +# `make prod-run` from the repository root uses this file as-is +# against a locally built image (tag :dev). The full variable reference +# lives in README.md next to this file. + +name: oxynote-prod + +services: + oxynote: + image: ghcr.io/oxynote/oxynote:${OXYNOTE_IMAGE_TAG:-latest} + ports: + - "8080:8080" + environment: + # the public origin; every URL, cookie domain and CORS rule the + # components need derives from this one value. + - OXYNOTE_PUBLIC_URL=http://localhost:8080 + - OXYNOTE_DB_DSN=postgresql://oxynote:change-me-db@postgres/oxynote?sslmode=disable + # optional: full-text search. + - OXYNOTE_MEILISEARCH_URL=http://meilisearch:7700 + - OXYNOTE_MEILISEARCH_MASTER_KEY=change-me-meili-key + # optional: outgoing email (signup verification, invites). Point it + # at a real relay, or uncomment the mailpit service below for a + # trial run and use these two lines: + # - OXYNOTE_SMTP_DSN=smtp://mailpit:1025 + # - OXYNOTE_EMAIL_FROM_ADDRESS=Oxynote + volumes: + # generated secrets and every uploaded image live here. Losing this + # volume signs everyone out, takes the images with it and + # permanently orphans stored data-source credentials. + - oxynote_data:/oxynote/data + depends_on: + postgres: + condition: service_healthy + meilisearch: + condition: service_healthy + # the image shuts its services down in order (flushing open documents + # last); docker's default 10s grace would SIGKILL that mid-flush. + stop_grace_period: 60s + restart: unless-stopped + + postgres: + image: postgres:18.6-alpine + environment: + - POSTGRES_USER=oxynote + - POSTGRES_PASSWORD=change-me-db + - POSTGRES_DB=oxynote + volumes: + - postgres_data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U oxynote -d oxynote"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + meilisearch: + image: getmeili/meilisearch:v1.53.1 + environment: + - MEILI_ENV=production + - MEILI_MASTER_KEY=change-me-meili-key + volumes: + - meilisearch_data:/meili_data + healthcheck: + test: ["CMD", "curl", "--fail", "http://localhost:7700/health"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + # a trial-run mail sink: uncomment it together with the two OXYNOTE_SMTP + # variables above and read the delivered mail at http://localhost:8025. + # mailpit: + # image: axllent/mailpit:v1.31.0 + # ports: + # - "8025:8025" + +volumes: + oxynote_data: + postgres_data: + meilisearch_data: diff --git a/docker/prod/healthcheck.mjs b/docker/prod/healthcheck.mjs new file mode 100644 index 00000000..41660506 --- /dev/null +++ b/docker/prod/healthcheck.mjs @@ -0,0 +1,33 @@ +// the image's layered health probe: every internal service must answer and +// the front door must route, so a hung or crashed child flips the container +// unhealthy even while the launcher process itself is alive. The ports +// mirror launcher/src/mapping.ts. +const checks = [ + "http://127.0.0.1:8180/api/x/version", + "http://127.0.0.1:8181/api/auth-config", + "http://127.0.0.1:3000/login", + "http://127.0.0.1:8080/auth-realtime/api/auth-config", +] + +const results = await Promise.all( + checks.map(async (url) => { + try { + const res = await fetch(url, { + signal: AbortSignal.timeout(5_000), + }) + + await res.body?.cancel() + + return res.status === 200 + } catch { + return false + } + }), +) + +if (results.includes(false)) { + const failed = checks.filter((_, i) => !results[i]) + + console.error(`unhealthy: ${failed.join(", ")}`) + process.exit(1) +} diff --git a/docker/prod/launcher/.prettierignore b/docker/prod/launcher/.prettierignore new file mode 100644 index 00000000..a95c9d6e --- /dev/null +++ b/docker/prod/launcher/.prettierignore @@ -0,0 +1,4 @@ +pnpm-lock.yaml +dist + +*.md diff --git a/docker/prod/launcher/eslint.config.mjs b/docker/prod/launcher/eslint.config.mjs new file mode 100644 index 00000000..a9745bce --- /dev/null +++ b/docker/prod/launcher/eslint.config.mjs @@ -0,0 +1,115 @@ +// @ts-check + +import eslintConfigPrettier from "eslint-config-prettier/flat" +import eslint from "@eslint/js" +import vitest from "@vitest/eslint-plugin" +import tseslint from "typescript-eslint" + +export default tseslint.config( + { ignores: ["dist"] }, + eslint.configs.recommended, + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, + eslintConfigPrettier, + + // a disable comment whose rule no longer fires is stale and must be + // deleted, not carried along + { + name: "oxynote/linter-options", + linterOptions: { + reportUnusedDisableDirectives: "error", + }, + }, + + // type-aware rules need a TS program, which the package's own + // tsconfig provides for everything under src/. The two overrides + // belong here rather than in the project-wide block below: both are + // type-aware, so applying them to the config files that opt out of + // typed linting would crash the run. + { + name: "oxynote/typescript/project", + files: ["src/**/*.ts"], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + // swapping || for ?? on primitive operands changes + // behavior for ""/0/false — only flag nullable object + // types, where the swap is always safe + "@typescript-eslint/prefer-nullish-coalescing": [ + "error", + { ignorePrimitives: true }, + ], + // interpolating a number is legitimate, so it is the + // single allowance on top of the strict set. Every + // option is spelled out because eslint replaces the + // preset's option object rather than merging into it — + // omitting them silently restores the rule's permissive + // defaults. + "@typescript-eslint/restrict-template-expressions": [ + "error", + { + allowAny: false, + allowBoolean: false, + allowNever: false, + allowNullish: false, + allowNumber: true, + allowRegExp: false, + }, + ], + }, + }, + + // build/tooling configs live outside the tsconfig project, so + // type-aware rules cannot run on them + { + name: "oxynote/typescript/disable-type-checked", + files: [ + "eslint.config.mjs", + "knip.ts", + "prettier.config.js", + "vitest.config.ts", + ], + languageOptions: { + parserOptions: { + program: null, + project: false, + projectService: false, + }, + }, + rules: tseslint.configs.disableTypeChecked.rules, + }, + + // test files: vitest's recommended rules, plus every test file must + // wrap its tests in a root describe naming the unit under test + { + name: "oxynote/vitest", + files: ["src/**/*.test.ts"], + plugins: { + vitest, + }, + rules: { + ...vitest.configs.recommended.rules, + "vitest/require-top-level-describe": "error", + }, + }, + + { + name: "oxynote/rules", + rules: { + "no-tabs": "off", + indent: ["off"], // clashes with prettier + "@typescript-eslint/no-explicit-any": "off", + // an assignment must be its own statement — no chaining + // and no assigning from inside an expression, e.g. + // `const g = (map[k] ??= {})` + "no-multi-assign": [ + "error", + { ignoreNonDeclaration: false }, + ], + }, + }, +) diff --git a/docker/prod/launcher/knip.ts b/docker/prod/launcher/knip.ts new file mode 100644 index 00000000..508d66b9 --- /dev/null +++ b/docker/prod/launcher/knip.ts @@ -0,0 +1,16 @@ +import type { KnipConfig } from "knip" + +const config: KnipConfig = { + project: ["src/**/*.ts"], + + // a type exported only so another export in the same file can name it + // — a union member or a factory's dependency bag — is part of that + // file's contract, not a dead export. Values are deliberately left + // out: an exported const nobody imports is worth hearing about. + ignoreExportsUsedInFile: { + interface: true, + type: true, + }, +} + +export default config diff --git a/docker/prod/launcher/package.json b/docker/prod/launcher/package.json new file mode 100644 index 00000000..2828fb1a --- /dev/null +++ b/docker/prod/launcher/package.json @@ -0,0 +1,43 @@ +{ + "name": "@oxynote/launcher", + "type": "module", + "private": true, + "packageManager": "pnpm@11.1.3", + "engines": { + "node": ">=24" + }, + "scripts": { + "check-types": "tsc --noEmit", + "build:bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --minify \"--define:__SENTRY_WEB_DSN__=\\\"${SENTRY_WEB_DSN:-}\\\"\" \"--define:__SENTRY_CORE_DSN__=\\\"${SENTRY_CORE_DSN:-}\\\"\" \"--define:__SENTRY_AUTH_REALTIME_DSN__=\\\"${SENTRY_AUTH_REALTIME_DSN:-}\\\"\" \"--define:__SENTRY_LAUNCHER_DSN__=\\\"${SENTRY_LAUNCHER_DSN:-}\\\"\" \"--banner:js=import{createRequire as __cr}from'node:module';const require=__cr(import.meta.url);\" --outfile=dist/launcher.mjs", + "eslint": "eslint --fix --max-warnings 0 --cache --cache-location node_modules/.cache/eslint --cache-strategy content .", + "check-eslint": "eslint --max-warnings 0 --cache --cache-location node_modules/.cache/eslint --cache-strategy content .", + "fmt": "prettier --write .", + "check-fmt": "prettier --check .", + "knip": "knip --fix --allow-remove-files --cache", + "check-knip": "knip --cache", + "test": "vitest run --coverage", + "test-watch": "vitest", + "lint": "pnpm run check-types && pnpm run knip && pnpm run eslint && pnpm run fmt", + "check-lint": "pnpm run check-types && pnpm run check-eslint && pnpm run check-fmt && pnpm run check-knip", + "qa": "pnpm run check-lint && pnpm run test", + "qa-fix": "pnpm run lint && pnpm run test" + }, + "dependencies": { + "@sentry/node": "^10.71.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.13.3", + "@vitest/coverage-v8": "^4.1.11", + "@vitest/eslint-plugin": "^1.6.27", + "esbuild": "^0.27.0", + "eslint": "^10.9.1", + "eslint-config-prettier": "^10.1.8", + "knip": "^6.32.2", + "prettier": "3.9.6", + "typescript": "^6.0.3", + "typescript-eslint": "^8.68.0", + "vitest": "^4.1.11" + } +} diff --git a/docker/prod/launcher/pnpm-lock.yaml b/docker/prod/launcher/pnpm-lock.yaml new file mode 100644 index 00000000..abda2e0f --- /dev/null +++ b/docker/prod/launcher/pnpm-lock.yaml @@ -0,0 +1,2780 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@sentry/node': + specifier: ^10.71.0 + version: 10.71.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.9.1(jiti@2.7.0)) + '@types/node': + specifier: ^24.13.3 + version: 24.13.3 + '@vitest/coverage-v8': + specifier: ^4.1.11 + version: 4.1.11(vitest@4.1.11) + '@vitest/eslint-plugin': + specifier: ^1.6.27 + version: 1.6.27(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.11) + esbuild: + specifier: ^0.27.0 + version: 0.27.7 + eslint: + specifier: ^10.9.1 + version: 10.9.1(jiti@2.7.0) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.9.1(jiti@2.7.0)) + knip: + specifier: ^6.32.2 + version: 6.32.2 + prettier: + specifier: 3.9.6 + version: 3.9.6 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + typescript-eslint: + specifier: ^8.68.0 + version: 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + vitest: + specifier: ^4.1.11 + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) + +packages: + + '@apm-js-collab/code-transformer-bundler-plugins@0.7.4': + resolution: {integrity: sha512-nAfOeZPSUAQvJa1iFT/5oCrTm5YQhMMrfCNthNnaXHZiOQhu1KGuLoIx7HtbAi3wfwaBYLaICPIeenIaEwcXIg==} + engines: {node: '>=18.0.0'} + + '@apm-js-collab/code-transformer@0.18.1': + resolution: {integrity: sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ==} + hasBin: true + + '@apm-js-collab/tracing-hooks@0.13.0': + resolution: {integrity: sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@opentelemetry/api-logs@0.220.0': + resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.10.0': + resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/instrumentation@0.220.0': + resolution: {integrity: sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.10.0': + resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.10.0': + resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.10.0': + resolution: {integrity: sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + + '@oxc-parser/binding-android-arm-eabi@0.143.0': + resolution: {integrity: sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.143.0': + resolution: {integrity: sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.143.0': + resolution: {integrity: sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.143.0': + resolution: {integrity: sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.143.0': + resolution: {integrity: sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': + resolution: {integrity: sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': + resolution: {integrity: sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.143.0': + resolution: {integrity: sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.143.0': + resolution: {integrity: sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': + resolution: {integrity: sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': + resolution: {integrity: sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.143.0': + resolution: {integrity: sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.143.0': + resolution: {integrity: sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.143.0': + resolution: {integrity: sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.143.0': + resolution: {integrity: sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.143.0': + resolution: {integrity: sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.143.0': + resolution: {integrity: sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.143.0': + resolution: {integrity: sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.143.0': + resolution: {integrity: sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.143.0': + resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + + '@oxc-project/types@0.146.0': + resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + + '@rolldown/binding-android-arm-eabi@1.2.5': + resolution: {integrity: sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.5': + resolution: {integrity: sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.5': + resolution: {integrity: sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.5': + resolution: {integrity: sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.5': + resolution: {integrity: sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + resolution: {integrity: sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + resolution: {integrity: sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.5': + resolution: {integrity: sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + resolution: {integrity: sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + resolution: {integrity: sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.5': + resolution: {integrity: sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.5': + resolution: {integrity: sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.5': + resolution: {integrity: sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + resolution: {integrity: sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.5': + resolution: {integrity: sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sentry/conventions@0.16.0': + resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==} + engines: {node: '>=14'} + + '@sentry/core@10.71.0': + resolution: {integrity: sha512-OIjT7rzcWJjUC6r3eBT3Td1j0afDBMkbbx9jTocSD+ZSfc25eEU7hoIPS0WvfeIOTIN3y8bfQnXavwMReaNVHQ==} + engines: {node: '>=18'} + + '@sentry/node-core@10.71.0': + resolution: {integrity: sha512-sxd0/ZW+Uda/17H0R7lB2Othm37VYcdwKdWdyHRizg872TfCX4UwTTPaoS2tMJAUjV2tVh83ZA0fsoC2q9SpjA==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/exporter-trace-otlp-http': '>=0.57.0 <1' + '@opentelemetry/instrumentation': '>=0.57.1 <1' + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/core': + optional: true + '@opentelemetry/exporter-trace-otlp-http': + optional: true + '@opentelemetry/instrumentation': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + + '@sentry/node@10.71.0': + resolution: {integrity: sha512-bw2M/xkMu2+ATo6QWFmtTZTYp5LV1krt9/DTtYqtt4GmhXmXgdFPXCv6783AJI3HUoEMx2BcehhNbKzGrFXo/g==} + engines: {node: '>=18'} + + '@sentry/opentelemetry@10.71.0': + resolution: {integrity: sha512-YgeL0xTObKma3MuOrt+/6M/f6mo/Z08LHh3OxPomzQpgpCgCLGyJ/739cDSSH1LRjqWdPQtoia5asl5ZRdhWmw==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + + '@sentry/server-utils@10.71.0': + resolution: {integrity: sha512-zdyShKNsghzPGVWVRzc7oybYTsRZdKgdPtq+dsksImo1b6n7ILlD7eYx1Q0eAOZoWkDqDmm+Ml6MQb76IT1eTA==} + engines: {node: '>=18'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@typescript-eslint/eslint-plugin@8.68.0': + resolution: {integrity: sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.68.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.68.0': + resolution: {integrity: sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.68.0': + resolution: {integrity: sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.68.0': + resolution: {integrity: sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.68.0': + resolution: {integrity: sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.68.0': + resolution: {integrity: sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.68.0': + resolution: {integrity: sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.68.0': + resolution: {integrity: sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.68.0': + resolution: {integrity: sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.68.0': + resolution: {integrity: sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + peerDependencies: + '@vitest/browser': 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/eslint-plugin@1.6.27': + resolution: {integrity: sha512-X1RCAfwbatG4GFbJ/1PIHP9MSZMt0JJThqbYID+vS4L783k6QGlLPe421wQE8dHXuGCcenVLsydiM4qzsYWxhg==} + engines: {node: '>=18'} + peerDependencies: + '@typescript-eslint/eslint-plugin': '*' + eslint: '>=8.57.0' + typescript: '>=5.0.0' + vitest: '*' + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + typescript: + optional: true + vitest: + optional: true + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + cjs-module-lexer@2.2.1: + resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.9.1: + resolution: {integrity: sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + engines: {node: '>=18.3.0'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.14.1: + resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-in-the-middle@3.3.3: + resolution: {integrity: sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==} + engines: {node: '>=18'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + knip@6.32.2: + resolution: {integrity: sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + meriyah@6.1.4: + resolution: {integrity: sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==} + engines: {node: '>=18.0.0'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + oxc-parser@0.143.0: + resolution: {integrity: sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown@1.2.5: + resolution: {integrity: sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + semifies@1.0.0: + resolution: {integrity: sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.68.0: + resolution: {integrity: sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + unbash@4.0.10: + resolution: {integrity: sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg==} + engines: {node: '>=14'} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.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 + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + 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.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + 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 + + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@apm-js-collab/code-transformer-bundler-plugins@0.7.4': + dependencies: + '@apm-js-collab/code-transformer': 0.18.1 + es-module-lexer: 2.3.2 + magic-string: 0.30.21 + module-details-from-path: 1.0.4 + + '@apm-js-collab/code-transformer@0.18.1': + dependencies: + '@types/estree': 1.0.9 + astring: 1.9.0 + esquery: 1.7.0 + meriyah: 6.1.4 + semifies: 1.0.0 + source-map: 0.6.1 + + '@apm-js-collab/tracing-hooks@0.13.0': + dependencies: + '@apm-js-collab/code-transformer': 0.18.1 + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@10.9.1(jiti@2.7.0))': + dependencies: + eslint: 10.9.1(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.9.1(jiti@2.7.0))': + optionalDependencies: + eslint: 10.9.1(jiti@2.7.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@opentelemetry/api-logs@0.220.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + import-in-the-middle: 3.3.3 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/semantic-conventions@1.43.0': {} + + '@oxc-parser/binding-android-arm-eabi@0.143.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.143.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.143.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.143.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.143.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.143.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.143.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.143.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.143.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.143.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.143.0': + optional: true + + '@oxc-project/types@0.143.0': {} + + '@oxc-project/types@0.146.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + + '@rolldown/binding-android-arm-eabi@1.2.5': + optional: true + + '@rolldown/binding-android-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-x64@1.2.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.5': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sentry/conventions@0.16.0': {} + + '@sentry/core@10.71.0': + dependencies: + '@sentry/conventions': 0.16.0 + + '@sentry/node-core@10.71.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))': + dependencies: + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.71.0 + '@sentry/opentelemetry': 10.71.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)) + import-in-the-middle: 3.3.3 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + + '@sentry/node@10.71.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.71.0 + '@sentry/node-core': 10.71.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)) + '@sentry/opentelemetry': 10.71.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)) + '@sentry/server-utils': 10.71.0 + import-in-the-middle: 3.3.3 + transitivePeerDependencies: + - '@opentelemetry/core' + - '@opentelemetry/exporter-trace-otlp-http' + - supports-color + + '@sentry/opentelemetry@10.71.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.71.0 + + '@sentry/server-utils@10.71.0': + dependencies: + '@apm-js-collab/code-transformer-bundler-plugins': 0.7.4 + '@apm-js-collab/tracing-hooks': 0.13.0 + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.71.0 + meriyah: 6.1.4 + transitivePeerDependencies: + - supports-color + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/type-utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.68.0 + eslint: 10.9.1(jiti@2.7.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3 + eslint: 10.9.1(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.68.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) + '@typescript-eslint/types': 8.68.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.68.0': + dependencies: + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 + + '@typescript-eslint/tsconfig-utils@8.68.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.9.1(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.68.0': {} + + '@typescript-eslint/typescript-estree@8.68.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.68.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@6.0.3) + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.68.0': + dependencies: + '@typescript-eslint/types': 8.68.0 + eslint-visitor-keys: 5.0.1 + + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) + + '@vitest/eslint-plugin@1.6.27(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.11)': + dependencies: + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + typescript: 6.0.3 + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + astring@1.9.0: {} + + balanced-match@4.0.4: {} + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + chai@6.2.2: {} + + cjs-module-lexer@2.2.1: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.3.2: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@10.9.1(jiti@2.7.0)): + dependencies: + eslint: 10.9.1(jiti@2.7.0) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.9.1(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + formatly@0.3.0: + dependencies: + fd-package-json: 2.0.0 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.14.1: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-in-the-middle@3.3.3: + dependencies: + cjs-module-lexer: 2.2.1 + es-module-lexer: 2.3.2 + module-details-from-path: 1.0.4 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jiti@2.7.0: {} + + js-tokens@10.0.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + knip@6.32.2: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + formatly: 0.3.0 + get-tsconfig: 4.14.1 + jiti: 2.7.0 + oxc-parser: 0.143.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.7 + smol-toml: 1.8.0 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 4.0.10 + yaml: 2.9.0 + zod: 4.4.3 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + 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 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + meriyah@6.1.4: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + module-details-from-path@1.0.4: {} + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + natural-compare@1.4.0: {} + + obug@2.1.4: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + oxc-parser@0.143.0: + dependencies: + '@oxc-project/types': 0.143.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.143.0 + '@oxc-parser/binding-android-arm64': 0.143.0 + '@oxc-parser/binding-darwin-arm64': 0.143.0 + '@oxc-parser/binding-darwin-x64': 0.143.0 + '@oxc-parser/binding-freebsd-x64': 0.143.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.143.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.143.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.143.0 + '@oxc-parser/binding-linux-arm64-musl': 0.143.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.143.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.143.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.143.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.143.0 + '@oxc-parser/binding-linux-x64-gnu': 0.143.0 + '@oxc-parser/binding-linux-x64-musl': 0.143.0 + '@oxc-parser/binding-openharmony-arm64': 0.143.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.143.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.143.0 + '@oxc-parser/binding-win32-x64-msvc': 0.143.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.9.6: {} + + punycode@2.3.1: {} + + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + + resolve-pkg-maps@1.0.0: {} + + rolldown@1.2.5: + dependencies: + '@oxc-project/types': 0.146.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.5 + '@rolldown/binding-android-arm64': 1.2.5 + '@rolldown/binding-darwin-arm64': 1.2.5 + '@rolldown/binding-darwin-x64': 1.2.5 + '@rolldown/binding-freebsd-x64': 1.2.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.5 + '@rolldown/binding-linux-arm64-gnu': 1.2.5 + '@rolldown/binding-linux-arm64-musl': 1.2.5 + '@rolldown/binding-linux-ppc64-gnu': 1.2.5 + '@rolldown/binding-linux-s390x-gnu': 1.2.5 + '@rolldown/binding-linux-x64-gnu': 1.2.5 + '@rolldown/binding-linux-x64-musl': 1.2.5 + '@rolldown/binding-openharmony-arm64': 1.2.5 + '@rolldown/binding-win32-arm64-msvc': 1.2.5 + '@rolldown/binding-win32-x64-msvc': 1.2.5 + + semifies@1.0.0: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + smol-toml@1.8.0: {} + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + strip-json-comments@5.0.3: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.1.1: {} + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.68.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) + eslint: 10.9.1(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + unbash@4.0.10: {} + + undici-types@7.18.2: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.26 + rolldown: 1.2.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + esbuild: 0.27.7 + fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 + + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.13.3 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + transitivePeerDependencies: + - msw + + walk-up-path@4.0.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + yaml@2.9.0: {} + + yocto-queue@0.1.0: {} + + zod@4.4.3: {} diff --git a/docker/prod/launcher/pnpm-workspace.yaml b/docker/prod/launcher/pnpm-workspace.yaml new file mode 100644 index 00000000..4272fd8e --- /dev/null +++ b/docker/prod/launcher/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/docker/prod/launcher/prettier.config.js b/docker/prod/launcher/prettier.config.js new file mode 100644 index 00000000..d4a2c78b --- /dev/null +++ b/docker/prod/launcher/prettier.config.js @@ -0,0 +1,6 @@ +export default { + trailingComma: "all", + useTabs: true, + tabWidth: 8, + semi: false, +} diff --git a/docker/prod/launcher/src/baked.test.ts b/docker/prod/launcher/src/baked.test.ts new file mode 100644 index 00000000..416e8089 --- /dev/null +++ b/docker/prod/launcher/src/baked.test.ts @@ -0,0 +1,16 @@ +import { describe, it } from "vitest" +import { bakedSentryDsns } from "./baked.js" + +describe("bakedSentryDsns", () => { + it("falls back to disabled telemetry outside an image build", ({ + expect, + }) => { + // esbuild injects the DSNs with --define only in the image + // build; here nothing is defined, so every fallback applies + expect(bakedSentryDsns).toEqual({ + webDsn: "", + coreDsn: "", + authRealtimeDsn: "", + }) + }) +}) diff --git a/docker/prod/launcher/src/baked.ts b/docker/prod/launcher/src/baked.ts new file mode 100644 index 00000000..4d3d4346 --- /dev/null +++ b/docker/prod/launcher/src/baked.ts @@ -0,0 +1,33 @@ +import type { SentryDsns } from "./mapping.js" + +// the central telemetry DSNs are injected here by esbuild --define at image +// build time; a build without them (local builds, tests) falls back to +// empty, which disables sentry entirely. A DSN is a write-only ingest +// address, safe to carry in a public image, and +// OXYNOTE_CRASH_REPORTING_DISABLED switches reporting off at runtime. +declare const __SENTRY_WEB_DSN__: string | undefined +declare const __SENTRY_CORE_DSN__: string | undefined +declare const __SENTRY_AUTH_REALTIME_DSN__: string | undefined +declare const __SENTRY_LAUNCHER_DSN__: string | undefined + +export const bakedSentryDsns: SentryDsns = { + webDsn: + typeof __SENTRY_WEB_DSN__ === "string" + ? __SENTRY_WEB_DSN__ + : "", + coreDsn: + typeof __SENTRY_CORE_DSN__ === "string" + ? __SENTRY_CORE_DSN__ + : "", + authRealtimeDsn: + typeof __SENTRY_AUTH_REALTIME_DSN__ === "string" + ? __SENTRY_AUTH_REALTIME_DSN__ + : "", +} + +// the launcher's own, kept out of SentryDsns because that bag is what gets +// handed to the children — this one is never passed to anybody. +export const bakedLauncherSentryDsn = + typeof __SENTRY_LAUNCHER_DSN__ === "string" + ? __SENTRY_LAUNCHER_DSN__ + : "" diff --git a/docker/prod/launcher/src/env.test.ts b/docker/prod/launcher/src/env.test.ts new file mode 100644 index 00000000..3a240608 --- /dev/null +++ b/docker/prod/launcher/src/env.test.ts @@ -0,0 +1,558 @@ +import { describe, it } from "vitest" +import { loadConfig } from "./env.js" +import { completeEnv } from "./test-helpers.js" + +describe("loadConfig", () => { + it("maps every required variable onto the typed configuration", ({ + expect, + }) => { + const config = loadConfig(completeEnv()) + + expect(config.databaseDsn).toBe( + "postgresql://user:pass@postgres/oxynote", + ) + expect(config.valkeyDsn).toBe("redis://valkey:6379") + expect(config.objectStorage).toEqual({ + url: "http://s3.example.com", + accessKey: "access", + secretKey: "secret", + bucket: "documents", + region: "", + }) + }) + + it("ignores variables outside the OXYNOTE_ namespace", ({ expect }) => { + const config = loadConfig( + completeEnv({ PATH: "/usr/bin", HOME: "/oxynote" }), + ) + + expect(config.publicOrigin).toBe("https://notes.example.com") + }) + + it("treats an empty value as an absent variable", ({ expect }) => { + expect(() => + loadConfig(completeEnv({ OXYNOTE_DB_DSN: "" })), + ).toThrow("OXYNOTE_DB_DSN") + }) + + it.for([ + { name: "a typo", input: "OXYNOTE_PUBILC_URL" }, + { + name: "an internal core variable", + input: "OXYNOTE_CORE_DB_DSN", + }, + { + name: "an internal auth-realtime variable", + input: "OXYNOTE_AUTH_REALTIME_ADDRESS", + }, + ])("rejects $name by name", ({ input }, { expect }) => { + expect(() => + loadConfig(completeEnv({ [input]: "value" })), + ).toThrow(input) + }) + + describe("public URL", () => { + it("derives the host forms and the WebSocket origin", ({ + expect, + }) => { + const config = loadConfig( + completeEnv({ + OXYNOTE_PUBLIC_URL: + "https://notes.example.com:8443", + }), + ) + + expect(config.publicOrigin).toBe( + "https://notes.example.com:8443", + ) + expect(config.publicHostPort).toBe( + "notes.example.com:8443", + ) + expect(config.cookieDomain).toBe("notes.example.com") + expect(config.publicWebSocketOrigin).toBe( + "wss://notes.example.com:8443", + ) + }) + + it("swaps http for ws", ({ expect }) => { + const config = loadConfig( + completeEnv({ + OXYNOTE_PUBLIC_URL: + "http://localhost:8080", + }), + ) + + expect(config.publicWebSocketOrigin).toBe( + "ws://localhost:8080", + ) + }) + + it("strips a trailing slash", ({ expect }) => { + const config = loadConfig( + completeEnv({ + OXYNOTE_PUBLIC_URL: + "https://notes.example.com/", + }), + ) + + expect(config.publicOrigin).toBe( + "https://notes.example.com", + ) + }) + + it.for([ + { + name: "a path", + input: "https://example.com/oxynote", + }, + { name: "a query", input: "https://example.com/?a=b" }, + { name: "a bare host", input: "notes.example.com" }, + ])("rejects $name", ({ input }, { expect }) => { + expect(() => + loadConfig( + completeEnv({ + OXYNOTE_PUBLIC_URL: input, + }), + ), + ).toThrow("OXYNOTE_PUBLIC_URL") + }) + }) + + describe("valkey URL", () => { + it("accepts credentials and a database index", ({ expect }) => { + const config = loadConfig( + completeEnv({ + OXYNOTE_VALKEY_DSN: + "rediss://user:pass@valkey:6379/2", + }), + ) + + expect(config.valkeyDsn).toBe( + "rediss://user:pass@valkey:6379/2", + ) + }) + + it.for([ + { name: "an http URL", input: "http://valkey:6379" }, + { name: "a bare address", input: "valkey:6379" }, + ])("rejects $name", ({ input }, { expect }) => { + expect(() => + loadConfig( + completeEnv({ + OXYNOTE_VALKEY_DSN: input, + }), + ), + ).toThrow("OXYNOTE_VALKEY_DSN") + }) + }) + + describe("storage DSN", () => { + it("decodes the credentials and reads the region", ({ + expect, + }) => { + const config = loadConfig( + completeEnv({ + OXYNOTE_OBJECT_STORAGE_DSN: + "https://ak%40x:s%3Akey@s3.example.com/bucket?region=eu-central-1", + }), + ) + + expect(config.objectStorage).toEqual({ + url: "https://s3.example.com", + accessKey: "ak@x", + secretKey: "s:key", + bucket: "bucket", + region: "eu-central-1", + }) + }) + + it("defaults the bucket when the path is empty", ({ + expect, + }) => { + const config = loadConfig( + completeEnv({ + OXYNOTE_OBJECT_STORAGE_DSN: + "http://a:b@s3.example.com", + }), + ) + + expect(config.objectStorage?.bucket).toBe("oxynote") + }) + + it.for([ + { + name: "missing credentials", + input: "http://s3.example.com/b", + }, + { + name: "a redis scheme", + input: "redis://a:b@s3.example.com", + }, + { name: "not a URL", input: "::" }, + ])("rejects $name", ({ input }, { expect }) => { + expect(() => + loadConfig( + completeEnv({ + OXYNOTE_OBJECT_STORAGE_DSN: + input, + }), + ), + ).toThrow("OXYNOTE_OBJECT_STORAGE_DSN") + }) + }) + + it("runs without valkey", ({ expect }) => { + const config = loadConfig( + completeEnv({ OXYNOTE_VALKEY_DSN: undefined }), + ) + + expect(config.valkeyDsn).toBeUndefined() + }) + + it("runs without an object store", ({ expect }) => { + const config = loadConfig( + completeEnv({ OXYNOTE_OBJECT_STORAGE_DSN: undefined }), + ) + + expect(config.objectStorage).toBeUndefined() + }) + + describe("smtp", () => { + it("splits the URL and defaults tls from the scheme", ({ + expect, + }) => { + const config = loadConfig( + completeEnv({ + OXYNOTE_SMTP_DSN: + "smtps://user:pa%40ss@mail.example.com:465", + OXYNOTE_EMAIL_FROM_ADDRESS: + "Oxynote ", + }), + ) + + expect(config.smtp).toEqual({ + host: "mail.example.com", + port: "465", + username: "user", + password: "pa@ss", + tls: "tls", + }) + expect(config.emailFromAddress).toBe( + "Oxynote ", + ) + }) + + it("lets the query override the tls mode", ({ expect }) => { + const config = loadConfig( + completeEnv({ + OXYNOTE_SMTP_DSN: + "smtp://mail.example.com:587?tls=starttls", + OXYNOTE_EMAIL_FROM_ADDRESS: + "team@example.com", + }), + ) + + expect(config.smtp?.tls).toBe("starttls") + }) + + it("is disabled when unset", ({ expect }) => { + const config = loadConfig(completeEnv()) + + expect(config.smtp).toBeUndefined() + expect(config.emailFromAddress).toBe("") + }) + + it.for([ + { + name: "a missing port", + input: "smtp://mail.example.com", + }, + { + name: "an http scheme", + input: "http://mail.example.com:587", + }, + { + name: "an unknown tls mode", + input: "smtp://mail.example.com:587?tls=maybe", + }, + { name: "not a URL", input: "::" }, + ])("rejects $name", ({ input }, { expect }) => { + expect(() => + loadConfig( + completeEnv({ + OXYNOTE_SMTP_DSN: input, + OXYNOTE_EMAIL_FROM_ADDRESS: + "team@example.com", + }), + ), + ).toThrow("OXYNOTE_SMTP_DSN") + }) + + it("requires the from address alongside the relay", ({ + expect, + }) => { + expect(() => + loadConfig( + completeEnv({ + OXYNOTE_SMTP_DSN: + "smtp://mail.example.com:587", + }), + ), + ).toThrow("OXYNOTE_EMAIL_FROM_ADDRESS") + }) + }) + + describe("feature groups", () => { + it("requires the meilisearch master key with the URL", ({ + expect, + }) => { + expect(() => + loadConfig( + completeEnv({ + OXYNOTE_MEILISEARCH_URL: + "http://meilisearch:7700", + }), + ), + ).toThrow("OXYNOTE_MEILISEARCH_MASTER_KEY") + }) + + it("rejects a companion without its keying variable", ({ + expect, + }) => { + expect(() => + loadConfig( + completeEnv({ + OXYNOTE_CHANGE_DETECTION_API_KEY: + "key", + }), + ), + ).toThrow("OXYNOTE_CHANGE_DETECTION_API_KEY") + }) + + it("requires the github app companions with the app id", ({ + expect, + }) => { + expect(() => + loadConfig( + completeEnv({ + OXYNOTE_GITHUB_APP_ID: "123", + }), + ), + ).toThrow( + /OXYNOTE_GITHUB_APP_SLUG[\s\S]*OXYNOTE_GITHUB_APP_SIGNATURE_SECRET/, + ) + }) + + it("requires the slack app companions with the client id", ({ + expect, + }) => { + expect(() => + loadConfig( + completeEnv({ + OXYNOTE_SLACK_APP_CLIENT_ID: + "id", + }), + ), + ).toThrow( + /OXYNOTE_SLACK_APP_CLIENT_SECRET[\s\S]*OXYNOTE_SLACK_APP_SIGNATURE_SECRET/, + ) + }) + + it("rejects assistant settings without a provider", ({ + expect, + }) => { + expect(() => + loadConfig( + completeEnv({ + OXYNOTE_AI_ASSISTANT_MODEL: + "claude-opus-5", + }), + ), + ).toThrow("OXYNOTE_AI_ASSISTANT_MODEL") + }) + + it("assembles an enabled feature into its config group", ({ + expect, + }) => { + const config = loadConfig( + completeEnv({ + OXYNOTE_MEILISEARCH_URL: + "http://meilisearch:7700", + OXYNOTE_MEILISEARCH_MASTER_KEY: + "master", + OXYNOTE_CHANGE_DETECTION_URL: + "http://changeDetection:5000", + OXYNOTE_GITHUB_APP_ID: "123", + OXYNOTE_GITHUB_APP_SLUG: "oxynote", + OXYNOTE_GITHUB_APP_SIGNATURE_SECRET: + "sig", + OXYNOTE_SLACK_APP_CLIENT_ID: "cid", + OXYNOTE_SLACK_APP_CLIENT_SECRET: "cs", + OXYNOTE_SLACK_APP_SIGNATURE_SECRET: + "ss", + OXYNOTE_AI_ASSISTANT_PROVIDER: + "anthropic", + OXYNOTE_AI_ASSISTANT_API_KEY: "sk-ant", + }), + ) + + expect(config.meilisearch).toEqual({ + url: "http://meilisearch:7700", + masterKey: "master", + }) + expect(config.changeDetection).toEqual({ + url: "http://changeDetection:5000", + apiKey: "", + }) + expect(config.githubApp).toEqual({ + appId: "123", + appSlug: "oxynote", + signatureSecret: "sig", + }) + expect(config.slackApp).toEqual({ + clientId: "cid", + clientSecret: "cs", + signatureSecret: "ss", + }) + expect(config.aiAssistant.PROVIDER).toBe("anthropic") + expect(config.aiAssistant.API_KEY).toBe("sk-ant") + expect(config.aiAssistant.MODEL).toBe("") + }) + }) + + describe("social login", () => { + it("registers a provider given both halves of its credentials", ({ + expect, + }) => { + const config = loadConfig( + completeEnv({ + OXYNOTE_SOCIAL_LOGIN_GITHUB_CLIENT_ID: + "id", + OXYNOTE_SOCIAL_LOGIN_GITHUB_CLIENT_SECRET: + "secret", + }), + ) + + expect(config.socialLogin).toEqual({ + github: { + clientId: "id", + clientSecret: "secret", + }, + }) + }) + + it("rejects a provider with a single half", ({ expect }) => { + expect(() => + loadConfig( + completeEnv({ + OXYNOTE_SOCIAL_LOGIN_GOOGLE_CLIENT_ID: + "id", + }), + ), + ).toThrow("OXYNOTE_SOCIAL_LOGIN_GOOGLE_CLIENT_SECRET") + }) + }) + + describe("tuning", () => { + it("passes the values through as the strings the components parse", ({ + expect, + }) => { + const config = loadConfig( + completeEnv({ + OXYNOTE_MAX_ORGANIZATIONS: "10", + OXYNOTE_MAX_ORGANIZATION_MEMBERS: "25", + OXYNOTE_RATE_LIMIT_DISABLED: "true", + OXYNOTE_MAX_DOCUMENT_HISTORY_ENTRIES: + "0", + OXYNOTE_DOCUMENT_HISTORY_RETENTION: + "2160h", + OXYNOTE_LOG_LEVEL: "DEBUG", + }), + ) + + expect(config.maxOrganizations).toBe("10") + expect(config.maxOrganizationMembers).toBe("25") + expect(config.rateLimitDisabled).toBe(true) + expect(config.maxDocumentHistoryEntries).toBe("0") + expect(config.documentHistoryRetention).toBe("2160h") + expect(config.logLevel).toBe("DEBUG") + }) + + it.for([ + { + name: "a word as a counter", + input: { OXYNOTE_MAX_ORGANIZATIONS: "many" }, + expected: "OXYNOTE_MAX_ORGANIZATIONS", + }, + { + name: "a number as a retention", + input: { + OXYNOTE_DOCUMENT_HISTORY_RETENTION: + "90", + }, + expected: "OXYNOTE_DOCUMENT_HISTORY_RETENTION", + }, + { + name: "an unknown log level", + input: { OXYNOTE_LOG_LEVEL: "TRACE" }, + expected: "OXYNOTE_LOG_LEVEL", + }, + { + name: "a flag spelled 1", + input: { OXYNOTE_RATE_LIMIT_DISABLED: "1" }, + expected: "OXYNOTE_RATE_LIMIT_DISABLED", + }, + ])("rejects $name", ({ input, expected }, { expect }) => { + expect(() => loadConfig(completeEnv(input))).toThrow( + expected, + ) + }) + }) + + describe("secret overrides", () => { + it("carries the overrides through unset by default", ({ + expect, + }) => { + const config = loadConfig(completeEnv()) + + expect(config.authSecret).toBeUndefined() + expect(config.dataSourceEncryptionKey).toBeUndefined() + }) + + it.for([ + { name: "16", input: "0123456789abcdef" }, + { name: "24", input: "0123456789abcdef01234567" }, + { + name: "32", + input: "0123456789abcdef0123456789abcdef", + }, + ])( + "accepts a $name byte encryption key", + ({ input }, { expect }) => { + const config = loadConfig( + completeEnv({ + OXYNOTE_DATA_SOURCE_ENCRYPTION_KEY: + input, + }), + ) + + expect(config.dataSourceEncryptionKey).toBe( + input, + ) + }, + ) + + it("rejects an encryption key of any other length", ({ + expect, + }) => { + expect(() => + loadConfig( + completeEnv({ + OXYNOTE_DATA_SOURCE_ENCRYPTION_KEY: + "tooshort", + }), + ), + ).toThrow("OXYNOTE_DATA_SOURCE_ENCRYPTION_KEY") + }) + }) +}) diff --git a/docker/prod/launcher/src/env.ts b/docker/prod/launcher/src/env.ts new file mode 100644 index 00000000..fedaa302 --- /dev/null +++ b/docker/prod/launcher/src/env.ts @@ -0,0 +1,629 @@ +import { z } from "zod" + +export interface ObjectStorageConfig { + url: string + accessKey: string + secretKey: string + bucket: string + region: string +} + +const tlsModes = ["none", "starttls", "tls"] as const + +type TlsMode = (typeof tlsModes)[number] + +function isTlsMode(value: string): value is TlsMode { + return (tlsModes as readonly string[]).includes(value) +} + +export interface SmtpConfig { + host: string + port: string + username: string + password: string + tls: TlsMode +} + +export interface OAuthCredentials { + clientId: string + clientSecret: string +} + +export interface GitHubAppConfig { + appId: string + appSlug: string + signatureSecret: string +} + +export interface SlackAppConfig { + clientId: string + clientSecret: string + signatureSecret: string +} + +export type SocialLoginProviderName = "github" | "google" | "slack" + +export interface Config { + // the public origin, no trailing slash; every public URL the + // components need derives from it. + publicOrigin: string + // host[:port] — core's origin allowlist matches this bare form for + // WebSocket upgrades. + publicHostPort: string + // the bare hostname, used as the session cookie domain. + cookieDomain: string + // the ws(s) counterpart of publicOrigin. + publicWebSocketOrigin: string + databaseDsn: string + // absent on a deployment running without valkey: the assistant keeps + // its conversations in the core process and better-auth keeps no + // secondary storage. + valkeyDsn: string | undefined + // absent on a deployment without an object store: objects are kept on + // the data volume instead. + objectStorage: ObjectStorageConfig | undefined + smtp: SmtpConfig | undefined + emailFromAddress: string + meilisearch: { url: string; masterKey: string } | undefined + changeDetection: { url: string; apiKey: string } | undefined + githubApp: GitHubAppConfig | undefined + slackApp: SlackAppConfig | undefined + socialLogin: Partial> + // the assistant family is passed through to core untouched, keyed by + // the OXYNOTE_AI_ASSISTANT_ suffix; core owns its deep validation. + aiAssistant: Record + maxOrganizations: string + maxOrganizationMembers: string + rateLimitDisabled: boolean + maxDocumentHistoryEntries: string + documentHistoryRetention: string + logLevel: string + termsOfServiceUrl: string + privacyPolicyUrl: string + crashReportingDisabled: boolean + // explicit secret overrides; unset means generate-and-persist. + authSecret: string | undefined + dataSourceEncryptionKey: string | undefined +} + +// every URL here is dialed or redirected to, so the scheme is pinned: bare +// z.url() accepts "core:8080" as a valid URL with a "core:" scheme, which +// would boot fine and then fail on first use. +function httpUrl() { + return z.url({ protocol: /^https?$/ }) +} + +// only the two literals are accepted: a flag spelled "1" or "yes" would +// otherwise read as false and silently leave the setting at its default. +function flag() { + return z + .enum(["true", "false"]) + .optional() + .transform((value) => value === "true") +} + +// a whole number kept as the string the component parses itself. +function count() { + return z.string().regex(/^\d+$/, "must be a whole number").optional() +} + +// the public origin every URL derives from. A path would silently break the +// caddy routing, which is root-anchored. +const publicOrigin = httpUrl().refine( + (value) => { + // zod runs this check even when the base URL check already + // failed, so the parse cannot be assumed to succeed. + try { + const url = new URL(value) + + return url.pathname === "/" && !url.search && !url.hash + } catch { + return false + } + }, + { error: "must be an origin without a path" }, +) + +// both components take the URL verbatim: node-redis parses it natively and +// core hands it to redigo's URL dialer, so credentials and a database index +// are supported end to end. +const valkeyDsn = z + .string() + .min(1) + .refine( + (value) => { + try { + const url = new URL(value) + + return ( + url.protocol === "redis:" || + url.protocol === "rediss:" + ) + } catch { + return false + } + }, + { error: "must be a redis:// or rediss:// URL" }, + ) + +// one DSN carrying everything the S3 client needs: +// http(s)://ACCESS_KEY:SECRET_KEY@host:port/bucket[?region=...] +const objectStorageDsn = z + .string() + .min(1) + .transform((value, ctx): ObjectStorageConfig => { + let url: URL + + try { + url = new URL(value) + } catch { + ctx.addIssue({ + code: "custom", + message: "must be a URL", + }) + + return z.NEVER + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + ctx.addIssue({ + code: "custom", + message: "must use http or https", + }) + + return z.NEVER + } + + if (!url.username || !url.password) { + ctx.addIssue({ + code: "custom", + message: "must carry the access key and secret key as URL credentials", + }) + + return z.NEVER + } + + const bucket = url.pathname.replaceAll("/", "") + + return { + url: url.origin, + accessKey: decodeURIComponent(url.username), + secretKey: decodeURIComponent(url.password), + bucket: bucket || "oxynote", + region: url.searchParams.get("region") ?? "", + } + }) + +// one URL carrying the relay's coordinates: +// smtp[s]://[user:pass@]host:port[?tls=none|starttls|tls]. The scheme picks +// the TLS default (smtp -> none, smtps -> tls); the query overrides it, +// which is how STARTTLS on port 587 is spelled. +const smtpDsn = z + .string() + .min(1) + .transform((value, ctx): SmtpConfig => { + let url: URL + + try { + url = new URL(value) + } catch { + ctx.addIssue({ + code: "custom", + message: "must be a URL", + }) + + return z.NEVER + } + + if (url.protocol !== "smtp:" && url.protocol !== "smtps:") { + ctx.addIssue({ + code: "custom", + message: "must use smtp or smtps", + }) + + return z.NEVER + } + + if (!url.port) { + ctx.addIssue({ + code: "custom", + message: "must name a port", + }) + + return z.NEVER + } + + const tlsParam = url.searchParams.get("tls") + + if (tlsParam !== null && !isTlsMode(tlsParam)) { + ctx.addIssue({ + code: "custom", + message: "tls must be none, starttls, or tls", + }) + + return z.NEVER + } + + const fallbackTls: TlsMode = + url.protocol === "smtps:" ? "tls" : "none" + + return { + host: url.hostname, + port: url.port, + username: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + tls: + tlsParam !== null && isTlsMode(tlsParam) + ? tlsParam + : fallbackTls, + } + }) + +// the key encrypts stored data-source credentials with AES, which accepts +// exactly these lengths; core never validates it and would fail on the +// first credential save instead. +const encryptionKey = z + .string() + .optional() + .refine( + (value) => + value === undefined || + [16, 24, 32].includes( + new TextEncoder().encode(value).length, + ), + { error: "must be exactly 16, 24, or 32 bytes" }, + ) + +// the value core parses with Go's time.ParseDuration. +const goDuration = z + .string() + .regex( + /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$|^0$/, + "must be a Go duration such as 2160h", + ) + .optional() + +const socialLoginProviders = [ + { + name: "github", + idKey: "OXYNOTE_SOCIAL_LOGIN_GITHUB_CLIENT_ID", + secretKey: "OXYNOTE_SOCIAL_LOGIN_GITHUB_CLIENT_SECRET", + }, + { + name: "google", + idKey: "OXYNOTE_SOCIAL_LOGIN_GOOGLE_CLIENT_ID", + secretKey: "OXYNOTE_SOCIAL_LOGIN_GOOGLE_CLIENT_SECRET", + }, + { + name: "slack", + idKey: "OXYNOTE_SOCIAL_LOGIN_SLACK_CLIENT_ID", + secretKey: "OXYNOTE_SOCIAL_LOGIN_SLACK_CLIENT_SECRET", + }, +] as const satisfies readonly { + name: SocialLoginProviderName + idKey: string + secretKey: string +}[] + +const aiAssistantKeys = [ + "PROVIDER", + "MODEL", + "API_KEY", + "BASE_URL", + "MAX_TOKENS", + "REQUEST_TIMEOUT", + "SUMMARY_MODEL", + "AZURE_API_VERSION", + "BEDROCK_REGION", + "BEDROCK_ACCESS_KEY", + "BEDROCK_SECRET_ACCESS_KEY", + "BEDROCK_SESSION_TOKEN", + "VERTEX_PROJECT_ID", + "VERTEX_REGION", + "VERTEX_SERVICE_ACCOUNT_JSON", +] as const + +// every variable an operator may set, spelled out. Anything else beginning +// with OXYNOTE_ is a boot error, so a typo — or an internal component +// variable — fails loudly instead of being silently ignored. +const baseSchema = z.object({ + OXYNOTE_PUBLIC_URL: publicOrigin, + OXYNOTE_DB_DSN: z.string().min(1), + OXYNOTE_VALKEY_DSN: valkeyDsn.optional(), + OXYNOTE_OBJECT_STORAGE_DSN: objectStorageDsn.optional(), + OXYNOTE_SMTP_DSN: smtpDsn.optional(), + OXYNOTE_EMAIL_FROM_ADDRESS: z.string().optional(), + OXYNOTE_MEILISEARCH_URL: httpUrl().optional(), + OXYNOTE_MEILISEARCH_MASTER_KEY: z.string().optional(), + OXYNOTE_CHANGE_DETECTION_URL: httpUrl().optional(), + OXYNOTE_CHANGE_DETECTION_API_KEY: z.string().optional(), + OXYNOTE_GITHUB_APP_ID: z + .string() + .regex(/^\d+$/, "must be the numeric app id") + .optional(), + OXYNOTE_GITHUB_APP_SLUG: z.string().optional(), + OXYNOTE_GITHUB_APP_SIGNATURE_SECRET: z.string().optional(), + OXYNOTE_SLACK_APP_CLIENT_ID: z.string().optional(), + OXYNOTE_SLACK_APP_CLIENT_SECRET: z.string().optional(), + OXYNOTE_SLACK_APP_SIGNATURE_SECRET: z.string().optional(), + OXYNOTE_SOCIAL_LOGIN_GITHUB_CLIENT_ID: z.string().optional(), + OXYNOTE_SOCIAL_LOGIN_GITHUB_CLIENT_SECRET: z.string().optional(), + OXYNOTE_SOCIAL_LOGIN_GOOGLE_CLIENT_ID: z.string().optional(), + OXYNOTE_SOCIAL_LOGIN_GOOGLE_CLIENT_SECRET: z.string().optional(), + OXYNOTE_SOCIAL_LOGIN_SLACK_CLIENT_ID: z.string().optional(), + OXYNOTE_SOCIAL_LOGIN_SLACK_CLIENT_SECRET: z.string().optional(), + OXYNOTE_AI_ASSISTANT_PROVIDER: z.string().optional(), + OXYNOTE_AI_ASSISTANT_MODEL: z.string().optional(), + OXYNOTE_AI_ASSISTANT_API_KEY: z.string().optional(), + OXYNOTE_AI_ASSISTANT_BASE_URL: z.string().optional(), + OXYNOTE_AI_ASSISTANT_MAX_TOKENS: count(), + OXYNOTE_AI_ASSISTANT_REQUEST_TIMEOUT: goDuration, + OXYNOTE_AI_ASSISTANT_SUMMARY_MODEL: z.string().optional(), + OXYNOTE_AI_ASSISTANT_AZURE_API_VERSION: z.string().optional(), + OXYNOTE_AI_ASSISTANT_BEDROCK_REGION: z.string().optional(), + OXYNOTE_AI_ASSISTANT_BEDROCK_ACCESS_KEY: z.string().optional(), + OXYNOTE_AI_ASSISTANT_BEDROCK_SECRET_ACCESS_KEY: z.string().optional(), + OXYNOTE_AI_ASSISTANT_BEDROCK_SESSION_TOKEN: z.string().optional(), + OXYNOTE_AI_ASSISTANT_VERTEX_PROJECT_ID: z.string().optional(), + OXYNOTE_AI_ASSISTANT_VERTEX_REGION: z.string().optional(), + OXYNOTE_AI_ASSISTANT_VERTEX_SERVICE_ACCOUNT_JSON: z.string().optional(), + OXYNOTE_MAX_ORGANIZATIONS: count(), + OXYNOTE_MAX_ORGANIZATION_MEMBERS: count(), + OXYNOTE_RATE_LIMIT_DISABLED: flag(), + OXYNOTE_MAX_DOCUMENT_HISTORY_ENTRIES: count(), + OXYNOTE_DOCUMENT_HISTORY_RETENTION: goDuration, + OXYNOTE_LOG_LEVEL: z + .enum(["DEBUG", "INFO", "WARN", "ERROR"]) + .optional(), + OXYNOTE_TERMS_OF_SERVICE_URL: httpUrl().optional(), + OXYNOTE_PRIVACY_POLICY_URL: httpUrl().optional(), + OXYNOTE_CRASH_REPORTING_DISABLED: flag(), + OXYNOTE_AUTH_SECRET: z.string().optional(), + OXYNOTE_DATA_SOURCE_ENCRYPTION_KEY: encryptionKey, +}) + +type ParsedValues = z.infer + +// each optional feature is keyed on one variable. A companion set without +// its key would be silently ignored by the component, and a key missing a +// required companion would fail deep inside it — both become boot errors +// here, named in the flat namespace. +const featureGroups: { + key: keyof ParsedValues + requires: (keyof ParsedValues)[] + companions: (keyof ParsedValues)[] +}[] = [ + { + key: "OXYNOTE_SMTP_DSN", + requires: ["OXYNOTE_EMAIL_FROM_ADDRESS"], + companions: ["OXYNOTE_EMAIL_FROM_ADDRESS"], + }, + { + key: "OXYNOTE_MEILISEARCH_URL", + requires: ["OXYNOTE_MEILISEARCH_MASTER_KEY"], + companions: ["OXYNOTE_MEILISEARCH_MASTER_KEY"], + }, + { + key: "OXYNOTE_CHANGE_DETECTION_URL", + requires: [], + companions: ["OXYNOTE_CHANGE_DETECTION_API_KEY"], + }, + { + key: "OXYNOTE_GITHUB_APP_ID", + requires: [ + "OXYNOTE_GITHUB_APP_SLUG", + "OXYNOTE_GITHUB_APP_SIGNATURE_SECRET", + ], + companions: [ + "OXYNOTE_GITHUB_APP_SLUG", + "OXYNOTE_GITHUB_APP_SIGNATURE_SECRET", + ], + }, + { + key: "OXYNOTE_SLACK_APP_CLIENT_ID", + requires: [ + "OXYNOTE_SLACK_APP_CLIENT_SECRET", + "OXYNOTE_SLACK_APP_SIGNATURE_SECRET", + ], + companions: [ + "OXYNOTE_SLACK_APP_CLIENT_SECRET", + "OXYNOTE_SLACK_APP_SIGNATURE_SECRET", + ], + }, + { + key: "OXYNOTE_AI_ASSISTANT_PROVIDER", + requires: [], + companions: aiAssistantKeys + .filter((key) => key !== "PROVIDER") + .map((key) => `OXYNOTE_AI_ASSISTANT_${key}` as const), + }, +] + +const schema = baseSchema.superRefine((values, ctx) => { + for (const group of featureGroups) { + if (values[group.key] !== undefined) { + for (const required of group.requires) { + if (values[required] !== undefined) { + continue + } + + ctx.addIssue({ + code: "custom", + path: [required], + message: `required when ${group.key} is set`, + }) + } + + continue + } + + for (const companion of group.companions) { + if (values[companion] === undefined) { + continue + } + + ctx.addIssue({ + code: "custom", + path: [companion], + message: `only meaningful with ${group.key}`, + }) + } + } + + // a social login provider needs both halves of its credentials or + // neither; one half is a boot error rather than a broken OAuth + // redirect a user discovers. + for (const provider of socialLoginProviders) { + const id = values[provider.idKey] + const secret = values[provider.secretKey] + + if (Boolean(id) === Boolean(secret)) { + continue + } + + ctx.addIssue({ + code: "custom", + path: [id ? provider.secretKey : provider.idKey], + message: `${provider.name} login needs both a client id and a client secret, or neither`, + }) + } +}) + +function socialLoginConfig( + values: ParsedValues, +): Partial> { + const login: Partial< + Record + > = {} + + for (const provider of socialLoginProviders) { + const clientId = values[provider.idKey] + const clientSecret = values[provider.secretKey] + + if (!clientId || !clientSecret) { + continue + } + + login[provider.name] = { clientId, clientSecret } + } + + return login +} + +function aiAssistantConfig(values: ParsedValues): Record { + return Object.fromEntries( + aiAssistantKeys.map((key) => [ + key, + values[`OXYNOTE_AI_ASSISTANT_${key}`] ?? "", + ]), + ) +} + +// loadConfig parses the container environment into the launcher's typed +// configuration. It is the only place that reads the environment, so a test +// builds a Config by calling this with a literal record. +export function loadConfig(source: Record): Config { + // compose files list variables that are left blank; an empty value + // reads as absent, so a required variable reports as missing instead + // of empty. + const present = Object.fromEntries( + Object.entries(source).filter( + ([, value]) => value !== undefined && value !== "", + ), + ) + + // the guard that keeps the public namespace closed: an undeclared + // OXYNOTE_ variable is a typo or an internal component variable, and + // silently ignoring either would leave the operator believing it + // took effect. + const known = new Set(Object.keys(baseSchema.shape)) + const unknown = Object.keys(present).filter( + (key) => key.startsWith("OXYNOTE_") && !known.has(key), + ) + + if (unknown.length > 0) { + throw new Error( + `unknown environment variables: ${unknown.join(", ")}. ` + + "The image is configured only through the " + + "documented OXYNOTE_* variables.", + ) + } + + const result = schema.safeParse(present) + + if (!result.success) { + throw new Error( + `invalid environment:\n${z.prettifyError(result.error)}`, + ) + } + + const values = result.data + const url = new URL(values.OXYNOTE_PUBLIC_URL) + + return { + publicOrigin: url.origin, + publicHostPort: url.host, + cookieDomain: url.hostname, + publicWebSocketOrigin: `${url.protocol === "https:" ? "wss" : "ws"}://${url.host}`, + databaseDsn: values.OXYNOTE_DB_DSN, + valkeyDsn: values.OXYNOTE_VALKEY_DSN, + objectStorage: values.OXYNOTE_OBJECT_STORAGE_DSN, + smtp: values.OXYNOTE_SMTP_DSN, + emailFromAddress: values.OXYNOTE_EMAIL_FROM_ADDRESS ?? "", + meilisearch: values.OXYNOTE_MEILISEARCH_URL + ? { + url: values.OXYNOTE_MEILISEARCH_URL, + masterKey: + values.OXYNOTE_MEILISEARCH_MASTER_KEY ?? + "", + } + : undefined, + changeDetection: values.OXYNOTE_CHANGE_DETECTION_URL + ? { + url: values.OXYNOTE_CHANGE_DETECTION_URL, + apiKey: + values.OXYNOTE_CHANGE_DETECTION_API_KEY ?? + "", + } + : undefined, + githubApp: values.OXYNOTE_GITHUB_APP_ID + ? { + appId: values.OXYNOTE_GITHUB_APP_ID, + appSlug: + values.OXYNOTE_GITHUB_APP_SLUG ?? + "", + signatureSecret: + values.OXYNOTE_GITHUB_APP_SIGNATURE_SECRET ?? + "", + } + : undefined, + slackApp: values.OXYNOTE_SLACK_APP_CLIENT_ID + ? { + clientId: values.OXYNOTE_SLACK_APP_CLIENT_ID, + clientSecret: + values.OXYNOTE_SLACK_APP_CLIENT_SECRET ?? + "", + signatureSecret: + values.OXYNOTE_SLACK_APP_SIGNATURE_SECRET ?? + "", + } + : undefined, + socialLogin: socialLoginConfig(values), + aiAssistant: aiAssistantConfig(values), + maxOrganizations: values.OXYNOTE_MAX_ORGANIZATIONS ?? "", + maxOrganizationMembers: + values.OXYNOTE_MAX_ORGANIZATION_MEMBERS ?? "", + rateLimitDisabled: values.OXYNOTE_RATE_LIMIT_DISABLED, + maxDocumentHistoryEntries: + values.OXYNOTE_MAX_DOCUMENT_HISTORY_ENTRIES ?? "", + documentHistoryRetention: + values.OXYNOTE_DOCUMENT_HISTORY_RETENTION ?? "", + logLevel: values.OXYNOTE_LOG_LEVEL ?? "", + termsOfServiceUrl: values.OXYNOTE_TERMS_OF_SERVICE_URL ?? "", + privacyPolicyUrl: values.OXYNOTE_PRIVACY_POLICY_URL ?? "", + crashReportingDisabled: values.OXYNOTE_CRASH_REPORTING_DISABLED, + authSecret: values.OXYNOTE_AUTH_SECRET, + dataSourceEncryptionKey: + values.OXYNOTE_DATA_SOURCE_ENCRYPTION_KEY, + } +} diff --git a/docker/prod/launcher/src/index.ts b/docker/prod/launcher/src/index.ts new file mode 100644 index 00000000..13847ce8 --- /dev/null +++ b/docker/prod/launcher/src/index.ts @@ -0,0 +1,286 @@ +import { spawn } from "node:child_process" +import net from "node:net" +import os from "node:os" +import { join } from "node:path" +import * as Sentry from "@sentry/node" +import { bakedLauncherSentryDsn, bakedSentryDsns } from "./baked.js" +import { loadConfig, type Config } from "./env.js" +import { checkLoopbackExposure } from "./loopback.js" +import { + authRealtimePort, + authRealtimeUrl, + buildChildEnvs, + caddyPort, + corePort, + coreUrl, + dataDir, + webPort, + webUrl, +} from "./mapping.js" +import { ensureSecrets } from "./secrets.js" +import { createCrashReporter } from "./sentry.js" +import { + createSupervisor, + type ChildSpec, + type Supervisor, +} from "./supervisor.js" + +// the composition root: the only module that reads the environment, touches +// the filesystem, spawns processes, or installs signal handlers. + +// built before anything else runs, because an invalid environment is one of +// the failures worth reporting and loadConfig throws on it — there is no +// parsed Config to read the switch from yet, so the raw variable is read +// here instead. +const crashReporter = createCrashReporter( + Sentry, + bakedLauncherSentryDsn, + process.env.OXYNOTE_CRASH_REPORTING_DISABLED === "true", +) + +// how long the ordered shutdown may take before the launcher force-exits; +// it stays under the example compose's stop_grace_period so docker never +// has to SIGKILL the whole container. +const shutdownDeadlineMs = 55_000 + +function log(line: string): void { + console.log(line) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +async function probe(url: string): Promise { + try { + const res = await fetch(url, { + signal: AbortSignal.timeout(2_000), + }) + + await res.body?.cancel() + + return res.status === 200 + } catch { + return false + } +} + +// the container's own externally reachable IPv4 addresses. +function externalAddresses(): string[] { + const addresses: string[] = [] + + for (const infos of Object.values(os.networkInterfaces())) { + for (const info of infos ?? []) { + if (info.family === "IPv4" && !info.internal) { + addresses.push(info.address) + } + } + } + + return addresses +} + +function connects(host: string, port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ host, port, timeout: 1_000 }) + + socket.once("connect", () => { + socket.destroy() + resolve(true) + }) + socket.once("error", () => { + resolve(false) + }) + socket.once("timeout", () => { + socket.destroy() + resolve(false) + }) + }) +} + +let shuttingDown = false + +async function shutdown(supervisor: Supervisor, code: number): Promise { + if (shuttingDown) { + return + } + + shuttingDown = true + + const deadline = setTimeout(() => { + console.error("[launcher] shutdown deadline exceeded") + process.exit(1) + }, shutdownDeadlineMs) + + deadline.unref() + + await supervisor.stopAll() + process.exit(code) +} + +function logEnabledFeatures(config: Config): void { + const state = (enabled: boolean) => (enabled ? "on" : "off") + + log( + `[launcher] search ${state(config.meilisearch !== undefined)}, ` + + `email ${state(config.smtp !== undefined)}, ` + + `github app ${state(config.githubApp !== undefined)}, ` + + `slack app ${state(config.slackApp !== undefined)}, ` + + `ai assistant ${state((config.aiAssistant.PROVIDER ?? "") !== "")}, ` + + `change detection ${state(config.changeDetection !== undefined)}`, + ) +} + +async function main(): Promise { + const config = loadConfig(process.env) + + const { secrets, report } = ensureSecrets(join(dataDir, "secrets"), { + authSecret: config.authSecret, + dataSourceEncryptionKey: config.dataSourceEncryptionKey, + }) + + for (const name of report.generated) { + log(`[launcher] generated secret ${name}`) + } + + if (report.fromVolume.length > 0) { + log( + `[launcher] using existing secrets: ${report.fromVolume.join(", ")}`, + ) + } + + if (report.fromEnv.length > 0) { + log( + `[launcher] secrets provided via environment: ${report.fromEnv.join(", ")}`, + ) + } + + const envs = buildChildEnvs(config, secrets, bakedSentryDsns, { + PATH: process.env.PATH ?? "", + HOME: "/oxynote", + }) + + const supervisor = createSupervisor({ + spawn: (command, args, env) => + spawn(command, args, { + env, + stdio: ["ignore", "pipe", "pipe"], + }), + probe, + sleep, + log, + onUnexpectedExit(name, code) { + const message = `${name} exited unexpectedly with code ${code}` + + log(`[launcher] ${message}`) + // the child is gone, so whatever it would have reported + // for itself is lost — an OOM kill leaves no other + // trace at all. Reporting is awaited before the + // shutdown it precedes, which then exits the process. + void crashReporter + .report(new Error(message)) + .then(() => + shutdown( + supervisor, + code === 0 ? 1 : code, + ), + ) + }, + }) + + process.once("SIGTERM", () => void shutdown(supervisor, 0)) + process.once("SIGINT", () => void shutdown(supervisor, 0)) + + const specs: ChildSpec[] = [ + { + name: "core", + command: "/oxynote/core/server", + args: [], + env: envs.core, + readyUrl: `${coreUrl}/api/x/version`, + // the first boot runs the migrations and creates the + // storage bucket before listening. + readyTimeoutMs: 180_000, + stopGraceMs: 10_000, + }, + { + name: "auth-realtime", + command: process.execPath, + // one esbuild bundle with sentry folded in; a separate + // --import preload would initialize a second sentry copy + // the bundled app never sees. + args: ["/oxynote/auth-realtime/index.mjs"], + env: envs.authRealtime, + readyUrl: `${authRealtimeUrl}/api/auth-config`, + readyTimeoutMs: 60_000, + // the shutdown flush may persist every open document. + stopGraceMs: 20_000, + }, + { + name: "web", + command: process.execPath, + args: ["/oxynote/web/server/index.mjs"], + env: envs.web, + readyUrl: `${webUrl}/login`, + readyTimeoutMs: 60_000, + stopGraceMs: 10_000, + }, + { + name: "caddy", + command: "/usr/local/bin/caddy", + args: [ + "run", + "--config", + "/oxynote/prod/Caddyfile", + "--adapter", + "caddyfile", + ], + env: envs.caddy, + readyUrl: `http://127.0.0.1:${caddyPort}/auth-realtime/api/auth-config`, + readyTimeoutMs: 30_000, + stopGraceMs: 10_000, + }, + ] + + try { + for (const spec of specs) { + await supervisor.start(spec) + } + + // the loopback gate: the internal, unauthenticated surfaces + // must be unreachable from the network. A regression that makes + // a service bind all interfaces fails the boot instead of + // serving. + const exposed = await checkLoopbackExposure( + { addresses: externalAddresses, connects }, + [corePort, authRealtimePort, webPort], + ) + + if (exposed.length > 0) { + throw new Error( + `internal service ports are reachable from the network (${exposed.join(", ")}) — refusing to serve`, + ) + } + } catch (err) { + console.error( + `[launcher] ${err instanceof Error ? err.message : String(err)}`, + ) + await crashReporter.report(err) + await supervisor.stopAll() + process.exit(1) + } + + log(`[launcher] oxynote is up at ${config.publicOrigin}`) + logEnabledFeatures(config) +} + +main().catch((err: unknown) => { + console.error( + `[launcher] ${err instanceof Error ? err.message : String(err)}`, + ) + void crashReporter.report(err).then(() => { + process.exit(1) + }) +}) diff --git a/docker/prod/launcher/src/logging.test.ts b/docker/prod/launcher/src/logging.test.ts new file mode 100644 index 00000000..90cda359 --- /dev/null +++ b/docker/prod/launcher/src/logging.test.ts @@ -0,0 +1,61 @@ +import { describe, it, vi } from "vitest" +import { createLinePrefixer } from "./logging.js" + +describe("createLinePrefixer", () => { + it("prefixes each complete line in a chunk", ({ expect }) => { + const write = vi.fn() + const prefixer = createLinePrefixer("[core]", write) + + prefixer.data("first\nsecond\n") + + expect(write.mock.calls).toEqual([ + ["[core] first"], + ["[core] second"], + ]) + }) + + it("holds a trailing fragment until its newline arrives", ({ + expect, + }) => { + const write = vi.fn() + const prefixer = createLinePrefixer("[web]", write) + + prefixer.data("par") + expect(write).toHaveBeenCalledTimes(0) + + prefixer.data("tial\nrest") + + expect(write.mock.calls).toEqual([["[web] partial"]]) + }) + + it("flushes the held fragment at stream end", ({ expect }) => { + const write = vi.fn() + const prefixer = createLinePrefixer("[auth-realtime]", write) + + prefixer.data("no newline") + prefixer.end() + + expect(write.mock.calls).toEqual([ + ["[auth-realtime] no newline"], + ]) + }) + + it("ends quietly when nothing is buffered", ({ expect }) => { + const write = vi.fn() + const prefixer = createLinePrefixer("[caddy]", write) + + prefixer.data("done\n") + prefixer.end() + + expect(write).toHaveBeenCalledTimes(1) + }) + + it("reads buffer chunks the way streams deliver them", ({ expect }) => { + const write = vi.fn() + const prefixer = createLinePrefixer("[core]", write) + + prefixer.data(Buffer.from("from a buffer\n")) + + expect(write.mock.calls).toEqual([["[core] from a buffer"]]) + }) +}) diff --git a/docker/prod/launcher/src/logging.ts b/docker/prod/launcher/src/logging.ts new file mode 100644 index 00000000..6d163ae2 --- /dev/null +++ b/docker/prod/launcher/src/logging.ts @@ -0,0 +1,43 @@ +export interface LinePrefixer { + data(chunk: Buffer | string): void + end(): void +} + +// createLinePrefixer buffers a child's raw output chunks and emits complete +// lines tagged with the child's name, so the four processes' logs stay +// tellable apart in the single container stream. A trailing fragment is +// held until its newline arrives and flushed at stream end, which keeps the +// last line of a crashing child — usually the one that matters — from +// being lost. +export function createLinePrefixer( + prefix: string, + write: (line: string) => void, +): LinePrefixer { + let buffer = "" + + return { + data(chunk) { + buffer += chunk.toString() + + for (;;) { + const newline = buffer.indexOf("\n") + + if (newline === -1) { + break + } + + write(`${prefix} ${buffer.slice(0, newline)}`) + buffer = buffer.slice(newline + 1) + } + }, + + end() { + if (buffer === "") { + return + } + + write(`${prefix} ${buffer}`) + buffer = "" + }, + } +} diff --git a/docker/prod/launcher/src/loopback.test.ts b/docker/prod/launcher/src/loopback.test.ts new file mode 100644 index 00000000..8939bd8c --- /dev/null +++ b/docker/prod/launcher/src/loopback.test.ts @@ -0,0 +1,46 @@ +import { describe, it, vi } from "vitest" +import { checkLoopbackExposure } from "./loopback.js" + +describe("checkLoopbackExposure", () => { + it("reports nothing when every dial is refused", async ({ expect }) => { + const connects = vi.fn().mockResolvedValue(false) + + const exposed = await checkLoopbackExposure( + { addresses: () => ["172.18.0.5"], connects }, + [8180, 8181, 3000], + ) + + expect(exposed).toEqual([]) + expect(connects).toHaveBeenCalledTimes(3) + expect(connects).toHaveBeenCalledWith("172.18.0.5", 8180) + }) + + it("names every address and port that accepted", async ({ expect }) => { + const connects = vi.fn( + (_host: string, port: number): Promise => + Promise.resolve(port === 8180), + ) + + const exposed = await checkLoopbackExposure( + { + addresses: () => ["172.18.0.5", "10.0.0.2"], + connects, + }, + [8180, 3000], + ) + + expect(exposed).toEqual(["172.18.0.5:8180", "10.0.0.2:8180"]) + }) + + it("passes with no external addresses at all", async ({ expect }) => { + const connects = vi.fn().mockResolvedValue(true) + + const exposed = await checkLoopbackExposure( + { addresses: () => [], connects }, + [8180], + ) + + expect(exposed).toEqual([]) + expect(connects).toHaveBeenCalledTimes(0) + }) +}) diff --git a/docker/prod/launcher/src/loopback.ts b/docker/prod/launcher/src/loopback.ts new file mode 100644 index 00000000..ad38b8ab --- /dev/null +++ b/docker/prod/launcher/src/loopback.ts @@ -0,0 +1,28 @@ +export interface LoopbackCheckDeps { + // the container's externally reachable addresses (non-loopback). + addresses(): string[] + // attempts a TCP connect; resolves true when something accepted. + connects(host: string, port: number): Promise +} + +// checkLoopbackExposure dials the container's own external addresses on the +// internal service ports. The services bind loopback, so every dial must be +// refused; an accepted connection means an unauthenticated internal surface +// is reachable from the network, and the boot must fail rather than serve. +// Returns the exposed address:port pairs — empty when the gate holds. +export async function checkLoopbackExposure( + deps: LoopbackCheckDeps, + ports: number[], +): Promise { + const exposed: string[] = [] + + for (const address of deps.addresses()) { + for (const port of ports) { + if (await deps.connects(address, port)) { + exposed.push(`${address}:${port}`) + } + } + } + + return exposed +} diff --git a/docker/prod/launcher/src/mapping.test.ts b/docker/prod/launcher/src/mapping.test.ts new file mode 100644 index 00000000..b0cd8780 --- /dev/null +++ b/docker/prod/launcher/src/mapping.test.ts @@ -0,0 +1,346 @@ +import { describe, it } from "vitest" +import { + authRealtimePort, + buildChildEnvs, + caddyPort, + corePort, + webPort, +} from "./mapping.js" +import { testConfig, testSecrets } from "./test-helpers.js" + +const testSentryDsns = { + webDsn: "https://web@sentry.example.com/1", + coreDsn: "https://core@sentry.example.com/2", + authRealtimeDsn: "https://auth@sentry.example.com/3", +} + +const inheritedEnv = { PATH: "/usr/bin", HOME: "/oxynote" } + +function build( + config = testConfig(), + sentryDsns = testSentryDsns, + secrets = testSecrets(), +) { + return buildChildEnvs(config, secrets, sentryDsns, inheritedEnv) +} + +describe("buildChildEnvs", () => { + // the loopback binds are the trust boundary: /api/x/* and + // /api/internal/* carry no authentication, so a change here is a + // security decision, not a port shuffle + it("binds every service to loopback and leaves only caddy public", ({ + expect, + }) => { + const envs = build() + + expect(envs.core.OXYNOTE_CORE_SERVER_ADDRESS).toBe( + "127.0.0.1:8180", + ) + expect(envs.authRealtime.OXYNOTE_AUTH_REALTIME_ADDRESS).toBe( + "127.0.0.1:8181", + ) + expect(envs.web.NITRO_HOST).toBe("127.0.0.1") + expect(envs.web.NITRO_PORT).toBe("3000") + expect(caddyPort).toBe(8080) + expect(corePort).toBe(8180) + expect(authRealtimePort).toBe(8181) + expect(webPort).toBe(3000) + }) + + it("derives every public URL from the single public origin", ({ + expect, + }) => { + const envs = build() + + expect(envs.core.OXYNOTE_CORE_SERVER_PUBLIC_URL).toBe( + "https://notes.example.com/core", + ) + expect(envs.core.OXYNOTE_CORE_BASE_APP_URL).toBe( + "https://notes.example.com", + ) + expect(envs.core.OXYNOTE_CORE_ORIGINS).toBe( + "https://notes.example.com,notes.example.com", + ) + expect(envs.core.OXYNOTE_CORE_SLACK_REDIRECT_URL).toBe( + "https://notes.example.com/slack", + ) + expect( + envs.authRealtime + .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_BASE_URL, + ).toBe("https://notes.example.com/auth-realtime") + expect( + envs.authRealtime + .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_COOKIE_DOMAIN, + ).toBe("notes.example.com") + expect( + envs.authRealtime + .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_ORGANIZATION_INVITATION_URL, + ).toBe("https://notes.example.com/accept-invite") + expect(envs.web.NUXT_PUBLIC_APP_BASE_URL).toBe( + "https://notes.example.com", + ) + expect(envs.web.NUXT_PUBLIC_CORE_API_BASE_WS_URL).toBe( + "wss://notes.example.com/core", + ) + expect(envs.web.NUXT_PUBLIC_AUTH_REALTIME_API_BASE_WS_URL).toBe( + "wss://notes.example.com/auth-realtime/hocuspocus", + ) + }) + + // the token audience check compares these two byte for byte + it("gives core and auth-realtime the identical MCP resource URL", ({ + expect, + }) => { + const envs = build() + + expect(envs.core.OXYNOTE_CORE_SERVER_MCP_RESOURCE_URL).toBe( + envs.authRealtime.OXYNOTE_AUTH_REALTIME_MCP_RESOURCE, + ) + expect(envs.core.OXYNOTE_CORE_SERVER_MCP_RESOURCE_URL).toBe( + "https://notes.example.com/core/api/mcp", + ) + }) + + it("wires the services to each other over loopback", ({ expect }) => { + const envs = build() + + expect(envs.core.OXYNOTE_CORE_SERVER_AUTH_BETTER_AUTH_URL).toBe( + "http://127.0.0.1:8181/api/auth/get-session", + ) + expect(envs.core.OXYNOTE_CORE_SERVER_MCP_SESSION_URL).toBe( + "http://127.0.0.1:8181/api/internal/mcp/session", + ) + expect( + envs.authRealtime.OXYNOTE_AUTH_REALTIME_BACKEND_URL, + ).toBe("http://127.0.0.1:8180") + expect(envs.web.NUXT_CORE_API_INTERNAL_HTTP_URL).toBe( + "http://127.0.0.1:8180", + ) + expect(envs.web.NUXT_AUTH_REALTIME_API_INTERNAL_HTTP_URL).toBe( + "http://127.0.0.1:8181", + ) + }) + + it("keeps every child's environment to its own component", ({ + expect, + }) => { + const envs = build() + + expect( + Object.keys(envs.web).filter((key) => + key.startsWith("OXYNOTE_"), + ), + ).toEqual([]) + expect( + Object.keys(envs.core).filter( + (key) => + !key.startsWith("OXYNOTE_CORE_") && + !(key in inheritedEnv), + ), + ).toEqual([]) + expect(envs.caddy).toEqual({ + ...inheritedEnv, + XDG_DATA_HOME: "/tmp/caddy/data", + XDG_CONFIG_HOME: "/tmp/caddy/config", + }) + }) + + it("hands each secret only to the component that needs it", ({ + expect, + }) => { + const envs = build() + + expect( + envs.authRealtime + .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SECRET, + ).toBe("test-auth-secret") + expect( + envs.core + .OXYNOTE_CORE_DB_DATA_SOURCE_CREDENTIALS_SIGNING_SECRET, + ).toBe("0123456789abcdef0123456789abcdef") + expect( + Object.values(envs.web).includes("test-auth-secret"), + ).toBe(false) + expect( + Object.values(envs.authRealtime).includes( + "0123456789abcdef0123456789abcdef", + ), + ).toBe(false) + }) + + // core keeps objects on the data volume when no store is configured, + // and refuses to boot with neither a URL nor a path + it("points core at the data volume when no object store is configured", ({ + expect, + }) => { + const envs = build(testConfig({ objectStorage: undefined })) + + expect(envs.core.OXYNOTE_CORE_OBJECT_STORAGE_LOCAL_PATH).toBe( + "/oxynote/data/object-storage", + ) + expect(envs.core.OXYNOTE_CORE_OBJECT_STORAGE_URL).toBe("") + expect(envs.core.OXYNOTE_CORE_OBJECT_STORAGE_ACCESS_KEY).toBe( + "", + ) + expect(envs.core.OXYNOTE_CORE_OBJECT_STORAGE_BUCKET).toBe("") + }) + + it("keeps the storage path alongside a configured object store", ({ + expect, + }) => { + const envs = build() + + expect(envs.core.OXYNOTE_CORE_OBJECT_STORAGE_URL).toBe( + "http://s3.example.com", + ) + expect(envs.core.OXYNOTE_CORE_OBJECT_STORAGE_BUCKET).toBe( + "documents", + ) + expect(envs.core.OXYNOTE_CORE_OBJECT_STORAGE_LOCAL_PATH).toBe( + "/oxynote/data/object-storage", + ) + }) + + it("empties both valkey variables when it is not configured", ({ + expect, + }) => { + const envs = build(testConfig({ valkeyDsn: undefined })) + + expect(envs.core.OXYNOTE_CORE_VALKEY_DSN).toBe("") + expect(envs.authRealtime.OXYNOTE_AUTH_REALTIME_VALKEY_DSN).toBe( + "", + ) + }) + + it("applies the baked telemetry DSNs per component", ({ expect }) => { + const envs = build() + + expect(envs.web.NUXT_PUBLIC_SENTRY_DSN).toBe( + testSentryDsns.webDsn, + ) + expect(envs.core.OXYNOTE_CORE_SENTRY_DSN).toBe( + testSentryDsns.coreDsn, + ) + expect(envs.authRealtime.SENTRY_DSN).toBe( + testSentryDsns.authRealtimeDsn, + ) + }) + + it("zeroes every DSN when error reporting is disabled", ({ + expect, + }) => { + const envs = build(testConfig({ crashReportingDisabled: true })) + + expect(envs.web.NUXT_PUBLIC_SENTRY_DSN).toBe("") + expect(envs.core.OXYNOTE_CORE_SENTRY_DSN).toBe("") + expect(envs.authRealtime.SENTRY_DSN).toBe("") + }) + + it("leaves a disabled feature's variables empty", ({ expect }) => { + const envs = build() + + expect(envs.core.OXYNOTE_CORE_MEILISEARCH_URL).toBe("") + expect(envs.core.OXYNOTE_CORE_EMAIL_SMTP_HOST).toBe("") + expect(envs.core.OXYNOTE_CORE_GITHUB_APP_ID).toBe("") + expect(envs.core.OXYNOTE_CORE_SLACK_CLIENT_ID).toBe("") + expect(envs.core.OXYNOTE_CORE_ASSISTANT_PROVIDER).toBe("") + expect( + envs.authRealtime + .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_ID, + ).toBe("") + }) + + it("maps an enabled feature onto its component variables", ({ + expect, + }) => { + const envs = build( + testConfig({ + smtp: { + host: "mail.example.com", + port: "587", + username: "user", + password: "pass", + tls: "starttls", + }, + emailFromAddress: "Oxynote ", + meilisearch: { + url: "http://meilisearch:7700", + masterKey: "master", + }, + githubApp: { + appId: "123", + appSlug: "oxynote", + signatureSecret: "sig", + }, + slackApp: { + clientId: "cid", + clientSecret: "cs", + signatureSecret: "ss", + }, + socialLogin: { + google: { + clientId: "gid", + clientSecret: "gs", + }, + }, + aiAssistant: { + ...testConfig().aiAssistant, + PROVIDER: "anthropic", + API_KEY: "sk-ant", + }, + rateLimitDisabled: true, + maxOrganizations: "10", + }), + ) + + expect(envs.core.OXYNOTE_CORE_EMAIL_SMTP_HOST).toBe( + "mail.example.com", + ) + expect(envs.core.OXYNOTE_CORE_EMAIL_SMTP_TLS).toBe("starttls") + expect(envs.core.OXYNOTE_CORE_EMAIL_FROM_ADDRESS).toBe( + "Oxynote ", + ) + expect(envs.core.OXYNOTE_CORE_MEILISEARCH_MASTER_KEY).toBe( + "master", + ) + expect(envs.core.OXYNOTE_CORE_GITHUB_APP_ID).toBe("123") + expect(envs.core.OXYNOTE_CORE_GITHUB_PRIVATE_KEY_PATH).toBe( + "/oxynote/github/private-key.pem", + ) + expect(envs.core.OXYNOTE_CORE_SLACK_SIGNATURE_SECRET).toBe("ss") + expect(envs.core.OXYNOTE_CORE_ASSISTANT_API_KEY).toBe("sk-ant") + expect( + envs.authRealtime + .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_ID, + ).toBe("gid") + expect( + envs.authRealtime + .OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED, + ).toBe("true") + expect( + envs.authRealtime + .OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS, + ).toBe("10") + }) + + it("keeps the desktop deep-link schemes in the trusted origins", ({ + expect, + }) => { + const envs = build() + + expect( + envs.authRealtime.OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS, + ).toBe("https://notes.example.com,oxynote://,oxynote:/") + }) + + it("ships the in-app documentation links", ({ expect }) => { + const envs = build() + + expect( + envs.web.NUXT_PUBLIC_PROMETHEUS_QUERY_GUIDE_URL, + ).toContain("prometheus.io") + expect( + envs.web.NUXT_PUBLIC_LINK_TO_MORE_INFO_ABOUT_PRODUCT, + ).toBe("https://oxynote.io") + }) +}) diff --git a/docker/prod/launcher/src/mapping.ts b/docker/prod/launcher/src/mapping.ts new file mode 100644 index 00000000..7e38ca0d --- /dev/null +++ b/docker/prod/launcher/src/mapping.ts @@ -0,0 +1,223 @@ +import type { Config } from "./env.js" +import type { Secrets } from "./secrets.js" + +// the internal port layout of the production image. Core's /api/x/* and +// auth-realtime's /api/internal/* surfaces carry no authentication, so the +// three services bind loopback — unreachable from outside the container — +// and caddy is the only wildcard listener. The supervisor's boot check +// verifies these binds; change them only together with +// docker/prod/Caddyfile. +export const caddyPort = 8080 +export const corePort = 8180 +export const authRealtimePort = 8181 +export const webPort = 3000 + +export const coreUrl = `http://127.0.0.1:${corePort}` +export const authRealtimeUrl = `http://127.0.0.1:${authRealtimePort}` +export const webUrl = `http://127.0.0.1:${webPort}` + +// where the data volume is mounted; generated secrets live beneath it. +export const dataDir = "/oxynote/data" + +// where core keeps uploaded objects when no object store is configured. +// It sits on the data volume, so the files survive a restart like the +// secrets do. +const objectStorageLocalPath = `${dataDir}/object-storage` + +// the fixed path a GitHub App's private key is mounted at when the +// integration is enabled. +const githubPrivateKeyPath = "/oxynote/github/private-key.pem" + +export interface SentryDsns { + webDsn: string + coreDsn: string + authRealtimeDsn: string +} + +export interface ChildEnvs { + core: Record + authRealtime: Record + web: Record + caddy: Record +} + +// in-app documentation links shipped with the image. The values mirror +// docker/env/web.example.env — the nuxt-side defaults are empty strings, so +// omitting them here would blank the links. +const webDocumentationLinks = { + NUXT_PUBLIC_LINK_TO_MORE_INFO_ABOUT_PRODUCT: "https://oxynote.io", + NUXT_PUBLIC_POSTGRESQL_READ_ONLY_USER_SETUP_GUIDE_URL: + "https://tableplus.com/blog/2018/04/postgresql-how-to-create-read-only-user.html", + NUXT_PUBLIC_MYSQL_READ_ONLY_USER_SETUP_GUIDE_URL: + "https://guide.trevor.io/hc/en-us/articles/6843804879261-Creating-a-read-only-database-user-in-MySQL", + NUXT_PUBLIC_MARIADB_READ_ONLY_USER_SETUP_GUIDE_URL: + "https://guide.trevor.io/hc/en-us/articles/6843804879261-Creating-a-read-only-database-user-in-MySQL", + NUXT_PUBLIC_PROMETHEUS_QUERY_GUIDE_URL: + "https://prometheus.io/docs/prometheus/latest/querying/basics/", + NUXT_PUBLIC_POSTGRESQL_QUERY_GUIDE_URL: + "https://www.postgresql.org/docs/current/sql-select.html", + NUXT_PUBLIC_MYSQL_QUERY_GUIDE_URL: + "https://dev.mysql.com/doc/refman/8.0/en/select.html", + NUXT_PUBLIC_MARIADB_QUERY_GUIDE_URL: + "https://mariadb.com/kb/en/select/", +} + +function aiAssistantEnv( + aiAssistant: Record, +): Record { + return Object.fromEntries( + Object.entries(aiAssistant).map(([key, value]) => [ + `OXYNOTE_CORE_ASSISTANT_${key}`, + value, + ]), + ) +} + +// buildChildEnvs assembles each process's environment from scratch: a child +// receives exactly its own component's variables and nothing else, so no +// flat OXYNOTE_* value and no other component's secret ever leaks through. +export function buildChildEnvs( + config: Config, + secrets: Secrets, + bakedSentryDsns: SentryDsns, + inheritedEnv: Record, +): ChildEnvs { + const publicCoreUrl = `${config.publicOrigin}/core` + const publicAuthRealtimeUrl = `${config.publicOrigin}/auth-realtime` + const mcpResourceUrl = `${config.publicOrigin}/core/api/mcp` + const sentryDsns = config.crashReportingDisabled + ? { webDsn: "", coreDsn: "", authRealtimeDsn: "" } + : bakedSentryDsns + + const core: Record = { + ...inheritedEnv, + OXYNOTE_CORE_SERVER_ADDRESS: `127.0.0.1:${corePort}`, + OXYNOTE_CORE_DB_DSN: config.databaseDsn, + OXYNOTE_CORE_VALKEY_DSN: config.valkeyDsn ?? "", + OXYNOTE_CORE_MEILISEARCH_URL: config.meilisearch?.url ?? "", + OXYNOTE_CORE_MEILISEARCH_MASTER_KEY: + config.meilisearch?.masterKey ?? "", + // an empty URL is what makes core store objects on disk at + // OBJECT_STORAGE_LOCAL_PATH instead of in a remote store. + OXYNOTE_CORE_OBJECT_STORAGE_URL: + config.objectStorage?.url ?? "", + OXYNOTE_CORE_OBJECT_STORAGE_REGION: + config.objectStorage?.region ?? "", + OXYNOTE_CORE_OBJECT_STORAGE_ACCESS_KEY: + config.objectStorage?.accessKey ?? "", + OXYNOTE_CORE_OBJECT_STORAGE_SECRET_KEY: + config.objectStorage?.secretKey ?? "", + OXYNOTE_CORE_OBJECT_STORAGE_BUCKET: + config.objectStorage?.bucket ?? "", + OXYNOTE_CORE_OBJECT_STORAGE_LOCAL_PATH: objectStorageLocalPath, + OXYNOTE_CORE_SERVER_AUTH_BETTER_AUTH_URL: `${authRealtimeUrl}/api/auth/get-session`, + OXYNOTE_CORE_SERVER_MCP_SESSION_URL: `${authRealtimeUrl}/api/internal/mcp/session`, + OXYNOTE_CORE_AUTH_REALTIME_URL: authRealtimeUrl, + OXYNOTE_CORE_CHANGEDETECTION_API_URL: + config.changeDetection?.url ?? "", + OXYNOTE_CORE_CHANGEDETECTION_API_KEY: + config.changeDetection?.apiKey ?? "", + OXYNOTE_CORE_SERVER_PUBLIC_URL: publicCoreUrl, + OXYNOTE_CORE_BASE_APP_URL: config.publicOrigin, + OXYNOTE_CORE_SERVER_MCP_RESOURCE_URL: mcpResourceUrl, + OXYNOTE_CORE_ORIGINS: `${config.publicOrigin},${config.publicHostPort}`, + OXYNOTE_CORE_GITHUB_APP_ID: config.githubApp?.appId ?? "", + OXYNOTE_CORE_GITHUB_APP_SLUG: config.githubApp?.appSlug ?? "", + OXYNOTE_CORE_GITHUB_SIGNATURE_SECRET: + config.githubApp?.signatureSecret ?? "", + OXYNOTE_CORE_GITHUB_INSTALLATION_SIGNING_SECRET: + secrets.githubInstallationSigningSecret, + OXYNOTE_CORE_GITHUB_PRIVATE_KEY_PATH: githubPrivateKeyPath, + OXYNOTE_CORE_SLACK_CLIENT_ID: config.slackApp?.clientId ?? "", + OXYNOTE_CORE_SLACK_CLIENT_SECRET: + config.slackApp?.clientSecret ?? "", + OXYNOTE_CORE_SLACK_SIGNATURE_SECRET: + config.slackApp?.signatureSecret ?? "", + OXYNOTE_CORE_SLACK_REDIRECT_URL: `${config.publicOrigin}/slack`, + OXYNOTE_CORE_SLACK_INSTALLATION_SIGNING_SECRET: + secrets.slackInstallationSigningSecret, + ...aiAssistantEnv(config.aiAssistant), + OXYNOTE_CORE_DB_MAX_DOCUMENT_HISTORY_ENTRIES: + config.maxDocumentHistoryEntries, + OXYNOTE_CORE_DB_DOCUMENT_HISTORY_RETENTION: + config.documentHistoryRetention, + OXYNOTE_CORE_DB_DATA_SOURCE_CREDENTIALS_SIGNING_SECRET: + secrets.dataSourceEncryptionKey, + OXYNOTE_CORE_EMAIL_SMTP_HOST: config.smtp?.host ?? "", + OXYNOTE_CORE_EMAIL_SMTP_PORT: config.smtp?.port ?? "", + OXYNOTE_CORE_EMAIL_SMTP_USERNAME: config.smtp?.username ?? "", + OXYNOTE_CORE_EMAIL_SMTP_PASSWORD: config.smtp?.password ?? "", + OXYNOTE_CORE_EMAIL_SMTP_TLS: config.smtp?.tls ?? "", + OXYNOTE_CORE_EMAIL_FROM_ADDRESS: config.emailFromAddress, + OXYNOTE_CORE_SENTRY_DSN: sentryDsns.coreDsn, + OXYNOTE_CORE_LOG_LEVEL: config.logLevel, + } + + const authRealtime: Record = { + ...inheritedEnv, + NODE_ENV: "production", + OXYNOTE_AUTH_REALTIME_ADDRESS: `127.0.0.1:${authRealtimePort}`, + OXYNOTE_AUTH_REALTIME_BACKEND_URL: coreUrl, + OXYNOTE_AUTH_REALTIME_DB_DSN: config.databaseDsn, + OXYNOTE_AUTH_REALTIME_VALKEY_DSN: config.valkeyDsn ?? "", + OXYNOTE_AUTH_REALTIME_BETTER_AUTH_BASE_URL: + publicAuthRealtimeUrl, + OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SECRET: secrets.authSecret, + OXYNOTE_AUTH_REALTIME_BETTER_AUTH_COOKIE_DOMAIN: + config.cookieDomain, + OXYNOTE_AUTH_REALTIME_FRONTEND_URL: config.publicOrigin, + OXYNOTE_AUTH_REALTIME_BETTER_AUTH_ORGANIZATION_INVITATION_URL: `${config.publicOrigin}/accept-invite`, + // the two oxynote schemes keep the desktop app's OAuth + // deep-link handoff working against a self-hosted instance. + OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS: `${config.publicOrigin},oxynote://,oxynote:/`, + OXYNOTE_AUTH_REALTIME_MCP_RESOURCE: mcpResourceUrl, + OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS: + config.maxOrganizations, + OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS: + config.maxOrganizationMembers, + OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED: + config.rateLimitDisabled ? "true" : "false", + OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_ID: + config.socialLogin.github?.clientId ?? "", + OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_SECRET: + config.socialLogin.github?.clientSecret ?? "", + OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_ID: + config.socialLogin.google?.clientId ?? "", + OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_SECRET: + config.socialLogin.google?.clientSecret ?? "", + OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_ID: + config.socialLogin.slack?.clientId ?? "", + OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_SECRET: + config.socialLogin.slack?.clientSecret ?? "", + SENTRY_DSN: sentryDsns.authRealtimeDsn, + } + + const web: Record = { + ...inheritedEnv, + NODE_ENV: "production", + NITRO_HOST: "127.0.0.1", + NITRO_PORT: String(webPort), + NUXT_PUBLIC_APP_BASE_URL: config.publicOrigin, + NUXT_PUBLIC_CORE_API_BASE_HTTP_URL: publicCoreUrl, + NUXT_PUBLIC_CORE_API_BASE_WS_URL: `${config.publicWebSocketOrigin}/core`, + NUXT_PUBLIC_AUTH_REALTIME_API_BASE_HTTP_URL: + publicAuthRealtimeUrl, + NUXT_PUBLIC_AUTH_REALTIME_API_BASE_WS_URL: `${config.publicWebSocketOrigin}/auth-realtime/hocuspocus`, + NUXT_CORE_API_INTERNAL_HTTP_URL: coreUrl, + NUXT_AUTH_REALTIME_API_INTERNAL_HTTP_URL: authRealtimeUrl, + NUXT_PUBLIC_SENTRY_DSN: sentryDsns.webDsn, + NUXT_PUBLIC_TERMS_OF_SERVICE_URL: config.termsOfServiceUrl, + NUXT_PUBLIC_PRIVACY_POLICY_URL: config.privacyPolicyUrl, + ...webDocumentationLinks, + } + + // caddy wants writable storage even with TLS off; /tmp is the only + // scratch path the image allows. + const caddy: Record = { + ...inheritedEnv, + XDG_DATA_HOME: "/tmp/caddy/data", + XDG_CONFIG_HOME: "/tmp/caddy/config", + } + + return { core, authRealtime, web, caddy } +} diff --git a/docker/prod/launcher/src/secrets.test.ts b/docker/prod/launcher/src/secrets.test.ts new file mode 100644 index 00000000..23fda735 --- /dev/null +++ b/docker/prod/launcher/src/secrets.test.ts @@ -0,0 +1,144 @@ +import { + mkdtempSync, + mkdirSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { describe, it } from "vitest" +import { ensureSecrets } from "./secrets.js" + +function tempDir(): string { + return join(mkdtempSync(join(tmpdir(), "oxynote-secrets-")), "secrets") +} + +const noOverrides = { + authSecret: undefined, + dataSourceEncryptionKey: undefined, +} + +describe("ensureSecrets", () => { + it("generates every secret on first boot and persists it", ({ + expect, + }) => { + const dir = tempDir() + + const { secrets, report } = ensureSecrets(dir, noOverrides) + + for (const value of Object.values(secrets)) { + // 24 random bytes as base64url — 32 ASCII characters, + // satisfying the AES-256 and 32-byte signing constraints + expect(value).toMatch(/^[A-Za-z0-9_-]{32}$/) + } + + expect(report.generated.toSorted()).toEqual([ + "auth-secret", + "data-source-encryption-key", + "github-installation-signing-secret", + "slack-installation-signing-secret", + ]) + expect(readdirSync(dir).toSorted()).toEqual( + report.generated.toSorted(), + ) + }) + + it("writes owner-only files inside an owner-only directory", ({ + expect, + }) => { + const dir = tempDir() + + ensureSecrets(dir, noOverrides) + + expect(statSync(dir).mode & 0o777).toBe(0o700) + + for (const file of readdirSync(dir)) { + expect(statSync(join(dir, file)).mode & 0o777).toBe( + 0o600, + ) + } + }) + + it("reuses the persisted secrets on later boots", ({ expect }) => { + const dir = tempDir() + + const first = ensureSecrets(dir, noOverrides) + const second = ensureSecrets(dir, noOverrides) + + expect(second.secrets).toEqual(first.secrets) + expect(second.report.generated).toEqual([]) + expect(second.report.fromVolume.toSorted()).toEqual( + first.report.generated.toSorted(), + ) + }) + + it("trims whitespace from a stored secret", ({ expect }) => { + const dir = tempDir() + + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "auth-secret"), " stored-value\n") + + const { secrets } = ensureSecrets(dir, noOverrides) + + expect(secrets.authSecret).toBe("stored-value") + }) + + it("lets an explicit override win without persisting it", ({ + expect, + }) => { + const dir = tempDir() + + const { secrets, report } = ensureSecrets(dir, { + authSecret: "operator-provided", + dataSourceEncryptionKey: undefined, + }) + + expect(secrets.authSecret).toBe("operator-provided") + expect(report.fromEnv).toEqual(["auth-secret"]) + expect(readdirSync(dir)).not.toContain("auth-secret") + }) + + it("keeps an overridden secret out of the volume across boots", ({ + expect, + }) => { + const dir = tempDir() + + ensureSecrets(dir, { + authSecret: "operator-provided", + dataSourceEncryptionKey: undefined, + }) + const { secrets } = ensureSecrets(dir, { + authSecret: "operator-provided", + dataSourceEncryptionKey: undefined, + }) + + expect(secrets.authSecret).toBe("operator-provided") + expect(readdirSync(dir)).not.toContain("auth-secret") + }) + + it("propagates an unreadable secret file", ({ expect }) => { + const dir = tempDir() + + // a directory where the secret file should be makes the read + // fail with something other than ENOENT + mkdirSync(join(dir, "auth-secret"), { recursive: true }) + + expect(() => ensureSecrets(dir, noOverrides)).toThrow() + }) + + it("explains the volume requirement when persisting fails", ({ + expect, + }) => { + const dir = tempDir() + + // an empty pre-existing file reads as absent, and the wx write + // then fails on it + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "auth-secret"), "") + + expect(() => ensureSecrets(dir, noOverrides)).toThrow( + "writable data volume", + ) + }) +}) diff --git a/docker/prod/launcher/src/secrets.ts b/docker/prod/launcher/src/secrets.ts new file mode 100644 index 00000000..74b15ea2 --- /dev/null +++ b/docker/prod/launcher/src/secrets.ts @@ -0,0 +1,124 @@ +import { randomBytes } from "node:crypto" +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +export interface Secrets { + authSecret: string + dataSourceEncryptionKey: string + githubInstallationSigningSecret: string + slackInstallationSigningSecret: string +} + +export interface SecretOverrides { + authSecret: string | undefined + dataSourceEncryptionKey: string | undefined +} + +export interface SecretReport { + generated: string[] + fromVolume: string[] + fromEnv: string[] +} + +function isNoEntry(err: unknown): boolean { + return err instanceof Error && "code" in err && err.code === "ENOENT" +} + +function isExists(err: unknown): boolean { + return err instanceof Error && "code" in err && err.code === "EEXIST" +} + +function readSecret(path: string): string | undefined { + try { + return readFileSync(path, "utf8").trim() || undefined + } catch (err) { + if (isNoEntry(err)) { + return undefined + } + + throw err + } +} + +// ensureSecrets resolves every internal secret with the precedence the +// established all-in-one images use: an explicit override wins and is never +// written to disk, an existing volume file is reused, and only a secret +// with neither is generated — from the CSPRNG — and persisted with +// owner-only permissions. Everything generated is 32 ASCII bytes, which +// satisfies both the AES-256 key length the data-source encryption needs +// and core's exactly-32-byte installation signing secrets. +export function ensureSecrets( + dir: string, + overrides: SecretOverrides, +): { secrets: Secrets; report: SecretReport } { + const report: SecretReport = { + generated: [], + fromVolume: [], + fromEnv: [], + } + + function resolve(file: string, override?: string): string { + if (override !== undefined) { + report.fromEnv.push(file) + + return override + } + + const path = join(dir, file) + const existing = readSecret(path) + + if (existing !== undefined) { + report.fromVolume.push(file) + + return existing + } + + // base64url of 24 random bytes is exactly 32 ASCII bytes. + const value = randomBytes(24).toString("base64url") + + try { + mkdirSync(dir, { recursive: true, mode: 0o700 }) + writeFileSync(path, value, { flag: "wx", mode: 0o600 }) + } catch (err) { + // a concurrent boot on the same volume may have written + // the file between the read and the write; its value + // wins. + if (isExists(err)) { + const written = readSecret(path) + + if (written !== undefined) { + // NOCOV: requires a concurrent writer + // between the read and the write. + report.fromVolume.push(file) + + return written + } + } + + throw new Error( + `cannot persist the generated secret "${file}" under ${dir} — the image needs a writable data volume there (${err instanceof Error ? err.message : String(err)})`, + { cause: err }, + ) + } + + report.generated.push(file) + + return value + } + + const secrets: Secrets = { + authSecret: resolve("auth-secret", overrides.authSecret), + dataSourceEncryptionKey: resolve( + "data-source-encryption-key", + overrides.dataSourceEncryptionKey, + ), + githubInstallationSigningSecret: resolve( + "github-installation-signing-secret", + ), + slackInstallationSigningSecret: resolve( + "slack-installation-signing-secret", + ), + } + + return { secrets, report } +} diff --git a/docker/prod/launcher/src/sentry.test.ts b/docker/prod/launcher/src/sentry.test.ts new file mode 100644 index 00000000..ed032e11 --- /dev/null +++ b/docker/prod/launcher/src/sentry.test.ts @@ -0,0 +1,111 @@ +import { describe, it, vi } from "vitest" +import { createCrashReporter, type SentryClient } from "./sentry.js" + +type InitOptions = Parameters[0] + +function harness(overrides: { flush?: () => Promise } = {}): { + init: ReturnType + captureException: ReturnType + flush: ReturnType + client: SentryClient + order: string[] +} { + const order: string[] = [] + + const init = vi.fn<(options: InitOptions) => void>() + const captureException = vi.fn<(error: unknown) => void>(() => { + order.push("capture") + }) + const flush = vi.fn<(timeout: number) => Promise>(() => { + order.push("flush") + + return overrides.flush?.() ?? Promise.resolve(true) + }) + + return { + init, + captureException, + flush, + client: { init, captureException, flush }, + order, + } +} + +describe("createCrashReporter", () => { + it("initializes sentry with the baked dsn and no tracing", ({ + expect, + }) => { + const h = harness() + + createCrashReporter( + h.client, + "https://key@sentry.test/1", + false, + ) + + expect(h.init).toHaveBeenCalledWith({ + dsn: "https://key@sentry.test/1", + environment: "production", + tracesSampleRate: 0, + }) + }) + + it.for([ + { + name: "the image was built without a dsn", + input: { dsn: "", disabled: false }, + }, + { + name: "the operator turned reporting off", + input: { + dsn: "https://key@sentry.test/1", + disabled: true, + }, + }, + ])("stays inert when $name", async ({ input }, { expect }) => { + const h = harness() + + await createCrashReporter( + h.client, + input.dsn, + input.disabled, + ).report(new Error("boom")) + + expect(h.init).not.toHaveBeenCalled() + expect(h.captureException).not.toHaveBeenCalled() + }) + + it("captures the error and flushes before resolving", async ({ + expect, + }) => { + const h = harness() + const error = new Error("boom") + + await createCrashReporter( + h.client, + "https://key@sentry.test/1", + false, + ).report(error) + + expect(h.captureException).toHaveBeenCalledWith(error) + // the callers all exit immediately afterwards, so a report that + // had not left the process by now would never be sent. + expect(h.order).toStrictEqual(["capture", "flush"]) + }) + + it("resolves when flushing fails, so a crash never becomes a hang", async ({ + expect, + }) => { + const h = harness({ + flush: () => Promise.reject(new Error("offline")), + }) + + await expect( + createCrashReporter( + h.client, + "https://key@sentry.test/1", + false, + ).report(new Error("boom")), + ).resolves.toBeUndefined() + }) +}) diff --git a/docker/prod/launcher/src/sentry.ts b/docker/prod/launcher/src/sentry.ts new file mode 100644 index 00000000..8fb17546 --- /dev/null +++ b/docker/prod/launcher/src/sentry.ts @@ -0,0 +1,72 @@ +// the launcher's own crash reporting. Every other process in the image +// reports for itself — core, auth-realtime and web each carry their own DSN +// — and this covers the one thing none of them can: the supervisor's view. +// A child killed by the OOM killer, a boot that fails before any child +// starts, an invalid environment that stops the container from serving at +// all: in each case the process that would have reported is dead or was +// never alive, and without this the operator sees only a container that +// exited. + +export interface SentryClient { + init(options: { + dsn: string + environment: string + tracesSampleRate: number + }): void + captureException(error: unknown): void + flush(timeout: number): Promise +} + +export interface CrashReporter { + // report captures an error and waits for it to leave the process. + // Every caller exits immediately afterwards, so a fire-and-forget + // capture would be discarded before it was ever sent. + report(error: unknown): Promise +} + +// how long a report may take before the shutdown it precedes goes ahead +// anyway. A crash must not become a hang because the network is down. +const flushTimeoutMs = 2_000 + +const noop: CrashReporter = { + report: () => Promise.resolve(), +} + +// createCrashReporter returns a reporter that is inert unless the image was +// built with a DSN and the operator has left reporting on. +// +// The disabled flag is read from the raw environment by the caller rather +// than from the parsed Config, and deliberately: config parsing is itself +// one of the failures worth reporting, so the reporter has to exist before +// there is a Config to consult. The flag is a plain "true"/"false" string, +// so reading it twice cannot disagree. +export function createCrashReporter( + client: SentryClient, + dsn: string, + disabled: boolean, +): CrashReporter { + if (dsn === "" || disabled) { + return noop + } + + client.init({ + dsn, + environment: "production", + // tracing stays off, as in auth-realtime: the bundle folds + // sentry in, which loses the require hooks tracing relies on, + // and error capture is the only thing this process wants. + tracesSampleRate: 0, + }) + + return { + report: async (error) => { + try { + client.captureException(error) + await client.flush(flushTimeoutMs) + } catch { + // a reporter that throws would replace the crash + // being reported with one about reporting it. + } + }, + } +} diff --git a/docker/prod/launcher/src/supervisor.test.ts b/docker/prod/launcher/src/supervisor.test.ts new file mode 100644 index 00000000..4f94bad1 --- /dev/null +++ b/docker/prod/launcher/src/supervisor.test.ts @@ -0,0 +1,279 @@ +import { EventEmitter } from "node:events" +import type { ChildProcess } from "node:child_process" +import { PassThrough } from "node:stream" +import { describe, it, vi } from "vitest" +import { createSupervisor, type ChildSpec } from "./supervisor.js" + +interface StubChild { + process: ChildProcess + emitter: EventEmitter + stdout: PassThrough + stderr: PassThrough + kill: ReturnType + exit(code: number | null, signal?: string): void +} + +// a ChildProcess stand-in exposing exactly what the supervisor touches: +// the two output streams, kill, and the exit event. +function stubChild(options: { exitOnTerm?: boolean } = {}): StubChild { + const emitter = new EventEmitter() + const stdout = new PassThrough() + const stderr = new PassThrough() + + const exit = (code: number | null, signal?: string) => { + emitter.emit("exit", code, signal ?? null) + } + + const kill = vi.fn((signal: string) => { + if (signal === "SIGKILL") { + exit(null, "SIGKILL") + + return true + } + + if (options.exitOnTerm !== false) { + exit(0) + } + + return true + }) + + const child = { + stdout, + stderr, + kill, + once: emitter.once.bind(emitter), + } as unknown as ChildProcess + + return { process: child, emitter, stdout, stderr, kill, exit } +} + +function spec(name: string, overrides: Partial = {}): ChildSpec { + return { + name, + command: `/bin/${name}`, + args: ["--run"], + env: { KEY: name }, + readyUrl: `http://127.0.0.1:1/${name}`, + readyTimeoutMs: 1_000, + stopGraceMs: 100, + ...overrides, + } +} + +function harness(childOptions: { exitOnTerm?: boolean } = {}) { + const children: StubChild[] = [] + + const spawn = vi.fn(() => { + const child = stubChild(childOptions) + + children.push(child) + + return child.process + }) + const probe = vi + .fn<(url: string) => Promise>() + .mockResolvedValue(true) + const log = vi.fn<(line: string) => void>() + const onUnexpectedExit = vi.fn<(name: string, code: number) => void>() + + const supervisor = createSupervisor({ + spawn, + probe, + sleep: () => Promise.resolve(), + log, + onUnexpectedExit, + }) + + return { children, spawn, probe, log, onUnexpectedExit, supervisor } +} + +describe("createSupervisor", () => { + describe("start", () => { + it("spawns the child with its own command and environment", async ({ + expect, + }) => { + const h = harness() + + await h.supervisor.start(spec("core")) + + expect(h.spawn).toHaveBeenCalledWith( + "/bin/core", + ["--run"], + { KEY: "core" }, + ) + }) + + it("waits for the readiness probe before resolving", async ({ + expect, + }) => { + const h = harness() + h.probe + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValue(true) + + await h.supervisor.start( + spec("core", { readyTimeoutMs: 5_000 }), + ) + + expect(h.probe).toHaveBeenCalledTimes(3) + expect(h.probe).toHaveBeenCalledWith( + "http://127.0.0.1:1/core", + ) + expect(h.log).toHaveBeenCalledWith( + "[launcher] core is ready", + ) + }) + + it("gives up when the child never becomes ready", async ({ + expect, + }) => { + const h = harness() + h.probe.mockResolvedValue(false) + + await expect( + h.supervisor.start( + spec("core", { readyTimeoutMs: 1_000 }), + ), + ).rejects.toThrow( + "core did not become ready within 1000ms", + ) + expect(h.onUnexpectedExit).toHaveBeenCalledTimes(0) + }) + + it("fails fast when the child exits before becoming ready", async ({ + expect, + }) => { + const h = harness() + h.probe.mockImplementation(() => { + h.children[0]?.exit(3) + + return Promise.resolve(false) + }) + + await expect( + h.supervisor.start(spec("core")), + ).rejects.toThrow("core exited before becoming ready") + expect(h.onUnexpectedExit).toHaveBeenCalledWith( + "core", + 3, + ) + }) + + it("tags the child's output with its name", async ({ + expect, + }) => { + const h = harness() + + await h.supervisor.start(spec("core")) + + h.children[0]?.stdout.emit("data", "hello\n") + h.children[0]?.stderr.emit("data", "oops\n") + + expect(h.log).toHaveBeenCalledWith("[core] hello") + expect(h.log).toHaveBeenCalledWith("[core] oops") + }) + + it("reports an exit after readiness as unexpected", async ({ + expect, + }) => { + const h = harness() + + await h.supervisor.start(spec("core")) + + h.children[0]?.exit(2) + await Promise.resolve() + + expect(h.onUnexpectedExit).toHaveBeenCalledWith( + "core", + 2, + ) + }) + + it("normalizes a signal death into a failure code", async ({ + expect, + }) => { + const h = harness() + + await h.supervisor.start(spec("core")) + + h.children[0]?.exit(null, "SIGSEGV") + await Promise.resolve() + + expect(h.onUnexpectedExit).toHaveBeenCalledWith( + "core", + 1, + ) + }) + }) + + describe("stopAll", () => { + it("terminates the children in reverse start order", async ({ + expect, + }) => { + const h = harness() + + await h.supervisor.start(spec("core")) + await h.supervisor.start(spec("caddy")) + + await h.supervisor.stopAll() + + const stops = h.log.mock.calls + .map(([line]) => line) + .filter((line) => line.includes("stopping")) + + expect(stops).toEqual([ + "[launcher] stopping caddy", + "[launcher] stopping core", + ]) + expect(h.children[0]?.kill).toHaveBeenCalledWith( + "SIGTERM", + ) + expect(h.children[1]?.kill).toHaveBeenCalledWith( + "SIGTERM", + ) + }) + + it("does not report shutdown exits as unexpected", async ({ + expect, + }) => { + const h = harness() + + await h.supervisor.start(spec("core")) + await h.supervisor.stopAll() + await Promise.resolve() + + expect(h.onUnexpectedExit).toHaveBeenCalledTimes(0) + }) + + it("kills a child that outlives its stop grace", async ({ + expect, + }) => { + const h = harness({ exitOnTerm: false }) + + await h.supervisor.start(spec("core")) + await h.supervisor.stopAll() + + expect(h.children[0]?.kill).toHaveBeenCalledWith( + "SIGTERM", + ) + expect(h.children[0]?.kill).toHaveBeenCalledWith( + "SIGKILL", + ) + }) + + it("skips a child that already exited", async ({ expect }) => { + const h = harness() + + await h.supervisor.start(spec("core")) + + h.children[0]?.exit(1) + await Promise.resolve() + + await h.supervisor.stopAll() + + expect(h.children[0]?.kill).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/docker/prod/launcher/src/supervisor.ts b/docker/prod/launcher/src/supervisor.ts new file mode 100644 index 00000000..d94b3948 --- /dev/null +++ b/docker/prod/launcher/src/supervisor.ts @@ -0,0 +1,166 @@ +import type { ChildProcess } from "node:child_process" +import { createLinePrefixer } from "./logging.js" + +export interface ChildSpec { + name: string + command: string + args: string[] + env: Record + // the URL polled until it answers 200; the child counts as started + // only after that, which is what serializes core's migrations before + // auth-realtime and keeps caddy's published port silent until the + // whole stack answers. + readyUrl: string + readyTimeoutMs: number + // how long a SIGTERM'd child may drain before SIGKILL. + stopGraceMs: number +} + +export interface SupervisorDeps { + spawn( + command: string, + args: string[], + env: Record, + ): ChildProcess + // answers whether a GET to the URL returned 200. + probe(url: string): Promise + sleep(ms: number): Promise + log(line: string): void + // called when a child exits outside an initiated shutdown. + onUnexpectedExit(name: string, code: number): void +} + +export interface Supervisor { + start(spec: ChildSpec): Promise + stopAll(): Promise +} + +interface Child { + spec: ChildSpec + process: ChildProcess + exited: Promise + running: boolean +} + +const readyPollIntervalMs = 500 + +// createSupervisor owns the child processes: it starts them one at a time, +// gating each on readiness, tags their output, converts any unexpected exit +// into a callback — the policy is crash-the-container, restarts belong to +// the container runtime — and stops them in reverse order on shutdown. +export function createSupervisor(deps: SupervisorDeps): Supervisor { + const children: Child[] = [] + let stopping = false + + function attachOutput(name: string, child: ChildProcess): void { + for (const stream of [child.stdout, child.stderr]) { + if (!stream) { + continue + } + + const prefixer = createLinePrefixer( + `[${name}]`, + (line) => { + deps.log(line) + }, + ) + + stream.on("data", (chunk: Buffer) => { + prefixer.data(chunk) + }) + stream.on("close", () => { + prefixer.end() + }) + } + } + + async function waitReady(child: Child): Promise { + const attempts = Math.max( + 1, + Math.ceil( + child.spec.readyTimeoutMs / readyPollIntervalMs, + ), + ) + + for (let i = 0; i < attempts; i++) { + if (!child.running) { + throw new Error( + `${child.spec.name} exited before becoming ready`, + ) + } + + if (await deps.probe(child.spec.readyUrl)) { + return + } + + await deps.sleep(readyPollIntervalMs) + } + + throw new Error( + `${child.spec.name} did not become ready within ${child.spec.readyTimeoutMs}ms`, + ) + } + + async function start(spec: ChildSpec): Promise { + deps.log(`[launcher] starting ${spec.name}`) + + const process = deps.spawn(spec.command, spec.args, spec.env) + + attachOutput(spec.name, process) + + const exited = new Promise((resolve) => { + process.once("exit", (code, signal) => { + resolve(code ?? (signal === null ? 0 : 1)) + }) + }) + + const child: Child = { spec, process, exited, running: true } + + children.push(child) + + void exited.then((code) => { + child.running = false + + if (!stopping) { + deps.onUnexpectedExit(spec.name, code) + } + }) + + await waitReady(child) + + deps.log(`[launcher] ${spec.name} is ready`) + } + + async function stopAll(): Promise { + stopping = true + + for (const child of [...children].reverse()) { + if (!child.running) { + continue + } + + deps.log(`[launcher] stopping ${child.spec.name}`) + child.process.kill("SIGTERM") + + const graceful = await Promise.race([ + child.exited.then(() => true), + deps + .sleep(child.spec.stopGraceMs) + .then(() => false), + ]) + + if (graceful) { + continue + } + + deps.log( + `[launcher] ${child.spec.name} exceeded its stop grace, killing`, + ) + child.process.kill("SIGKILL") + + await child.exited + } + } + + return { start, stopAll } +} diff --git a/docker/prod/launcher/src/test-helpers.ts b/docker/prod/launcher/src/test-helpers.ts new file mode 100644 index 00000000..75a4f4c7 --- /dev/null +++ b/docker/prod/launcher/src/test-helpers.ts @@ -0,0 +1,82 @@ +import type { Config } from "./env.js" +import type { Secrets } from "./secrets.js" + +// a complete, valid environment. Each test overrides only the variables it +// is about, so the rest can never be the reason a case passes or fails. +export function completeEnv( + overrides: Record = {}, +): Record { + return { + OXYNOTE_PUBLIC_URL: "https://notes.example.com", + OXYNOTE_DB_DSN: "postgresql://user:pass@postgres/oxynote", + OXYNOTE_VALKEY_DSN: "redis://valkey:6379", + OXYNOTE_OBJECT_STORAGE_DSN: + "http://access:secret@s3.example.com/documents", + ...overrides, + } +} + +// the configuration completeEnv() parses into, for tests that start after +// loading. +export function testConfig(overrides: Partial = {}): Config { + return { + publicOrigin: "https://notes.example.com", + publicHostPort: "notes.example.com", + cookieDomain: "notes.example.com", + publicWebSocketOrigin: "wss://notes.example.com", + databaseDsn: "postgresql://user:pass@postgres/oxynote", + valkeyDsn: "redis://valkey:6379", + objectStorage: { + url: "http://s3.example.com", + accessKey: "access", + secretKey: "secret", + bucket: "documents", + region: "", + }, + smtp: undefined, + emailFromAddress: "", + meilisearch: undefined, + changeDetection: undefined, + githubApp: undefined, + slackApp: undefined, + socialLogin: {}, + aiAssistant: { + PROVIDER: "", + MODEL: "", + API_KEY: "", + BASE_URL: "", + MAX_TOKENS: "", + REQUEST_TIMEOUT: "", + SUMMARY_MODEL: "", + AZURE_API_VERSION: "", + BEDROCK_REGION: "", + BEDROCK_ACCESS_KEY: "", + BEDROCK_SECRET_ACCESS_KEY: "", + BEDROCK_SESSION_TOKEN: "", + VERTEX_PROJECT_ID: "", + VERTEX_REGION: "", + VERTEX_SERVICE_ACCOUNT_JSON: "", + }, + maxOrganizations: "", + maxOrganizationMembers: "", + rateLimitDisabled: false, + maxDocumentHistoryEntries: "", + documentHistoryRetention: "", + logLevel: "", + termsOfServiceUrl: "", + privacyPolicyUrl: "", + crashReportingDisabled: false, + authSecret: undefined, + dataSourceEncryptionKey: undefined, + ...overrides, + } +} + +export function testSecrets(): Secrets { + return { + authSecret: "test-auth-secret", + dataSourceEncryptionKey: "0123456789abcdef0123456789abcdef", + githubInstallationSigningSecret: "github-installation-secret", + slackInstallationSigningSecret: "slack-installation-secret", + } +} diff --git a/docker/prod/launcher/tsconfig.json b/docker/prod/launcher/tsconfig.json new file mode 100644 index 00000000..54ee8d00 --- /dev/null +++ b/docker/prod/launcher/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "types": ["node"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "removeComments": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "allowUnreachableCode": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker/prod/launcher/vitest.config.ts b/docker/prod/launcher/vitest.config.ts new file mode 100644 index 00000000..a93d07cf --- /dev/null +++ b/docker/prod/launcher/vitest.config.ts @@ -0,0 +1,43 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + // every test runs concurrently, and no mock, stubbed global, or + // env stub may leak between tests + sequence: { + concurrent: true, + }, + restoreMocks: true, + unstubGlobals: true, + unstubEnvs: true, + + // the launcher is plain node — no DOM, no framework runtime, so + // there is one environment and tests are named .test.ts + environment: "node", + include: ["src/**/*.test.ts"], + + coverage: { + provider: "v8", + // terminal summary only — no coverage/ artifact + // directory for git, prettier, and eslint to trip over. + reporter: ["text"], + include: ["src/**/*.ts"], + exclude: [ + // the composition root: it reads the environment, + // spawns the children and installs the signal + // handlers, so evaluating it is starting the + // container. What it wires together is covered + // through the modules it calls. + "src/index.ts", + ], + // set from the measured baseline and ratcheted up as + // suites grow — never lowered. + thresholds: { + statements: 97.8, + branches: 92.9, + functions: 98, + lines: 97.8, + }, + }, + }, +}) diff --git a/e2e/CLAUDE.md b/e2e/CLAUDE.md index dc69d84f..14cb1880 100644 --- a/e2e/CLAUDE.md +++ b/e2e/CLAUDE.md @@ -10,7 +10,26 @@ stated. ## Project `@oxynote/e2e` — Playwright tests that drive the composed product through -a browser, plus the docker-compose stack they run against. +a browser, plus the docker-compose stacks they run against. + +**There are two suites and they share every test.** The dev suite drives +the four containers this repository builds; the prod suite drives the +all-in-one image an operator actually installs +([docker/prod/](../docker/prod/CLAUDE.md)), and adds the `prod-*.test.ts` +files, which only mean anything there. Neither is a subset of the other: +the flow tests are the same files, run twice, because the thing under test +is a different assembly of the same product. + +**Everything that belongs to one stack says which, and `dev` is never the +unnamed default.** `docker-compose.dev.yaml` and `docker-compose.prod.yaml`, +`playwright.dev.config.ts` and `playwright.prod.config.ts`, `test:dev` and +`test:prod`, `make e2e-dev` and `make e2e-prod`, `qa-e2e-dev.yml` and +`qa-e2e-prod.yml`. A bare name for one half and a qualified name for the +other reads as though the bare one were the whole suite, which is exactly +the confusion worth spending a few characters to avoid — and it costs +`-c`/`-f` on the direct commands, which is a fair price. Use `prod`, not +`production`, in every name; `production` stays a word for the real-world +thing a deployment is, as in `NODE_ENV=production`. **It lives at the repository root, not in `web/`, for two reasons.** The tests exercise the whole system — the web app through Caddy, real core, @@ -27,20 +46,25 @@ instead. Package manager is **pnpm**. From the repository root: ```sh -make e2e # run the suite; the config builds, starts and tears down -make e2e-stack-build # build the stack's images, for iterating -make e2e-stack-stop # stop the stack and drop its data +make e2e-dev # run the suite against the dev stack; builds and tears down +make e2e-dev-stack-build # build that stack's images, for iterating +make e2e-dev-stack-stop # stop it and drop its data + +make e2e-prod # the same suite against the all-in-one image +make e2e-prod-stack-build # build that image +make e2e-prod-stack-stop # stop it and drop its data ``` From `e2e/`: ```sh pnpm setup # deps + the playwright chromium browser -pnpm test # playwright test (full cycle, see below) +pnpm test:dev # playwright against the dev stack (full cycle, see below) +pnpm test:prod # the same, against the all-in-one image pnpm check-lint # check-types + check-eslint + check-fmt + check-knip pnpm lint # the fixing counterpart of all four -pnpm qa # check-lint + test; qa-fix = lint + test +pnpm qa # check-lint + test:dev; qa-fix = lint + test:dev pnpm check-types # tsc --noEmit pnpm check-eslint # eslint --max-warnings 0 . (eslint / fmt are the @@ -54,28 +78,47 @@ pnpm check-knip # dead exports, files and dependencies Nothing has to be running first, and nothing is left behind. That is deliberate, and the reason is that a test suite has several front -doors — `make e2e`, `pnpm test`, and the play button in the Playwright VS -Code extension — and only the config is common to all of them. Putting +doors — `make e2e-dev`, `pnpm test:dev`, and the play button in the Playwright +VS Code extension — and only the config is common to all of them. Putting the build in the caller meant the extension tested whatever image was last left behind, which passes and lies. Now every entry point runs the -same cycle, so `make e2e` is a one-line target that only starts the run. +same cycle, so `make e2e-dev` is a one-line target that only starts the run. The build still lives in the Makefile, and the hook shells back out to -`make e2e-stack-build` rather than reimplementing it. Two reasons: the +`make e2e-dev-stack-build` rather than reimplementing it. Two reasons: the hook cannot express it — goreleaser produces core's image, which compose cannot build at all, since the service has no build section — and going -through make is what keeps `E2E_COMPOSE_EXTRA` working, which is how CI +through make is what keeps `E2E_DEV_COMPOSE_EXTRA` working, which is how CI layers the buildx cache config in -[docker-compose.ci.yaml](docker-compose.ci.yaml) over the base file. +[docker-compose.dev.ci.yaml](docker-compose.dev.ci.yaml) over the base file. + +**Both suites run through the same setup and teardown**, which is why +they take the stack they drive from `E2E_STACK` rather than from a second +copy of the hooks. Each playwright config sets it as its first statement, +before playwright loads the global setup or forks a worker — both inherit +the config process's environment — and +[helpers/config.ts](helpers/config.ts) reads it to resolve the ports while +[helpers/stack.ts](helpers/stack.ts) reads it to pick the compose file and +the make targets. Nothing else in the suite knows which stack it is on. + +The knock-on is that **neither config sets `use.baseURL`**: a config is +evaluated before it can influence a module it imports, so the origin has +to come from the helpers instead. `visit()` splices it on, and every other +navigation and request in the suite was already absolute. **The cost is that every run is a full build and teardown.** With warm caches and no source change that is tens of seconds, not minutes, and a dropped volume per run means each run starts from an empty database. To -iterate without paying it, build once with `make e2e-stack-build` and -drive playwright directly with `pnpm exec playwright test`, which is the -same binary without the make wrapper — but understand that you are then +iterate without paying it, build once with `make e2e-dev-stack-build` and +drive playwright directly with +`pnpm exec playwright test -c playwright.dev.config.ts`, which is the same +binary without the make wrapper — but understand that you are then responsible for rebuilding after a source change, and that a stale image -fails silently. +fails silently. **`-c` is not optional**: neither config carries +playwright's default filename, so a bare `playwright test` finds nothing. +That is the price of naming both stacks rather than leaving the dev one +implicit, and it is the right trade — an unnamed default is exactly what +makes two-stack setups ambiguous. Both build steps run quietly, under a ticking line, and replay their whole log if they fail. Docker and goreleaser say a great deal that @@ -85,14 +128,16 @@ matters only when something breaks. Its lifecycle belongs to the setup and teardown hooks, which reach the Makefile through [helpers/stack.ts](helpers/stack.ts); anything else — reading logs, poking at a container — is plain `docker compose` run from -this directory, which discovers `docker-compose.yaml` without being told -where it is. A `pnpm stack:*` wrapper would only be a second name for a -command that is already shorter than its alias. +this directory with `-f docker-compose.dev.yaml` or +`-f docker-compose.prod.yaml`. Neither carries compose's default name, for +the same reason neither playwright config does: which stack a command +drives is never left to a default. A `pnpm stack:*` wrapper would only be a +second name for a command that is already shorter than its alias. ## The stack -[docker-compose.yaml](docker-compose.yaml) is the whole backend, built -from this repository's own source — the same images and Dockerfiles the +[docker-compose.dev.yaml](docker-compose.dev.yaml) is the whole backend, +built from this repository's own source — the same images and Dockerfiles the dev stack uses, never a published release. It deliberately differs from `docker/docker-compose.dev.yaml`: @@ -106,6 +151,15 @@ dev stack uses, never a published release. It deliberately differs from - **Every image is pinned**, as in the dev stack. A run has to fail because this repository changed, not because an upstream `latest` moved overnight. +- **The images this repo builds are tagged `:e2e-dev` here and + `:e2e-prod` in the prod stack**, never the `:dev` the dev stack uses. + A tag names the stack an image belongs to, and the separation is load + bearing rather than cosmetic: sharing a tag would mean an e2e run + silently replaced the image `make start` is running. Core's comes from + goreleaser, so its tag is threaded through as + `make build-go CORE_IMAGE_TAG=e2e-dev` → core's `IMAGE_TAG` → + `CORE_SNAPSHOT_TAG`, which `.goreleaser.yml` reads as the snapshot + version. The prod image takes `PROD_IMAGE_TAG` the same way. - **Only the services the tests touch.** No changedetection, sockpuppet-chrome, mariadb, datagen, prometheus or grafana. - **The front door is `docker/Caddyfile` verbatim**, mounted from the dev @@ -126,6 +180,39 @@ verification is mandatory (`requireEmailVerification`), so tests fetch the real message from mailpit's REST API and follow the real link — nothing is stubbed or short-circuited. +**Neither stack runs valkey or an object store.** Both are optional +everywhere in the product: without them the assistant keeps its +conversations in the core process, better-auth keeps no secondary session +storage, and uploaded images live on the filesystem. That is what a +deployment does when those variables are unset, so it is the configuration +most installs actually run — and testing it here is worth more than +testing a shape that only larger deployments have. A test that needs +either one back has to say why in the same breath. + +## The prod stack + +[docker-compose.prod.yaml](docker-compose.prod.yaml) is +`docker/prod/docker-compose.example.yaml` — the deployment the +image's README documents — retuned as a fixture: throwaway state, its own +ports (`:19080` front door, `:19025` mailpit), inlined env, pinned images. +It runs alongside both other stacks. + +Two things about it are not variations on the dev stack: + +- **Compose builds nothing.** The image comes from `make + prod-build`, because core's binary has to be produced by + goreleaser and staged where the Dockerfile's `COPY` expects it. There is + no build section to point compose at, which is also why the CI cache + reaches the build through `PROD_BUILD_EXTRA` on the `docker build` + rather than through a compose override. It is tagged `:e2e-prod`. +- **There is a `probe` container**, an idle alpine on the same private + network. It exists for [tests/](tests/) and is the + only honest way to ask whether a container that is not caddy can reach + core, auth-realtime or the web server directly. Probing published host + ports would answer nothing: the internal ports are not published, so + every such probe passes whether the services bind loopback or the + wildcard. + ## Testing standards ### Layout @@ -138,6 +225,16 @@ nothing is stubbed or short-circuited. with. `web/` co-locates because a test there has exactly one subject file; an e2e test has none — it crosses four services by design, so it is filed by flow instead. +- **A `prod-` prefix is the one exception to naming by flow**, and it is + filed by stack rather than by flow because that is what those cases have + in common: each observes something only true of the all-in-one image. + The dev config ignores `prod-*.test.ts` (`testIgnore`), so the prefix is + what decides a case runs once instead of twice. They stay in + [tests/](tests/) beside everything else — a subdirectory would suggest a + second tier, and there is only one. A test earns the prefix only if it + would be meaningless or misleading against the dev stack; a flow that + merely happens to work in both is a normal flow test, run by both suites + already. - **Shared code lives in [helpers/](helpers/), one module per concern** (`auth`, `workspace`, `editor`, `collaboration`, `mailpit`, `i18n`, `page`, `config`, `api`, `realtime`). Helpers are setup and plumbing; @@ -255,6 +352,28 @@ something no cheaper tier can. which is why a missing check survives a suite that covers every line. Naming follows the same rule as everywhere else — the group names the subject, each name completes "it …" with the denial it observes. +- **The trust boundary is tested from outside the image**, in + [tests/prod-trust-boundary.test.ts](tests/prod-trust-boundary.test.ts). + Two independent things keep core's `/api/x/*` and auth-realtime's + `/api/internal/*` unreachable — the services bind the container's + loopback, and caddy refuses the prefixes at the front door — and the + file asserts them separately, because either one alone is a single + point of failure and a suite that conflated them would go green with + one of them gone. The launcher runs its own bind check at boot; this + asks the same question from the network, so a regression that took the + boot gate with it is still caught. + - The bind cases go through the `probe` container, and the group carries + a **positive control** — the same probe reaching the front door — so a + broken network cannot pass as a loopback bind. Every table-driven + negative here needs one; without it the whole group is one typo away + from proving nothing. + - The front-door cases are sent with `rawRequest`, over `node:http`, + because the point is what caddy does with a path and `fetch` would + normalise `..`, duplicate slashes and encoding away before the request + ever left. Two tables: spellings caddy is expected to recognise as the + same request must be `403`, and traversal attempts must simply never + succeed — asserting a specific status for those would couple the suite + to caddy's normalisation rather than to the property that matters. - **A denial that does not hold yet is marked `test.fail()`**, with a comment saying what is missing and what to delete when it is fixed. That keeps the gap in the suite instead of in a backlog, and Playwright @@ -342,12 +461,26 @@ something no cheaper tier can. - **Workers are capped at four locally and two on CI.** Every test's setup is several cross-service round trips against one core and one postgres; past a few workers the setup starts timing out, which reads - as flaky tests when it is only load — and a private-repo CI runner has - two vCPUs for the stack and the browsers together. For the same - reason the test budget is 60s (a 7s local test is a 40s test on that - runner), and the collaboration tests are marked slow, tripling theirs: + as flaky tests when it is only load. CI gets fewer as headroom rather + than because of any particular runner — it carries the stack and the + browsers on one machine, and a green suite there is worth more than a + slightly faster one. For the same reason the test budget is 60s, well + above what a test takes locally, and the collaboration tests are marked + slow, tripling theirs: each one runs two signups, two verifications, an invitation and its acceptance before the first assertion. +- **Both suites run four workers, and turning that down is not how a + flake gets fixed.** The prod stack contends harder than the dev one — + caddy, web, core and auth-realtime share a single container there — and + that pressure is worth keeping, because it is what surfaces the real + defects. Every failure that only appeared at four turned out to be one: + a form that read its session once instead of following it, sync + connections that were never torn down when a tab closed, a dialog + dismissed with one keypress when two were open, a toast asserted after + it had auto-dismissed, and cross-service chains waited on with the + default five seconds. Each was fixed where it lived. If a case fails + only under load, that is the finding — chase it, do not widen the gap + it hides in. - **No test depends on another's state or on execution order**, and no test cleans up after itself: the stack's database is thrown away wholesale, which is why it carries no volumes. @@ -437,8 +570,8 @@ unawaited assertion described above. It is scoped to [tests/](tests/) and [helpers/](helpers/). **knip** guards dead exports, files and dependencies, and needs no config -file: its Playwright plugin reads [playwright.config.ts](playwright.config.ts), -resolves `globalSetup` and `globalTeardown` from it, and treats the test +file: its Playwright plugin picks up both `playwright.*.config.ts` files, +resolves `globalSetup` and `globalTeardown` from them, and treats the test files as entry points. Nothing here has `web/`'s auto-import or vendored-directory blind spots, so a `knip.ts` would only be a file to keep in sync. @@ -448,9 +581,9 @@ TypeScript is strict, with `noUnusedLocals`, `noUnusedParameters`, ## CI -[.github/workflows/qa-e2e.yml](../.github/workflows/qa-e2e.yml) mirrors +[.github/workflows/qa-e2e-dev.yml](../.github/workflows/qa-e2e-dev.yml) mirrors the other QA workflows: a `lint` job running `check-lint`, and a `test` -job running `make e2e`. The test job needs more than a node toolchain — +job running `make e2e-dev`. The test job needs more than a node toolchain — it sets up Go and goreleaser, because the suite runs against images built from this repository and core's is not something compose can build. On failure it uploads the html report, which is why the CI reporter is @@ -461,20 +594,43 @@ report with traces to download. suite for a change anywhere that can break it — `web/`, `server/`, `docker/`, `e2e/` — because that is where regressions actually come from. A pull request only triggers it for changes to `e2e/` itself. A full run -builds a nuxt image and costs real minutes on a private repository, and -paying that on every push of every frontend PR buys only a few minutes' -warning over the run on main. `concurrency` supersedes earlier runs on +builds a nuxt image and costs real minutes, and paying that on every push +of every frontend PR buys only a few minutes' warning over the run on +main. `concurrency` supersedes earlier runs on every branch, `main` included — only the latest push's result matters, so a superseded run shows as cancelled rather than finishing. **Layer caching is what keeps that affordable.** A runner starts with no layers, so the workflow sets up buildx and applies -[docker-compose.ci.yaml](docker-compose.ci.yaml) through -`E2E_COMPOSE_EXTRA` — a separate file because `type=gha` is meaningless +[docker-compose.dev.ci.yaml](docker-compose.dev.ci.yaml) through +`E2E_DEV_COMPOSE_EXTRA` — a separate file because `type=gha` is meaningless outside a runner and would break every local build. Paired with the dependency layer in `web/Dockerfile.dev`, a push that leaves `web/` alone reuses the whole image instead of rebuilding it. +**The prod suite is +[.github/workflows/qa-e2e-prod.yml](../.github/workflows/qa-e2e-prod.yml), +and it runs on tags, not on branches.** No push and no pull request +triggers it; `workflow_dispatch` is there so the suite is reachable before +the first tag exists and for re-verifying an image without cutting a +release. The dev suite is what guards day-to-day work, and this one answers +a different question — does the artifact about to be published serve the +product, and is its trust boundary intact — which is only worth its minutes +when something is being released, because building the image means building +nuxt, auth-realtime and the launcher from scratch. +`cancel-in-progress` is **off** here, the opposite of every branch +workflow: a superseded branch run is answering about a commit that is no +longer the branch, but a tag is verified once and its answer is wanted. + +The release workflow does not exist yet. When it does, this should become a +gate it depends on rather than a second run triggered by the same tag. + +Its cache reaches the build through `PROD_BUILD_EXTRA` rather than a +compose override, because compose builds nothing in the prod stack — the +image comes from `docker build` in the Makefile. That is also why +[docker-compose.dev.ci.yaml](docker-compose.dev.ci.yaml) carries the `dev` +in its name with no prod counterpart to pair with. + ## Not covered yet The desktop/Electron build. Playwright drives it through a different diff --git a/e2e/docker-compose.ci.yaml b/e2e/docker-compose.dev.ci.yaml similarity index 57% rename from e2e/docker-compose.ci.yaml rename to e2e/docker-compose.dev.ci.yaml index f3b8b2ad..06d6327c 100644 --- a/e2e/docker-compose.ci.yaml +++ b/e2e/docker-compose.dev.ci.yaml @@ -1,11 +1,16 @@ -# layer cache for CI only, applied on top of docker-compose.yaml. +# layer cache for CI only, applied on top of docker-compose.dev.yaml. # # it is a separate file because `type=gha` is meaningless outside a # GitHub runner — putting it in the main compose file would break every -# local build. The workflow supplies it through E2E_COMPOSE_EXTRA. +# local build. The workflow supplies it through E2E_DEV_COMPOSE_EXTRA. # # without this, a runner starts from zero layers and rebuilds the whole # nuxt image on every run, which is the bulk of the job's wall clock. +# +# there is no prod counterpart, which is why this one is named for the dev +# stack: compose builds nothing in docker-compose.prod.yaml — the +# all-in-one image comes from `docker build` in the Makefile — so its cache +# flags go through PROD_BUILD_EXTRA instead of a compose override. services: web: diff --git a/e2e/docker-compose.yaml b/e2e/docker-compose.dev.yaml similarity index 90% rename from e2e/docker-compose.yaml rename to e2e/docker-compose.dev.yaml index 745dd2da..b5fbc1c2 100644 --- a/e2e/docker-compose.yaml +++ b/e2e/docker-compose.dev.yaml @@ -7,6 +7,11 @@ # - env is inlined and committed; there is nothing secret to protect # - only the services the tests exercise (no changedetection, mariadb, # datagen or grafana) +# - no valkey and no object store, so core and auth-realtime fall back +# to their built-in equivalents (in-process conversations, no +# secondary session storage, images on the container filesystem) — +# which is what a production deployment does when those variables +# are unset, and therefore the configuration most installs run # # the front door is the dev Caddyfile verbatim: e2e must exercise the # same routing and the same /api/x and /api/internal blocks that ship. @@ -15,7 +20,7 @@ # this repository changed, not because something upstream published a # new latest overnight. -name: oxynote-e2e +name: oxynote-e2e-dev services: caddy: @@ -33,7 +38,7 @@ services: condition: service_healthy web: - image: ghcr.io/oxynote/web:e2e + image: ghcr.io/oxynote/web:e2e-dev build: context: ../web dockerfile: Dockerfile.dev @@ -56,14 +61,13 @@ services: start_period: 20s auth-realtime: - image: ghcr.io/oxynote/auth-realtime:e2e + image: ghcr.io/oxynote/auth-realtime:e2e-dev build: context: ../server/auth-realtime dockerfile: Dockerfile.dev environment: - OXYNOTE_AUTH_REALTIME_BACKEND_URL=http://core:8080 - OXYNOTE_AUTH_REALTIME_DB_DSN=postgresql://devuser:devpass@postgres/devdb?sslmode=disable - - OXYNOTE_AUTH_REALTIME_VALKEY_URL=redis://valkey:6379 - OXYNOTE_AUTH_REALTIME_BETTER_AUTH_BASE_URL=http://localhost:18080/auth-realtime - OXYNOTE_AUTH_REALTIME_BETTER_AUTH_COOKIE_DOMAIN=localhost - OXYNOTE_AUTH_REALTIME_FRONTEND_URL=http://localhost:18080 @@ -84,8 +88,6 @@ services: depends_on: postgres: condition: service_healthy - valkey: - condition: service_healthy healthcheck: test: [ @@ -100,15 +102,13 @@ services: retries: 30 core: - image: ghcr.io/oxynote/core:dev + image: ghcr.io/oxynote/core:e2e-dev platform: linux/amd64 environment: - OXYNOTE_CORE_DB_DSN=postgresql://devuser:devpass@postgres/devdb?sslmode=disable - - OXYNOTE_CORE_MEILISEARCH_DSN=http://meilisearch:7700 + - OXYNOTE_CORE_MEILISEARCH_URL=http://meilisearch:7700 - OXYNOTE_CORE_MEILISEARCH_MASTER_KEY=devmasterkey - - OXYNOTE_CORE_VALKEY_NETWORK=tcp - - OXYNOTE_CORE_VALKEY_ADDRESS=valkey:6379 - - OXYNOTE_CORE_STORAGE_PATH=/home/nonroot/storage + - OXYNOTE_CORE_OBJECT_STORAGE_LOCAL_PATH=/home/nonroot/storage - OXYNOTE_CORE_SERVER_AUTH_BETTER_AUTH_URL=http://auth-realtime:8081/api/auth/get-session # the MCP surface: core validates bearer tokens against auth-realtime # over the compose network, and the resource URL is the public @@ -143,14 +143,6 @@ services: timeout: 5s retries: 20 - valkey: - image: valkey/valkey:9.1.1-alpine - healthcheck: - test: ["CMD", "valkey-cli", "ping"] - interval: 5s - timeout: 5s - retries: 20 - meilisearch: image: getmeili/meilisearch:v1.53.1 environment: diff --git a/e2e/docker-compose.prod.yaml b/e2e/docker-compose.prod.yaml new file mode 100644 index 00000000..dab0c136 --- /dev/null +++ b/e2e/docker-compose.prod.yaml @@ -0,0 +1,109 @@ +# the production all-in-one image under test, with the backing services a +# real deployment gives it. This is docker/prod/docker-compose.example.yaml +# — the deployment the README documents — retuned for a test run. +# +# it differs from docker-compose.dev.yaml (the dev stack) in what it is testing, +# not in how: there, four containers built from this checkout; here, the one +# image an operator actually pulls, with caddy, web, core and auth-realtime +# supervised inside it. Everything the suite asserts about the product must +# hold for both. +# +# the same fixture rules apply: +# - no named volumes, so every run starts from an empty database and an +# empty data volume (the generated secrets are regenerated per run) +# - no container_name, and its own ports (:19080 front door, :19025 +# mailpit), so it coexists with the dev stack and the e2e dev stack alike +# - env is inlined and committed; there is nothing secret to protect +# - every image is pinned +# - no OXYNOTE_VALKEY_DSN and no OXYNOTE_OBJECT_STORAGE_DSN, which is the default +# single-node deployment: the assistant keeps its conversations in the +# core process, better-auth keeps no secondary storage, and uploaded +# images live on the data volume +# +# the image is built by `make prod-build`, not by compose: core's binary has +# to come from goreleaser and be staged for the Dockerfile's COPY, so there +# is no build section here to point at. `make e2e-prod-stack-build` tags it +# :e2e-prod, so a run cannot replace the :prod image `make prod-run` uses. + +name: oxynote-e2e-prod + +services: + oxynote: + image: ghcr.io/oxynote/oxynote:e2e-prod + platform: linux/amd64 + ports: + - "19080:8080" + environment: + - OXYNOTE_PUBLIC_URL=http://localhost:19080 + - OXYNOTE_DB_DSN=postgresql://devuser:devpass@postgres/devdb?sslmode=disable + - OXYNOTE_MEILISEARCH_URL=http://meilisearch:7700 + - OXYNOTE_MEILISEARCH_MASTER_KEY=devmasterkey + - OXYNOTE_SMTP_DSN=smtp://mailpit:1025?tls=none + - OXYNOTE_EMAIL_FROM_ADDRESS=Oxynote + - OXYNOTE_MAX_ORGANIZATION_MEMBERS=5 + # every test signs up and creates its own workspace, and a stack left + # up for a day of iteration would hit a lower cap and start failing + # signups with a full-house message. + - OXYNOTE_MAX_ORGANIZATIONS=100000 + # every request in a run arrives from one address, and better-auth + # gives sign-in and sign-up three per ten seconds between them, so the + # limiter would fail the suite rather than the code under test. + - OXYNOTE_RATE_LIMIT_DISABLED=true + depends_on: + postgres: + condition: service_healthy + meilisearch: + condition: service_healthy + # the image ships its own layered probe (every internal service plus the + # front door), which is what the probe container and the tests wait on. + # Its start period is three minutes; compose inherits that. + # + # the image shuts its services down in order, flushing open documents + # last, and docker's default 10s grace would SIGKILL that mid-flush. + stop_grace_period: 60s + + # a sibling on the private network, and the only honest way to ask the + # question the trust boundary answers: can a container that is not caddy + # reach core, auth-realtime or the web server directly? The prod tests + # drive it with `docker compose exec` — see helpers/prod.ts. + # + # probing published host ports instead would prove nothing: the internal + # ports are not published, so every such probe would pass whether the + # services bound loopback or the wildcard. + probe: + image: alpine:3.23.0 + command: ["sleep", "infinity"] + depends_on: + oxynote: + condition: service_healthy + + postgres: + image: postgres:18.6-alpine + environment: + POSTGRES_USER: devuser + POSTGRES_PASSWORD: devpass + POSTGRES_DB: devdb + healthcheck: + test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"] + interval: 5s + timeout: 5s + retries: 20 + + meilisearch: + image: getmeili/meilisearch:v1.53.1 + environment: + - MEILI_MASTER_KEY=devmasterkey + - MEILI_ENV=development + healthcheck: + test: ["CMD", "curl", "--fail", "http://localhost:7700/health"] + interval: 5s + timeout: 5s + retries: 20 + + # the tests read verification links out of mailpit's REST API, so its http + # port is published rather than proxied through the image's front door — + # which routes nothing but the product. + mailpit: + image: axllent/mailpit:v1.31.0 + ports: + - "19025:8025" diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index dfd16c13..78ae3897 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -12,9 +12,11 @@ const TICK_INTERVAL = 1_000 // probe itself with. // // The build runs here rather than in the caller so that every way of -// starting the suite behaves the same: `make e2e`, `pnpm test`, and the -// play button in the Playwright VS Code extension all test the code that -// is in the tree right now, not whatever image was last left behind. +// starting the suite behaves the same: the make targets, the pnpm scripts +// and the play button in the Playwright VS Code extension all test the code +// that is in the tree right now, not whatever image was last left behind. +// Which stack gets built and started is the playwright config's choice, and +// this hook is shared by both. export default async function globalSetup(): Promise { // make prints its own labelled step per image, so it is not wrapped // in a progress line of this file's own. diff --git a/e2e/helpers/collaboration.ts b/e2e/helpers/collaboration.ts index 2e390522..d8f4ad84 100644 --- a/e2e/helpers/collaboration.ts +++ b/e2e/helpers/collaboration.ts @@ -43,12 +43,20 @@ export async function inviteTeamMember( }) .click() - // the delivered email is the proof the invitation went through; the - // invite modal has closed itself by then, leaving only settings + // the delivered email is the proof the invitation went through const inviteLink = await fetchInviteLink(request, email) - await page.keyboard.press("Escape") - await expect(page.getByRole("dialog")).toHaveCount(0) + // one Escape closes whichever dialog is on top, and how many are open + // depends on whether the invite modal has finished closing itself yet + // — under load it has not, so a single press leaves settings behind + // and every assertion after this one runs against a covered page. + // Press until nothing is left rather than assuming one is enough. + await expect(async () => { + await page.keyboard.press("Escape") + await expect(page.getByRole("dialog")).toHaveCount(0, { + timeout: 1_000, + }) + }).toPass() return inviteLink } diff --git a/e2e/helpers/config.ts b/e2e/helpers/config.ts index f73a666c..f2ad2aaa 100644 --- a/e2e/helpers/config.ts +++ b/e2e/helpers/config.ts @@ -1,8 +1,21 @@ -// the ports the e2e stack publishes; they differ from the dev stack's so -// both can run at once. Changing one means changing docker-compose.yaml. -export const BASE_URL = "http://localhost:18080" -export const MAILPIT_URL = "http://localhost:18025" -export const REALTIME_WS_URL = `ws://localhost:18080/auth-realtime/hocuspocus` +// which stack the run is driving. Two suites share these tests: the dev +// stack (docker-compose.dev.yaml) and the all-in-one image an operator +// installs (docker-compose.prod.yaml). Each publishes its own ports so +// both can be up at once, and each playwright config names its stack here +// before playwright loads anything else — the workers are spawned from +// this process, so they inherit the choice. +export const STACK = process.env.E2E_STACK === "prod" ? "prod" : "dev" + +// the ports the stack under test publishes. Changing one means changing +// the matching docker-compose file. +const PORTS = + STACK === "prod" + ? { frontDoor: "19080", mailpit: "19025" } + : { frontDoor: "18080", mailpit: "18025" } + +export const BASE_URL = `http://localhost:${PORTS.frontDoor}` +export const MAILPIT_URL = `http://localhost:${PORTS.mailpit}` +export const REALTIME_WS_URL = `ws://localhost:${PORTS.frontDoor}/auth-realtime/hocuspocus` // the mcp surface's canonical url, which is also the OAuth resource // (RFC 8707) a token is bound to. export const MCP_URL = `${BASE_URL}/core/api/mcp` @@ -12,4 +25,4 @@ export const MCP_URL = `${BASE_URL}/core/api/mcp` // things a redirect target has to do here. A loopback http address also // needs the client to register as native; a web client would have to // offer https. -export const MCP_REDIRECT_URL = "http://127.0.0.1:18025/" +export const MCP_REDIRECT_URL = `http://127.0.0.1:${PORTS.mailpit}/` diff --git a/e2e/helpers/page.ts b/e2e/helpers/page.ts index a8132e06..65e49ae4 100644 --- a/e2e/helpers/page.ts +++ b/e2e/helpers/page.ts @@ -1,4 +1,5 @@ import type { Page } from "@playwright/test" +import { BASE_URL } from "./config" // visit navigates to a path and waits until the app is interactive. // @@ -8,8 +9,14 @@ import type { Page } from "@playwright/test" // instance onto the mount root as it finishes, which is the first moment // a click is guaranteed to register. Navigate through this rather than // page.goto whenever a test then drives the page. +// +// The origin is resolved here rather than left to playwright's baseURL: the +// two suites drive two stacks on two ports, and only the helpers know which +// one this run picked. Callers pass a path or a whole URL — one read out of +// page.url() to revisit where a test already is — and URL resolution takes +// both, leaving an absolute one alone. export async function visit(page: Page, path: string): Promise { - await page.goto(path) + await page.goto(new URL(path, BASE_URL).href) await page.waitForFunction( () => "__vue_app__" in (document.getElementById("__nuxt") ?? {}), ) diff --git a/e2e/helpers/prod.ts b/e2e/helpers/prod.ts new file mode 100644 index 00000000..ea195671 --- /dev/null +++ b/e2e/helpers/prod.ts @@ -0,0 +1,153 @@ +import { spawn } from "node:child_process" +import { request as httpRequest } from "node:http" +import { BASE_URL } from "./config" +import { composeArgs, composeCwd } from "./stack" + +// the production image's internal port layout, mirroring +// docker/prod/launcher/src/mapping.ts. Caddy (8080) is the only +// wildcard listener; the three services behind it bind 127.0.0.1, and +// caddy's own admin API is switched off rather than bound anywhere. +// +// Each entry carries a path that answers 200 when the service is reached, +// so a probe that gets through is unambiguous rather than a bare 404. +export const INTERNAL_SERVICES = [ + { name: "core", port: "8180", path: "/api/x/version" }, + { name: "auth-realtime", port: "8181", path: "/api/auth-config" }, + { name: "web", port: "3000", path: "/login" }, + { name: "caddy admin", port: "2019", path: "/config/" }, +] as const + +// the front door of the container, reached the same way as the ports above. +// It is the positive control for every probe: the probe container can +// resolve and reach the image, so a refusal on 8180 is a bind and not a +// broken network. +export const FRONT_DOOR = { port: "8080", path: "/login" } + +// the compose service the probe runs in, and the one it dials. +const PROBE_SERVICE = "probe" +const TARGET_SERVICE = "oxynote" + +export interface ProbeResult { + // whether the target answered at the HTTP level at all. A refused + // connection and an HTTP error are both failures for wget, and only + // the second one means the surface was reachable. + reached: boolean + detail: string +} + +// probeFromNetwork asks a sibling container on the private network to fetch +// a URL from the image, which is the question the trust boundary actually +// answers: not "is this port published" — none of them are — but "can a +// container that is not caddy reach an unauthenticated internal surface". +// +// It is the same check the launcher runs at boot from inside the container, +// asked from the outside, so a regression that removed the boot gate as +// well as the loopback bind still fails here. +export async function probeFromNetwork( + port: string, + path: string, +): Promise { + const url = `http://${TARGET_SERVICE}:${port}${path}` + const { code, output } = await runCompose([ + "exec", + "-T", + PROBE_SERVICE, + "wget", + "-q", + "-O", + "-", + "-T", + "3", + url, + ]) + + // busybox wget exits nonzero for a refused connection and for an HTTP + // error alike, and only its message separates them. "server returned + // error" means the TCP connection was accepted and a response came + // back — the surface was reachable, whatever it answered. + const reached = code === 0 || /server returned error/i.test(output) + + return { + reached, + detail: `${url} -> ${output.trim() || `exit ${String(code)}`}`, + } +} + +export interface RawResponse { + status: number + headers: Record + body: string +} + +// rawRequest sends a path to the front door exactly as written, without a +// URL parser in the way. +// +// This is what the bypass cases need and what fetch() cannot do: the WHATWG +// parser resolves "..", collapses duplicate slashes and re-encodes as it +// builds the request, so a test written against fetch would send caddy a +// path that had already been normalised on the client and would prove +// nothing about caddy's own normalisation. Redirects are not followed, for +// the same reason — the status is the answer. +export function rawRequest(path: string, method = "GET"): Promise { + const { hostname, port } = new URL(BASE_URL) + + return new Promise((resolve, reject) => { + const req = httpRequest( + { hostname, port, path, method, timeout: 10_000 }, + (res) => { + let body = "" + + res.setEncoding("utf8") + res.on("data", (chunk: string) => { + body += chunk + }) + res.on("end", () => { + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body, + }) + }) + }, + ) + + req.on("timeout", () => { + req.destroy(new Error(`no answer for ${method} ${path}`)) + }) + req.on("error", reject) + req.end() + }) +} + +interface CommandResult { + code: number + output: string +} + +// runCompose drives the production stack's compose project. It never +// rejects on a nonzero exit: the probe reads the failure, and a rejection +// would turn the expected outcome into an error. +function runCompose(args: string[]): Promise { + const child = spawn("docker", [...composeArgs(), ...args], { + cwd: composeCwd, + stdio: ["ignore", "pipe", "pipe"], + }) + + let output = "" + + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + child.stdout.on("data", (chunk: string) => { + output += chunk + }) + child.stderr.on("data", (chunk: string) => { + output += chunk + }) + + return new Promise((resolve, reject) => { + child.on("error", reject) + child.on("close", (code) => { + resolve({ code: code ?? -1, output }) + }) + }) +} diff --git a/e2e/helpers/review.ts b/e2e/helpers/review.ts index 67af35c0..3f60ea48 100644 --- a/e2e/helpers/review.ts +++ b/e2e/helpers/review.ts @@ -38,7 +38,12 @@ export async function makeReviewable(page: Page): Promise { }) .click() - await expect(branchSwitcher(page)).toContainText(branchLabel("main")) + // enabling the workflow forks the page server-side — the draft branch + // is created, the stored content copied into it and main protected — + // so the switcher only appears once that whole chain has come back. + await expect(branchSwitcher(page)).toContainText(branchLabel("main"), { + timeout: 15_000, + }) } // switchToBranch opens the given branch of a reviewable page through the @@ -53,7 +58,11 @@ export async function switchToBranch( await branchSwitcher(page).click() await page.getByRole("menuitem", { name: branchLabel(branch) }).click() - await expect(branchSwitcher(page)).toContainText(branchLabel(branch)) + // the switch fetches the branch and re-syncs the document over the + // websocket before the switcher settles on the new name. + await expect(branchSwitcher(page)).toContainText(branchLabel(branch), { + timeout: 15_000, + }) await waitForEditor(page) if (branch === "draft") { @@ -84,8 +93,12 @@ export async function mergeDraftIntoMain(page: Page): Promise { await expect( page.getByText(t("editor.name-editor.review-workflow.merge.success")), - ).toBeVisible() - await expect(branchSwitcher(page)).toContainText(branchLabel("main")) + ).toBeVisible({ timeout: 15_000 }) + // merging writes the draft back into main and drops the draft, so the + // switcher settles only after that has been stored and reloaded. + await expect(branchSwitcher(page)).toContainText(branchLabel("main"), { + timeout: 15_000, + }) await waitForEditor(page) } diff --git a/e2e/helpers/stack.ts b/e2e/helpers/stack.ts index e0328e1a..a4401245 100644 --- a/e2e/helpers/stack.ts +++ b/e2e/helpers/stack.ts @@ -1,15 +1,36 @@ import { spawn } from "node:child_process" import { fileURLToPath } from "node:url" +import { STACK } from "./config" const E2E_DIR = fileURLToPath(new URL("..", import.meta.url)) const ROOT_DIR = fileURLToPath(new URL("../..", import.meta.url)) +// the two stacks this suite can drive, each with the make targets that +// build and drop it and the compose file that brings it up. The dev stack +// builds four images from this checkout; the prod one builds the +// all-in-one image and pulls the rest, so its compose file has no build +// section at all. +const STACKS = { + dev: { + composeFile: "docker-compose.dev.yaml", + buildTarget: "e2e-dev-stack-build", + stopTarget: "e2e-dev-stack-stop", + }, + prod: { + composeFile: "docker-compose.prod.yaml", + buildTarget: "e2e-prod-stack-build", + stopTarget: "e2e-prod-stack-stop", + }, +} as const + +const stack = STACKS[STACK] + // the stack is driven through the root Makefile rather than through // docker compose directly, so the build stays defined in one place: it is // two steps, and only one of them is compose's. Core's image is produced // by goreleaser, which compose cannot do. Running make also keeps -// E2E_COMPOSE_EXTRA working — CI sets it to layer the buildx cache config -// in docker-compose.ci.yaml over the base file. +// E2E_DEV_COMPOSE_EXTRA and PROD_BUILD_EXTRA working — CI sets them to +// layer its buildx cache over the local build. // // Every make target here already runs its steps through // scripts/run-quietly.sh, which ticks a line per step and replays the log @@ -17,20 +38,37 @@ const ROOT_DIR = fileURLToPath(new URL("../..", import.meta.url)) // would hide the per-step labels and replace two of them ("building go // images", "building stack images") with one anonymous row of dots. export function buildStack(): Promise { - return run("make", ["e2e-stack-build"], ROOT_DIR, "inherit") + return run("make", [stack.buildTarget], ROOT_DIR, "inherit") } export function stopStack(): Promise { - return run("make", ["e2e-stack-stop"], ROOT_DIR, "inherit") + return run("make", [stack.stopTarget], ROOT_DIR, "inherit") } // compose is the exception: it is called directly, with nothing in front // of it to quieten the container-by-container churn, so its output is // held back and replayed only if the step fails. export function startStack(): Promise { - return run("docker", ["compose", "up", "-d", "--wait"], E2E_DIR, "capture") + return run( + "docker", + [...composeArgs(), "up", "-d", "--wait"], + E2E_DIR, + "capture", + ) } +// composeArgs names the file every compose call has to be pointed at. +// Neither file carries compose's default name, so an unpointed call would +// find nothing — and if one of them ever did, it would silently drive the +// wrong project. +export function composeArgs(): string[] { + return ["compose", "-f", stack.composeFile] +} + +// composeCwd is the directory compose is invoked from — the compose file +// paths are relative to it. +export const composeCwd = E2E_DIR + // a step that did not finish. Playwright prints an error's stack, and a // JS trace through this file says nothing about a docker build that broke // or a run someone stopped on purpose — the message is the whole story, diff --git a/e2e/package.json b/e2e/package.json index 432e5ce2..32a7bb93 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -9,7 +9,8 @@ "scripts": { "deps": "pnpm install", "setup": "pnpm run deps && playwright install chromium", - "test": "playwright test", + "test:dev": "playwright test -c playwright.dev.config.ts", + "test:prod": "playwright test -c playwright.prod.config.ts", "check-types": "tsc --noEmit", "eslint": "eslint --fix --max-warnings 0 .", "check-eslint": "eslint --max-warnings 0 .", @@ -19,8 +20,8 @@ "check-knip": "knip", "lint": "pnpm run check-types && pnpm run knip && pnpm run eslint && pnpm run fmt", "check-lint": "pnpm run check-types && pnpm run check-eslint && pnpm run check-fmt && pnpm run check-knip", - "qa": "pnpm run check-lint && pnpm run test", - "qa-fix": "pnpm run lint && pnpm run test" + "qa": "pnpm run check-lint && pnpm run test:dev", + "qa-fix": "pnpm run lint && pnpm run test:dev" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts deleted file mode 100644 index 5c3be908..00000000 --- a/e2e/playwright.config.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { defineConfig, devices } from "@playwright/test" -import { BASE_URL } from "./helpers/config" - -export default defineConfig({ - testDir: "./tests", - fullyParallel: true, - // nearly every test signs up, verifies, logs in and creates a - // workspace before it starts, and the stack answers all of them from - // one core and one postgres. Past a few workers the setup itself - // starts timing out, which reads as flaky tests when it is only load. - // CI gets fewer still: a private-repo runner has two vCPUs for the - // whole stack and the browsers together. - workers: process.env.CI ? 2 : 4, - // the default 30s budget contradicts the suite's own waits — a login - // redirect is allowed 15s and an invite acceptance 30s, because they - // are cross-service chains that stretch under load. CI runs the whole - // stack on two cores, where a test that takes 7s locally takes 40. - timeout: 60_000, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - // github gives inline annotations on the PR; html is what the - // workflow uploads when a run fails, traces and all. - reporter: process.env.CI - ? [["github"], ["html", { open: "never" }]] - : [["list"]], - globalSetup: "./global-setup.ts", - globalTeardown: "./global-teardown.ts", - use: { - baseURL: BASE_URL, - trace: "on-first-retry", - screenshot: "only-on-failure", - }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], -}) diff --git a/e2e/playwright.dev.config.ts b/e2e/playwright.dev.config.ts new file mode 100644 index 00000000..9bef9094 --- /dev/null +++ b/e2e/playwright.dev.config.ts @@ -0,0 +1,49 @@ +import { defineConfig, devices } from "@playwright/test" + +// the stack this suite drives. It is set here, before playwright loads the +// global setup or forks a worker, because both inherit this process's +// environment — helpers/config.ts reads it to resolve the stack's ports. +// Setting it explicitly also stops a value exported in the shell from +// pointing this config at the production stack. +process.env.E2E_STACK = "dev" + +export default defineConfig({ + testDir: "./tests", + // the prod-*.test.ts files hold the cases that only mean something + // against the all-in-one image; playwright.prod.config.ts runs them. + testIgnore: "prod-*.test.ts", + fullyParallel: true, + // every test signs up, verifies, logs in and creates a workspace + // before it starts, and the stack answers all of them from one core + // and one postgres — so the suite is worth running at a width that + // keeps that contention honest. A test that fails only here is a + // missing wait, not a reason to turn this down. + workers: 4, + // the default 30s budget contradicts the suite's own waits — a login + // redirect is allowed 15s and an invite acceptance 30s, because they + // are cross-service chains that stretch under load, and a run that + // shares its machine with the stack stretches them further still. + timeout: 60_000, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + // github gives inline annotations on the PR; html is what the + // workflow uploads when a run fails, traces and all. + reporter: process.env.CI + ? [["github"], ["html", { open: "never" }]] + : [["list"]], + globalSetup: "./global-setup.ts", + globalTeardown: "./global-teardown.ts", + // no baseURL: the two suites drive two stacks on two ports, and the + // origin is resolved by helpers/config.ts instead — visit() splices it + // on, and every other navigation and request here is already absolute. + use: { + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}) diff --git a/e2e/playwright.prod.config.ts b/e2e/playwright.prod.config.ts new file mode 100644 index 00000000..2e353b1d --- /dev/null +++ b/e2e/playwright.prod.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from "@playwright/test" + +// the same suite, driven against the all-in-one image instead of the four +// containers the dev stack builds. Every flow test runs unchanged — that is +// the point, since the image is what an operator actually installs and +// nothing else proves it serves the product — plus the prod-*.test.ts +// files, which hold the cases that only mean something here. +// +// The stack is named before playwright loads the global setup or forks a +// worker, because both inherit this process's environment; +// helpers/config.ts reads it to resolve the stack's ports and +// helpers/stack.ts to pick the compose file and make targets. +process.env.E2E_STACK = "prod" + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + // the dev suite's width, deliberately: one container running caddy, + // web, core and auth-realtime together contends harder than four + // containers do, which is exactly the pressure worth keeping on. + workers: 4, + timeout: 60_000, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI + ? [["github"], ["html", { open: "never" }]] + : [["list"]], + globalSetup: "./global-setup.ts", + globalTeardown: "./global-teardown.ts", + use: { + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}) diff --git a/e2e/tests/access.test.ts b/e2e/tests/access.test.ts index d940d966..6483105b 100644 --- a/e2e/tests/access.test.ts +++ b/e2e/tests/access.test.ts @@ -160,7 +160,12 @@ test.describe("access", () => { await page .getByRole("menuitem", { name: t("sidebar.header.log-out") }) .click() - await expect(page).toHaveURL(/\/login$/, { timeout: 15_000 }) + // the query string is deliberately not pinned: logging out can + // land on /login or on /login?next=..., depending on whether the + // route guard or the sign-out redirect gets there first, and + // which one wins says nothing about the refusal this case is + // about. What follows is the assertion that matters. + await expect(page).toHaveURL(/\/login(\?|$)/, { timeout: 15_000 }) const after = await request.get(coreURL("/api/capabilities"), { headers: { cookie }, diff --git a/e2e/tests/collaboration.test.ts b/e2e/tests/collaboration.test.ts index 628de8f0..29faea01 100644 --- a/e2e/tests/collaboration.test.ts +++ b/e2e/tests/collaboration.test.ts @@ -107,6 +107,14 @@ test.describe("collaboration", () => { await other.page.keyboard.type("present") await expect(remoteCarets(contentEditor(page))).toHaveCount(1) + // leaving is a navigation away from the document: it unmounts the + // editor, which tears the sync connection down and broadcasts the + // awareness removal straight away. Closing the page instead is a + // hard kill that skips the unload path entirely, so nothing is + // broadcast and the caret survives until yjs times the stale + // state out 30s later. The app also tears down on pagehide, for + // the real tab close this cannot reproduce. + await visit(other.page, "/") await other.context.close() await expect(remoteCarets(contentEditor(page))).toHaveCount(0, { diff --git a/e2e/tests/editor.test.ts b/e2e/tests/editor.test.ts index 2de82804..be792c3c 100644 --- a/e2e/tests/editor.test.ts +++ b/e2e/tests/editor.test.ts @@ -160,12 +160,18 @@ test.describe("editor", () => { }) .click() - await expect(sidebarDocument(page, name)).toHaveCount(0) + // the toast comes first, and not only because it is the server's + // own confirmation that the delete landed: it dismisses itself + // after a few seconds, so waiting on the sidebar ahead of it + // leaves this racing a message that may already be gone. The + // deletion is a request, an invalidation and a tree refetch, so + // it gets longer than the default to complete. await expect( page.getByText( t("editor.document-deletion-modal.deletion-success", { name }), ), - ).toBeVisible() + ).toBeVisible({ timeout: 15_000 }) + await expect(sidebarDocument(page, name)).toHaveCount(0) // the welcome page is the only one left, so that is where the // user lands await expect(page).toHaveURL(/Welcome-to-Oxynote-[a-z0-9]{20}$/) diff --git a/e2e/tests/prod-trust-boundary.test.ts b/e2e/tests/prod-trust-boundary.test.ts new file mode 100644 index 00000000..259eef5a --- /dev/null +++ b/e2e/tests/prod-trust-boundary.test.ts @@ -0,0 +1,222 @@ +import { expect, test } from "@playwright/test" +import { + FRONT_DOOR, + INTERNAL_SERVICES, + probeFromNetwork, + rawRequest, +} from "../helpers/prod" + +// the production image's whole security model is that caddy is the only +// thing anyone can talk to. Core's /api/x/* and auth-realtime's +// /api/internal/* carry no authentication at all — they trust the caller by +// construction — so two independent things have to hold, and these tests +// assert them separately because either one alone is a single point of +// failure: +// +// 1. the services bind the container's loopback, so nothing on the +// network can address them however it spells the request; and +// 2. caddy blocks the internal prefixes at the front door, so the one +// reachable listener refuses to proxy them. +// +// They only mean anything against the all-in-one image — the dev stack puts +// each service in its own container on a shared network by design — so they +// live here rather than beside the flow tests. +// +// A file the tests use as their vocabulary: the endpoints below are the real +// internal surface, taken from core's internalRouter and auth-realtime's +// /internal routes. Reaching any of them without a session means reading or +// writing any document in any workspace, tearing an organization down, or +// sending mail as the deployment. + +// the raw path of an /api/x endpoint, as it would arrive at the front door. +const CORE_INTERNAL_ENDPOINTS = [ + { name: "the version report", method: "GET", path: "/core/api/x/version" }, + { name: "the metrics scrape", method: "GET", path: "/core/api/x/metrics" }, + { + name: "the pprof index", + method: "GET", + path: "/core/api/x/debug/pprof/", + }, + { + name: "a pprof heap dump", + method: "GET", + path: "/core/api/x/debug/pprof/heap", + }, + { + name: "organization initialization", + method: "POST", + path: "/core/api/x/organizations/org-e2e/initialize", + }, + { + name: "organization teardown", + method: "POST", + path: "/core/api/x/organizations/org-e2e/teardown", + }, + { + name: "an unauthorized branch listing", + method: "GET", + path: "/core/api/x/documents/doc-e2e/branches", + }, + { + name: "an unauthorized branch read", + method: "GET", + path: "/core/api/x/documents/doc-e2e/branch/branch-e2e/", + }, + { + name: "an unauthorized branch write", + method: "PUT", + path: "/core/api/x/documents/doc-e2e/branch/branch-e2e/", + }, + { name: "outgoing email", method: "POST", path: "/core/api/x/email" }, +] as const + +const AUTH_REALTIME_INTERNAL_ENDPOINTS = [ + { + name: "MCP session validation", + method: "GET", + path: "/auth-realtime/api/internal/mcp/session", + }, + { + name: "direct document operations", + method: "POST", + path: "/auth-realtime/api/internal/documents/doc-e2e/branches/branch-e2e/operations", + }, +] as const + +// spellings of a blocked path that a proxy is expected to still recognise. +// Caddy matches on the unescaped path and does so case-insensitively, and +// it merges duplicate slashes, so each of these is the same request as +// /core/api/x/version and has to be refused as one. +const EQUIVALENT_SPELLINGS = [ + { name: "an uppercase segment", path: "/core/api/X/version" }, + { name: "an uppercase prefix", path: "/CORE/API/X/version" }, + { name: "a percent-encoded segment", path: "/core/api/%78/version" }, + { name: "a percent-encoded separator", path: "/core/api/x%2fversion" }, + { name: "a doubled separator", path: "/core//api/x/version" }, + { name: "a doubled root", path: "//core/api/x/version" }, + { name: "a current-directory segment", path: "/core/./api/x/version" }, + { name: "a query string", path: "/core/api/x/version?probe=1" }, +] as const + +// spellings that try to arrive at the internal surface by a route the block +// does not literally match. Where caddy normalises them back they are +// refused, and where it does not they land somewhere harmless — what must +// never happen is that one of them succeeds. +const TRAVERSAL_SPELLINGS = [ + { name: "a parent segment", path: "/core/api/x/../x/version" }, + { name: "a parent segment from a sibling", path: "/core/api/y/../x/version" }, + { name: "an encoded parent segment", path: "/core/api/%2e%2e/api/x/version" }, + { name: "a path parameter", path: "/core/api/x/version;/" }, + { name: "a trailing dot", path: "/core/api/x/version/." }, + { + name: "a parent segment into auth-realtime", + path: "/auth-realtime/api/foo/../internal/mcp/session", + }, + { + name: "an encoded parent segment into auth-realtime", + path: "/auth-realtime/api/%2e%2e/api/internal/mcp/session", + }, + // the block matches the prefix with a separator after it, so the bare + // prefix is not caught by it — nothing useful is served there, and this + // is the case that says so out loud. + { name: "the bare core prefix", path: "/core/api/x" }, + { + name: "the bare auth-realtime prefix", + path: "/auth-realtime/api/internal", + }, +] as const + +test.describe("internal service binds", () => { + for (const service of INTERNAL_SERVICES) { + test(`refuses ${service.name} to another container on the network`, async () => { + const result = await probeFromNetwork(service.port, service.path) + + expect(result.reached, result.detail).toBe(false) + }) + } + + // without this the tests above pass for the wrong reason — a probe + // container that cannot resolve the image at all, or a stack that never + // came up, refuses every port just as convincingly as a loopback bind. + test("reaches the front door from the same container", async () => { + const result = await probeFromNetwork(FRONT_DOOR.port, FRONT_DOOR.path) + + expect(result.reached, result.detail).toBe(true) + }) +}) + +test.describe("front door blocks", () => { + for (const endpoint of [ + ...CORE_INTERNAL_ENDPOINTS, + ...AUTH_REALTIME_INTERNAL_ENDPOINTS, + ]) { + test(`refuses ${endpoint.name}`, async () => { + const response = await rawRequest(endpoint.path, endpoint.method) + + expect(response.status).toBe(403) + }) + } + + for (const spelling of EQUIVALENT_SPELLINGS) { + test(`refuses a blocked path written with ${spelling.name}`, async () => { + const response = await rawRequest(spelling.path) + + expect(response.status).toBe(403) + }) + } + + for (const spelling of TRAVERSAL_SPELLINGS) { + test(`never serves a blocked path reached through ${spelling.name}`, async () => { + const response = await rawRequest(spelling.path) + + expect( + response.status, + `${spelling.path} answered ${String(response.status)}`, + ).toBeGreaterThanOrEqual(300) + // core reports its build on /api/x/version, which is the one + // endpoint here that would say so in its body if a bypass had + // worked and the status alone had not given it away. + expect(response.body).not.toContain("version") + }) + } +}) + +test.describe("front door routing", () => { + // the blocks above are only evidence if the same front door serves + // everything else. A stack that answered 403 to every request would + // pass every test in the group above and none of these. + test("serves the app", async () => { + const response = await rawRequest("/login") + + expect(response.status).toBe(200) + }) + + test("reaches core and leaves its session check in place", async () => { + const response = await rawRequest("/core/api/capabilities") + + expect(response.status).toBe(401) + }) + + test("reaches auth-realtime", async () => { + const response = await rawRequest("/auth-realtime/api/auth-config") + + expect(response.status).toBe(200) + }) + + // /api/apps/* is the deliberate opposite of /api/x/*: GitHub and Slack + // deliver webhooks and OAuth callbacks there with no session, proving + // themselves with a request signature instead. It answers 404 here only + // because no GitHub App is configured for the stack — what matters is + // that the internal block does not swallow it. + test("leaves the public app callbacks open", async () => { + const response = await rawRequest("/core/api/apps/github/events", "POST") + + expect(response.status).not.toBe(403) + }) + + test("does not name the server it runs", async () => { + const response = await rawRequest("/login") + + expect(response.headers.server).toBeUndefined() + }) +}) diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 29f74afa..be0a5e36 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -96,20 +96,20 @@ Every organization is seeded at initialization with a Prometheus data source nam Uploaded images live under `organizations/{org}/documents/{doc}/files/{blockUID}` — the file id **is** the image block's `uid` — and are tracked in `document_files`. That key is an object key in the S3-compatible store, or a path under the storage directory on a deployment running without one; `internal/storage` holds what the two backends share and `internal/storage/{s3,fs}` implement them. Hooks hold external resources too (changedetection.io watchers, GitHub tracking). Both are reclaimed by sweeps, not by request handlers: - **The row outlives its owner.** `document_files` and `document_hooks` reference documents, branches and organizations with `ON DELETE SET NULL`, so a deleted document — or an org deleted by Better Auth — leaves rows behind rather than destroying the only record of the external resource. `document_files.storage_key` stores the object key outright so it stays reachable once the FKs are gone. -- **`internal/document/file/manager`** ticks every 5 minutes: it trims expired changelog snapshots, then reclaims files. A file is referenced if its id appears in any branch content of its document, any retained `document_branch_changelogs` snapshot, or any comment/reply — checked in SQL (`CheckDocumentFileReferenced`), so a spurious match only ever retains. Unreferenced files are stamped `unreferenced_at` and deleted a day later; rows with NULL FKs skip the wait; files younger than a day are never touched, which covers the gap between an upload and the first content persist that mentions it. +- **`internal/document/file/manager`** ticks every 5 minutes: it trims expired history entries, then reclaims files. A file is referenced if its id appears in any branch content of its document, any retained `document_branch_history_entries` row, or any comment/reply — checked in SQL (`CheckDocumentFileReferenced`), so a spurious match only ever retains. Unreferenced files are stamped `unreferenced_at` and deleted a day later; rows with NULL FKs skip the wait; files younger than a day are never touched, which covers the gap between an upload and the first content persist that mentions it. - **`internal/document/hook/manager`** does the same for hooks: a row whose branch, document or organization went NULL gets `Hook.Delete` (external teardown) before the row is dropped. - **Organization deletion is announced, not discovered.** Better Auth owns the organizations table, so core cannot notice a deletion on its own. `beforeDeleteOrganization` (`auth-realtime/src/auth.ts`) calls `POST /api/x/organizations/{id}/teardown` while every row still exists: core tears down the org's hooks, queues the search removal, drops the `slack_apps` and `github_installations` rows, and deletes the logo object. The hook throws if core fails, so the organization survives instead of leaving orphans nothing can address. Document files still need no action — the cascade nulls their keys and the file manager reclaims them. - **Ordering is inverted between create and delete.** Creating writes the row before the object (upload, duplication); deleting removes the object before the row. Hooks follow the same rule from outside the transaction: `copyHooksToBranch` runs after the fork or merge commits, because `hook.NewHook` creates the watcher as a side effect and a rollback cannot take that back; a failed insert tears the just-created watcher down again. Both leave at most a row without an object, which the sweep resolves — an object without a row is invisible to every cleanup path. - **Duplication copies, never shares.** `Document.Duplicate` regenerates every block uid, rewrites image `src` paths to the new document (swapping only the path, never the host), and returns the old→new id map the handler uses to copy each object server-side into the duplicate's own folder. Files are therefore owned by exactly one document. - **Search jobs apply in order, per document.** A diff is a delta against the state its predecessors left, so the manager holds back every later diff of a document whose earlier one failed, rather than replaying it on top of newer work; an organization removal is global and holds everything after it. Without this a failed add replayed after a delete re-inserts the blocks of a document that no longer exists, and no later job would ever remove them. - **Search index entries are removed from the ids the delete reports.** Only default-branch blocks are indexed, keyed by block uid with a `documentId` field. `agent.DeleteDocument` collects the subtree ids before the row goes away and returns them, and the deletion paths queue a search job carrying them through `search.Jobs` in the same transaction, so the manager clears the index by `documentId` filter — the descendants the cascade destroys have no other trace to clean up by. -- Snapshots pin the files they reference, so `DB_MAX_DOCUMENT_CHANGELOGS` (count, trimmed on insert) and `DB_DOCUMENT_CHANGELOG_RETENTION` (age, trimmed in the sweep) also decide how long a removed image survives. +- History entries pin the files they reference, so `DB_MAX_DOCUMENT_HISTORY_ENTRIES` (count, trimmed on insert) and `DB_DOCUMENT_HISTORY_RETENTION` (age, trimmed in the sweep) also decide how long a removed image survives. The ProseMirror schema is defined in `server/auth-realtime/src/schema/` (one file per block kind, `index.ts` aggregates). The Go-side mirror is `server/core/internal/document/node.go` (`RootBlock`, `Block`, `Mark`). ## Database -Postgres (image `postgres:18.6-alpine`). Migrations are embedded in the core binary from `server/core/internal/db/migrations/` and applied automatically by `db.New` on startup via `rubenv/sql-migrate`. Add new migrations as the next-numbered `NNN_.sql` file. +Postgres (image `postgres:18.6-alpine`). Migrations are embedded in the core binary from `server/core/internal/db/migrations/` and applied automatically by `db.New` on startup via `rubenv/sql-migrate`. Add new migrations as the next-numbered `NNN_.sql` file — **once the product ships**. Until the first release there is only `001_initial.sql` and no deployed database to migrate, so a schema change goes into it in place rather than arriving as a rename-the-table follow-up nobody will ever need. The cost is local: an existing dev volume keeps the old schema, because sql-migrate records `001` as applied and will not re-run it, so reset the dev stack (`docker compose -p oxynote -f docker/docker-compose.dev.yaml down -v`) after editing it. The e2e stacks carry no volumes and are unaffected. **The core migrations own all tables, including the Better Auth ones** (`users`, `user_accounts`, `organizations`, … are created by `001_initial.sql` with snake_case columns matching the `fields` mappings in `src/auth.ts`). The generated `better_auth_schema.sql` is **reference output only — never apply it directly**; regenerate it to diff what Better Auth expects after changing `src/auth.ts`: @@ -132,12 +132,12 @@ Env lives in `docker/env/`: committed `*.example.env` templates list **every** v background work is skipped. `GET /api/capabilities` reports what ended up enabled. - Keys: `GITHUB_APP_ID` (private key in `docker/github/`), `SLACK_CLIENT_ID`, `ASSISTANT_PROVIDER`, `MEILISEARCH_DSN`, `CHANGEDETECTION_API_URL`, `VALKEY_ADDRESS`, - `STORAGE_URL`, `EMAIL_SMTP_HOST`. `docker/env/*.example.env` lists every variable each + `S3_URL`, `EMAIL_SMTP_HOST`. `docker/env/*.example.env` lists every variable each service reads. - Two of these degrade rather than switch off, and both silently confine the deployment - to one instance. An empty `STORAGE_URL` keeps objects in a directory on the local + to one instance. An empty `S3_URL` keeps objects in a directory on the local filesystem (`internal/storage/fs`, the counterpart of `internal/storage/s3`) rooted at - `STORAGE_PATH` — that path has no default and an unset one is a boot error, so objects + `S3_LOCAL_PATH` — that path has no default and an unset one is a boot error, so objects never land somewhere the deployment did not choose. An empty `VALKEY_ADDRESS` holds the assistant's conversation history, checkpoints and pending confirmations in the core process (`pkg/memkit`), losing every conversation on restart. A model too weak diff --git a/server/auth-realtime/CLAUDE.md b/server/auth-realtime/CLAUDE.md index adddd187..d3134a8e 100644 --- a/server/auth-realtime/CLAUDE.md +++ b/server/auth-realtime/CLAUDE.md @@ -21,6 +21,9 @@ Package manager is **pnpm** (own workspace). pnpm install # install dependencies pnpm dev # tsx watch, sentry preloaded via --import pnpm build # tsc -p tsconfig.build.json -> dist/ +pnpm build:bundle # esbuild single-file bundle -> dist/bundle.mjs; + # what both docker images run (sentry folded in, + # optional native accelerators left external) pnpm start # run the built service pnpm check-lint # check-types + check-eslint + check-fmt + @@ -37,7 +40,7 @@ pnpm qa # check-lint + test; qa-fix = lint + test **`src/index.ts` is the only module with side effects.** It is the one place that reads the environment, opens a database pool, connects to Valkey (only -when `OXYNOTE_AUTH_REALTIME_VALKEY_URL` is set — without it better-auth is +when `OXYNOTE_AUTH_REALTIME_VALKEY_DSN` is set — without it better-auth is built with no secondary storage at all), or listens on a port. Everything else is a factory taking what it needs: @@ -45,6 +48,9 @@ else is a factory taking what it needs: src/ index.ts composition root: env -> wiring -> listen sentry.ts the sentry bootstrap, loaded before the app graph exists + bundle.ts the docker entry: sentry + index folded into one esbuild + artifact (build:bundle), so no --import preload and no + node_modules ship in the images env.ts zod-parsed, typed configuration core.ts the client for every call this service makes into core db.ts the Store: every query this service runs, behind one diff --git a/server/auth-realtime/Dockerfile.dev b/server/auth-realtime/Dockerfile.dev index 415e621d..af5e4963 100644 --- a/server/auth-realtime/Dockerfile.dev +++ b/server/auth-realtime/Dockerfile.dev @@ -12,9 +12,11 @@ WORKDIR /server COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json tsconfig.build.json ./ COPY src ./src +# check-types gates (esbuild does not look at types); build:bundle emits +# the single-file runtime artifact the production image ships too RUN pnpm install --frozen-lockfile && \ - pnpm run build && \ - pnpm prune --prod + pnpm run check-types && \ + pnpm run build:bundle FROM base AS runner WORKDIR /server @@ -22,12 +24,12 @@ WORKDIR /server RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 hono -COPY --from=builder --chown=hono:nodejs /server/node_modules /server/node_modules -COPY --from=builder --chown=hono:nodejs /server/dist /server/dist -COPY --from=builder --chown=hono:nodejs /server/package.json /server/package.json +# the bundle folds sentry in (no --import preload) and carries every +# dependency, so no node_modules are shipped. +COPY --from=builder --chown=hono:nodejs /server/dist/bundle.mjs /server/bundle.mjs ENV NODE_ENV=production USER hono EXPOSE 8081 -CMD ["node", "--import", "/server/dist/sentry.js", "/server/dist/index.js"] +CMD ["node", "/server/bundle.mjs"] diff --git a/server/auth-realtime/package.json b/server/auth-realtime/package.json index 5a8d4245..03604124 100644 --- a/server/auth-realtime/package.json +++ b/server/auth-realtime/package.json @@ -8,6 +8,7 @@ "scripts": { "dev": "tsx watch --import ./src/sentry.ts src/index.ts", "build": "tsc -p tsconfig.build.json", + "build:bundle": "esbuild src/bundle.ts --bundle --platform=node --format=esm --minify --external:pg-native --external:bufferutil --external:utf-8-validate \"--banner:js=import{createRequire as __cr}from'node:module';const require=__cr(import.meta.url);\" --outfile=dist/bundle.mjs", "start": "node --import ./dist/sentry.js dist/index.js", "check-types": "tsc --noEmit", "eslint": "eslint --fix --max-warnings 0 --cache --cache-location node_modules/.cache/eslint --cache-strategy content .", @@ -67,6 +68,7 @@ "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.1.11", "@vitest/eslint-plugin": "^1.6.27", + "esbuild": "^0.27.0", "eslint": "^10.9.1", "eslint-config-prettier": "^10.1.8", "knip": "^6.32.2", diff --git a/server/auth-realtime/pnpm-lock.yaml b/server/auth-realtime/pnpm-lock.yaml index 7cad4dc4..34c3a952 100644 --- a/server/auth-realtime/pnpm-lock.yaml +++ b/server/auth-realtime/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: '@vitest/eslint-plugin': specifier: ^1.6.27 version: 1.6.27(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.11) + esbuild: + specifier: ^0.27.0 + version: 0.27.7 eslint: specifier: ^10.9.1 version: 10.9.1(jiti@2.7.0) @@ -160,7 +163,7 @@ importers: version: 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages: @@ -320,156 +323,312 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} @@ -1580,6 +1739,11 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -2578,81 +2742,159 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.27.7': + optional: true + '@esbuild/aix-ppc64@0.28.2': optional: true + '@esbuild/android-arm64@0.27.7': + optional: true + '@esbuild/android-arm64@0.28.2': optional: true + '@esbuild/android-arm@0.27.7': + optional: true + '@esbuild/android-arm@0.28.2': optional: true + '@esbuild/android-x64@0.27.7': + optional: true + '@esbuild/android-x64@0.28.2': optional: true + '@esbuild/darwin-arm64@0.27.7': + optional: true + '@esbuild/darwin-arm64@0.28.2': optional: true + '@esbuild/darwin-x64@0.27.7': + optional: true + '@esbuild/darwin-x64@0.28.2': optional: true + '@esbuild/freebsd-arm64@0.27.7': + optional: true + '@esbuild/freebsd-arm64@0.28.2': optional: true + '@esbuild/freebsd-x64@0.27.7': + optional: true + '@esbuild/freebsd-x64@0.28.2': optional: true + '@esbuild/linux-arm64@0.27.7': + optional: true + '@esbuild/linux-arm64@0.28.2': optional: true + '@esbuild/linux-arm@0.27.7': + optional: true + '@esbuild/linux-arm@0.28.2': optional: true + '@esbuild/linux-ia32@0.27.7': + optional: true + '@esbuild/linux-ia32@0.28.2': optional: true + '@esbuild/linux-loong64@0.27.7': + optional: true + '@esbuild/linux-loong64@0.28.2': optional: true + '@esbuild/linux-mips64el@0.27.7': + optional: true + '@esbuild/linux-mips64el@0.28.2': optional: true + '@esbuild/linux-ppc64@0.27.7': + optional: true + '@esbuild/linux-ppc64@0.28.2': optional: true + '@esbuild/linux-riscv64@0.27.7': + optional: true + '@esbuild/linux-riscv64@0.28.2': optional: true + '@esbuild/linux-s390x@0.27.7': + optional: true + '@esbuild/linux-s390x@0.28.2': optional: true + '@esbuild/linux-x64@0.27.7': + optional: true + '@esbuild/linux-x64@0.28.2': optional: true + '@esbuild/netbsd-arm64@0.27.7': + optional: true + '@esbuild/netbsd-arm64@0.28.2': optional: true + '@esbuild/netbsd-x64@0.27.7': + optional: true + '@esbuild/netbsd-x64@0.28.2': optional: true + '@esbuild/openbsd-arm64@0.27.7': + optional: true + '@esbuild/openbsd-arm64@0.28.2': optional: true + '@esbuild/openbsd-x64@0.27.7': + optional: true + '@esbuild/openbsd-x64@0.28.2': optional: true + '@esbuild/openharmony-arm64@0.27.7': + optional: true + '@esbuild/openharmony-arm64@0.28.2': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + '@esbuild/sunos-x64@0.28.2': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + '@esbuild/win32-arm64@0.28.2': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + '@esbuild/win32-ia32@0.28.2': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + '@esbuild/win32-x64@0.28.2': optional: true @@ -3408,7 +3650,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/eslint-plugin@1.6.27(@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.11)': dependencies: @@ -3418,7 +3660,7 @@ snapshots: optionalDependencies: '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3) typescript: 6.0.3 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) transitivePeerDependencies: - supports-color @@ -3431,13 +3673,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -3531,7 +3773,7 @@ snapshots: zod: 4.4.3 optionalDependencies: pg: 8.23.0 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -3615,6 +3857,35 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -4352,7 +4623,7 @@ snapshots: uuid@14.0.2: {} - vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0): + vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -4361,16 +4632,16 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 - esbuild: 0.28.2 + esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.23.12 yaml: 2.9.0 - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -4387,7 +4658,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 diff --git a/server/auth-realtime/src/bundle.ts b/server/auth-realtime/src/bundle.ts new file mode 100644 index 00000000..6ddf65a7 --- /dev/null +++ b/server/auth-realtime/src/bundle.ts @@ -0,0 +1,7 @@ +// the bundle entry: sentry first, then the service, folded into one file +// by build:bundle — as a separate --import preload the bundled app would +// carry a second sentry copy whose init it never sees. Excluded from the +// tsc build (tsconfig.build.json), which would otherwise emit a stray +// compiled copy into dist. +import "./sentry.js" +import "./index.js" diff --git a/server/auth-realtime/src/db.test.ts b/server/auth-realtime/src/db.test.ts index 78b8d33a..43dc1808 100644 --- a/server/auth-realtime/src/db.test.ts +++ b/server/auth-realtime/src/db.test.ts @@ -1,6 +1,6 @@ import { describe, it, vi } from "vitest" import type { Kysely } from "kysely" -import { createStore, type Database } from "./db.js" +import { createDatabase, createStore, type Database } from "./db.js" // kysely builds a query by chaining, so the stub returns itself from every // builder call and answers at the terminal one. This is the only file that @@ -199,3 +199,15 @@ describe("createStore", () => { }) }) }) + +describe("createDatabase", () => { + it("closes the underlying connection pool", async ({ expect }) => { + // the pg pool is lazy and never dialled, so closing it is safe + // without a database. + const handle = createDatabase( + "postgresql://devuser:devpass@localhost:5432/devdb", + ) + + await expect(handle.close()).resolves.toBeUndefined() + }) +}) diff --git a/server/auth-realtime/src/db.ts b/server/auth-realtime/src/db.ts index 120b7cc4..520295ac 100644 --- a/server/auth-realtime/src/db.ts +++ b/server/auth-realtime/src/db.ts @@ -50,6 +50,7 @@ export interface Store { export interface DatabaseHandle { store: Store dialect: PostgresDialect + close(): Promise } export function createStore(db: Kysely): Store { @@ -109,8 +110,12 @@ export function createDatabase(dsn: string): DatabaseHandle { pool: new Pool({ connectionString: dsn }), }) + const db = new Kysely({ dialect }) + return { - store: createStore(new Kysely({ dialect })), + store: createStore(db), dialect, + // destroy ends the underlying pg pool through the dialect. + close: () => db.destroy(), } } diff --git a/server/auth-realtime/src/env.test.ts b/server/auth-realtime/src/env.test.ts index deef91c8..779cbb91 100644 --- a/server/auth-realtime/src/env.test.ts +++ b/server/auth-realtime/src/env.test.ts @@ -10,7 +10,7 @@ function completeEnv( OXYNOTE_AUTH_REALTIME_BACKEND_URL: "http://core:8080", OXYNOTE_AUTH_REALTIME_DB_DSN: "postgresql://devuser:devpass@postgres/devdb", - OXYNOTE_AUTH_REALTIME_VALKEY_URL: "redis://valkey:6379", + OXYNOTE_AUTH_REALTIME_VALKEY_DSN: "redis://valkey:6379", OXYNOTE_AUTH_REALTIME_BETTER_AUTH_BASE_URL: "http://localhost:8080/auth-realtime", OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SECRET: "sup3rs3cr3t", @@ -36,7 +36,7 @@ describe("loadEnv", () => { expect(env.databaseDSN).toBe( "postgresql://devuser:devpass@postgres/devdb", ) - expect(env.valkeyUrl).toBe("redis://valkey:6379") + expect(env.valkeyDsn).toBe("redis://valkey:6379") expect(env.betterAuthSecret).toBe("sup3rs3cr3t") expect(env.cookieDomain).toBe("localhost") expect(env.frontendUrl).toBe("http://localhost:8080") @@ -108,11 +108,66 @@ describe("loadEnv", () => { it("accepts an absent valkey URL", ({ expect }) => { const env = loadEnv( completeEnv({ - OXYNOTE_AUTH_REALTIME_VALKEY_URL: undefined, + OXYNOTE_AUTH_REALTIME_VALKEY_DSN: undefined, }), ) - expect(env.valkeyUrl).toBeUndefined() + expect(env.valkeyDsn).toBeUndefined() + }) + + describe("listen address", () => { + it("binds all interfaces on the default port when unset", ({ + expect, + }) => { + const env = loadEnv(completeEnv()) + + expect(env.listenHost).toBeUndefined() + expect(env.listenPort).toBe(8081) + }) + + it("splits a configured address into host and port", ({ + expect, + }) => { + const env = loadEnv( + completeEnv({ + OXYNOTE_AUTH_REALTIME_ADDRESS: + "127.0.0.1:8181", + }), + ) + + expect(env.listenHost).toBe("127.0.0.1") + expect(env.listenPort).toBe(8181) + }) + + it("keeps binding all interfaces when the host part is empty", ({ + expect, + }) => { + const env = loadEnv( + completeEnv({ + OXYNOTE_AUTH_REALTIME_ADDRESS: ":9090", + }), + ) + + expect(env.listenHost).toBeUndefined() + expect(env.listenPort).toBe(9090) + }) + + it.for([ + { + name: "an address without a port", + input: "127.0.0.1", + }, + { name: "a non-numeric port", input: "127.0.0.1:http" }, + ])("rejects $name", ({ input }, { expect }) => { + expect(() => + loadEnv( + completeEnv({ + OXYNOTE_AUTH_REALTIME_ADDRESS: + input, + }), + ), + ).toThrow("OXYNOTE_AUTH_REALTIME_ADDRESS") + }) }) describe("counters", () => { diff --git a/server/auth-realtime/src/env.ts b/server/auth-realtime/src/env.ts index 3d27b7ef..dd07f2be 100644 --- a/server/auth-realtime/src/env.ts +++ b/server/auth-realtime/src/env.ts @@ -15,7 +15,10 @@ export interface Env { databaseDSN: string // absent on a deployment running without valkey, where better-auth // keeps no secondary storage at all. - valkeyUrl: string | undefined + valkeyDsn: string | undefined + // the interface the server binds to; undefined binds all interfaces. + listenHost: string | undefined + listenPort: number // the public base URL carries the reverse proxy's /auth-realtime // prefix; authOrigin is the bare origin the proxy forwards to. publicAuthBaseUrl: string @@ -58,6 +61,34 @@ function counter(fallback: number) { .pipe(z.number().int().positive()) } +// an unset address falls back to all interfaces on the default port; a +// present one must be host:port (the host part may be empty), so a typo is +// a boot error instead of a server listening on the wrong interface. +function listenAddress(fallbackPort: number) { + return z + .string() + .regex(/^[^:]*:\d+$/, "must be host:port") + .optional() + .transform((value): { host?: string; port: number } => { + if (value === undefined) { + return { host: undefined, port: fallbackPort } + } + + const colon = value.lastIndexOf(":") + + return { + host: value.slice(0, colon) || undefined, + port: Number(value.slice(colon + 1)), + } + }) + .pipe( + z.object({ + host: z.string().optional(), + port: z.number().int().positive(), + }), + ) +} + // only the two literals are accepted: a flag spelled "1" or "yes" would // otherwise read as false and silently leave the setting at its default. function flag(fallback: boolean) { @@ -99,7 +130,8 @@ const schema = z .object({ OXYNOTE_AUTH_REALTIME_BACKEND_URL: httpUrl(), OXYNOTE_AUTH_REALTIME_DB_DSN: z.string().min(1), - OXYNOTE_AUTH_REALTIME_VALKEY_URL: z.string().min(1).optional(), + OXYNOTE_AUTH_REALTIME_VALKEY_DSN: z.string().min(1).optional(), + OXYNOTE_AUTH_REALTIME_ADDRESS: listenAddress(8081), OXYNOTE_AUTH_REALTIME_BETTER_AUTH_BASE_URL: httpUrl(), OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SECRET: z.string().min(1), OXYNOTE_AUTH_REALTIME_BETTER_AUTH_COOKIE_DOMAIN: z @@ -214,7 +246,9 @@ export function loadEnv(source: Record): Env { return { coreUrl: values.OXYNOTE_AUTH_REALTIME_BACKEND_URL, databaseDSN: values.OXYNOTE_AUTH_REALTIME_DB_DSN, - valkeyUrl: values.OXYNOTE_AUTH_REALTIME_VALKEY_URL, + valkeyDsn: values.OXYNOTE_AUTH_REALTIME_VALKEY_DSN, + listenHost: values.OXYNOTE_AUTH_REALTIME_ADDRESS.host, + listenPort: values.OXYNOTE_AUTH_REALTIME_ADDRESS.port, publicAuthBaseUrl, authOrigin, betterAuthSecret: diff --git a/server/auth-realtime/src/index.ts b/server/auth-realtime/src/index.ts index 5759a181..0238b772 100644 --- a/server/auth-realtime/src/index.ts +++ b/server/auth-realtime/src/index.ts @@ -8,6 +8,7 @@ import { createCoreClient } from "./core.js" import { createAuth } from "./auth.js" import { createHocuspocus } from "./hocuspocus.js" import { createRoutes } from "./routes.js" +import { bestEffort } from "./reporting.js" // the composition root: the only module that reads the environment, opens // connections, or listens. Everything below it is a factory taking what it @@ -15,7 +16,8 @@ import { createRoutes } from "./routes.js" // database, a redis, or a port. const env = loadEnv(process.env) -const { store, dialect } = createDatabase(env.databaseDSN) +const database = createDatabase(env.databaseDSN) +const { store, dialect } = database const core = createCoreClient(env.coreUrl) // valkey is optional: without it better-auth keeps no secondary storage, @@ -23,10 +25,12 @@ const core = createCoreClient(env.coreUrl) // // RESP2: node-redis 6 defaults to RESP3, whose reply shapes better-auth's // secondary storage does not read correctly -let redis +// the type is spelled out because the shutdown handler reads it from a +// closure, where an inferred evolving any does not survive. +let redis: ReturnType | undefined -if (env.valkeyUrl) { - redis = createClient({ url: env.valkeyUrl, RESP: 2 }) +if (env.valkeyDsn) { + redis = createClient({ url: env.valkeyDsn, RESP: 2 }) await redis.connect() } @@ -97,7 +101,8 @@ app.get( const server = serve( { fetch: app.fetch, - port: 8081, + hostname: env.listenHost, + port: env.listenPort, }, (info) => { void hocuspocus.hooks("onListen", { @@ -113,19 +118,59 @@ const server = serve( ) nodeWs.injectWebSocket(server) -// graceful shutdown -process.on("SIGINT", () => { - server.close() - process.exit(0) -}) +// how long a graceful shutdown may take before the process force-exits. +const shutdownDeadlineMs = 15_000 -process.on("SIGTERM", () => { - server.close((err) => { - if (err) { - console.error(err) - process.exit(1) +// graceful shutdown: stop accepting HTTP, then drain hocuspocus before +// exiting — its onStoreDocument persists are debounced for up to ten +// seconds, so exiting on the signal alone would drop that window of edits. +async function shutdown() { + const deadline = setTimeout(() => { + console.error("shutdown deadline exceeded") + process.exit(1) + }, shutdownDeadlineMs) + deadline.unref() + + const httpClosed = new Promise((resolve) => { + server.close(() => { + resolve() + }) + }) + + // the drain hocuspocus's own standalone server performs in destroy(): + // a document unloads only after its pending store completed, so once + // every document is gone the debounced persists have run. The + // extension is registered before connections close so no unload can + // slip past it. + await new Promise((resolve) => { + if (hocuspocus.getDocumentsCount() === 0) { + resolve() + return } - process.exit(0) + hocuspocus.configuration.extensions.push({ + afterUnloadDocument({ instance }) { + if (instance.getDocumentsCount() === 0) { + resolve() + } + + return Promise.resolve() + }, + }) + + hocuspocus.closeConnections() + hocuspocus.flushPendingStores() }) -}) + + await hocuspocus.hooks("onDestroy", { instance: hocuspocus }) + await httpClosed + + // redis is absent on a deployment running without valkey. + await bestEffort(() => redis?.close()) + await bestEffort(() => database.close()) + + process.exit(0) +} + +process.once("SIGINT", () => void shutdown()) +process.once("SIGTERM", () => void shutdown()) diff --git a/server/auth-realtime/src/sentry.ts b/server/auth-realtime/src/sentry.ts index ff5a3d92..d6c9b4ee 100644 --- a/server/auth-realtime/src/sentry.ts +++ b/server/auth-realtime/src/sentry.ts @@ -4,8 +4,10 @@ if (process.env.SENTRY_DSN) { Sentry.init({ dsn: process.env.SENTRY_DSN, environment: process.env.NODE_ENV ?? "development", - tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE - ? parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE) - : 0, + // tracing stays off, deliberately and unconfigurably: the + // production bundle folds sentry into the app, which loses the + // require hooks tracing relies on, and error capture is the + // only thing this service uses. + tracesSampleRate: 0, }) } diff --git a/server/auth-realtime/src/test-helpers.ts b/server/auth-realtime/src/test-helpers.ts index cf8dc38a..d2b18194 100644 --- a/server/auth-realtime/src/test-helpers.ts +++ b/server/auth-realtime/src/test-helpers.ts @@ -64,7 +64,9 @@ export function testEnv(overrides: Partial = {}): Env { return { coreUrl: "http://core:8080", databaseDSN: "postgresql://devuser:devpass@postgres/devdb", - valkeyUrl: "redis://valkey:6379", + valkeyDsn: "redis://valkey:6379", + listenHost: undefined, + listenPort: 8081, publicAuthBaseUrl: "http://localhost:8080/auth-realtime", authOrigin: "http://localhost:8080", betterAuthSecret: "sup3rs3cr3t", diff --git a/server/auth-realtime/tsconfig.build.json b/server/auth-realtime/tsconfig.build.json index f5e52fb4..c09c2a48 100644 --- a/server/auth-realtime/tsconfig.build.json +++ b/server/auth-realtime/tsconfig.build.json @@ -5,6 +5,7 @@ "dist", "src/**/*.test.ts", "src/**/test-helpers.ts", - "src/test-setup.ts" + "src/test-setup.ts", + "src/bundle.ts" ] } diff --git a/server/auth-realtime/vitest.config.ts b/server/auth-realtime/vitest.config.ts index 99f2b6f2..4c5b4130 100644 --- a/server/auth-realtime/vitest.config.ts +++ b/server/auth-realtime/vitest.config.ts @@ -36,6 +36,9 @@ export default defineConfig({ // loaded through node's --import before the app // graph exists, so it can hold no imports of ours "src/sentry.ts", + // the bundle entry: evaluating its two ordered + // imports is starting the service, like index.ts + "src/bundle.ts", "src/**/test-helpers.ts", "src/test-setup.ts", ], diff --git a/server/core/.goreleaser.yml b/server/core/.goreleaser.yml index 3f2400b6..39efbdeb 100644 --- a/server/core/.goreleaser.yml +++ b/server/core/.goreleaser.yml @@ -2,7 +2,10 @@ version: 2 project_name: oxynote dist: bin snapshot: - version_template: dev + # the tag a snapshot image carries. Defaults to dev for the dev stack; + # the e2e stack overrides it so a run cannot clobber the image the dev + # stack is using. Set through core's Makefile IMAGE_TAG. + version_template: '{{ envOrDefault "CORE_SNAPSHOT_TAG" "dev" }}' builds: - id: core main: ./cmd/core diff --git a/server/core/Makefile b/server/core/Makefile index be6cc36d..eb877c6e 100644 --- a/server/core/Makefile +++ b/server/core/Makefile @@ -2,13 +2,18 @@ # ratcheted up as suites grow — never lowered. COVERAGE_MIN = 93.1 +# the tag the snapshot image carries; goreleaser reads it as the snapshot +# version. The e2e stack passes e2e-dev so its run cannot clobber the image +# the dev stack is using. +IMAGE_TAG ?= dev + # Build the Docker image # dockers_v2 snapshot builds load into the local daemon with an arch suffix; -# retag so the dev compose stack can reference a stable :dev tag. +# retag so the compose stacks can reference a stable tag. .PHONY: build build: - goreleaser release --snapshot --clean - docker tag ghcr.io/oxynote/core:dev-amd64 ghcr.io/oxynote/core:dev + CORE_SNAPSHOT_TAG=$(IMAGE_TAG) goreleaser release --snapshot --clean + docker tag ghcr.io/oxynote/core:$(IMAGE_TAG)-amd64 ghcr.io/oxynote/core:$(IMAGE_TAG) # Run all tests with the race detector. .PHONY: test diff --git a/server/core/cmd/core/main.go b/server/core/cmd/core/main.go index 1ffe0fee..8680cdb9 100644 --- a/server/core/cmd/core/main.go +++ b/server/core/cmd/core/main.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" "os" "os/signal" @@ -63,13 +64,13 @@ const ( // one turn when the environment says nothing. _defaultAssistantMaxTokens = 64000 - // _defaultMaxDocumentChangelogs specifies how many changelog snapshots + // _defaultMaxDocumentHistoryEntries specifies how many history entries // are retained per document branch when the environment says nothing. - _defaultMaxDocumentChangelogs = 100 + _defaultMaxDocumentHistoryEntries = 100 - // _defaultDocumentChangelogRetention specifies how long changelog - // snapshots are retained when the environment says nothing. - _defaultDocumentChangelogRetention = time.Hour * 24 * 90 + // _defaultDocumentHistoryRetention specifies how long history entries + // are retained when the environment says nothing. + _defaultDocumentHistoryRetention = time.Hour * 24 * 90 // _editClientTimeout bounds one edit-operations request to the Node // service. @@ -100,15 +101,15 @@ func main() { //nolint:maintidx // main performs linear wiring of all components metrics, cleanup := newMetrics(log) defer cleanup() - maxDocumentChangelogs, err := parseUintEnv("DB_MAX_DOCUMENT_CHANGELOGS", _defaultMaxDocumentChangelogs) + maxDocumentHistoryEntries, err := parseUintEnv("DB_MAX_DOCUMENT_HISTORY_ENTRIES", _defaultMaxDocumentHistoryEntries) if err != nil { fail(log, closers, "cannot read the configuration", err) return } - documentChangelogRetention, err := parseDurationEnv( - "DB_DOCUMENT_CHANGELOG_RETENTION", - _defaultDocumentChangelogRetention, + documentHistoryRetention, err := parseDurationEnv( + "DB_DOCUMENT_HISTORY_RETENTION", + _defaultDocumentHistoryRetention, ) if err != nil { fail(log, closers, "cannot read the configuration", err) @@ -119,8 +120,8 @@ func main() { //nolint:maintidx // main performs linear wiring of all components DSN: buildinfo.Getenv("DB_DSN"), MaxNotifications: _maxNotifications, MaxSlackMessages: _maxSlackMessages, - MaxDocumentChangelogs: maxDocumentChangelogs, - DocumentChangelogRetention: documentChangelogRetention, + MaxDocumentHistoryEntries: maxDocumentHistoryEntries, + DocumentHistoryRetention: documentHistoryRetention, DataSourceCredentialsSigningSecret: buildinfo.Getenv("DB_DATA_SOURCE_CREDENTIALS_SIGNING_SECRET"), }) if err != nil { @@ -130,16 +131,13 @@ func main() { //nolint:maintidx // main performs linear wiring of all components closers = append([]io.Closer{dbc}, closers...) - // an empty VALKEY_ADDRESS is a deployment running without valkey at + // an empty VALKEY_DSN is a deployment running without valkey at // all. The nil pool is what tells the assistant to keep its // conversations in this process instead. var rdb *redis.Pool - if address := buildinfo.Getenv("VALKEY_ADDRESS"); address != "" { - rdb, err = redisutil.NewPool( - buildinfo.Getenv("VALKEY_NETWORK"), - address, - ) + if dsn := buildinfo.Getenv("VALKEY_DSN"); dsn != "" { + rdb, err = redisutil.NewPool(dsn) if err != nil { fail(log, closers, "cannot create a redis pool", err) return @@ -205,14 +203,14 @@ func main() { //nolint:maintidx // main performs linear wiring of all components termCtx, termCancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer termCancel() - // an empty MEILISEARCH_DSN means search is disabled: nothing is - // indexed and the search surfaces refuse. A set DSN that cannot be + // an empty MEILISEARCH_URL means search is disabled: nothing is + // indexed and the search surfaces refuse. A set URL that cannot be // reached stays a boot error inside NewClient. var meiliMan meilisearch.ServiceManager - if dsn := buildinfo.Getenv("MEILISEARCH_DSN"); dsn != "" { + if url := buildinfo.Getenv("MEILISEARCH_URL"); url != "" { meiliMan = meilisearch.New( - dsn, + url, meilisearch.WithAPIKey(buildinfo.Getenv("MEILISEARCH_MASTER_KEY")), ) } @@ -225,23 +223,23 @@ func main() { //nolint:maintidx // main performs linear wiring of all components searchJobs := search.NewJobs(searchClient.Configured()) - storageURL := buildinfo.Getenv("STORAGE_URL") + objectStorageURL := buildinfo.Getenv("OBJECT_STORAGE_URL") var storageClient storage.Store - if storageURL != "" { + if objectStorageURL != "" { storageClient, err = storageS3.NewClient( termCtx, storageS3.Options{ - URL: storageURL, - Region: buildinfo.Getenv("STORAGE_REGION"), - AccessKey: buildinfo.Getenv("STORAGE_ACCESS_KEY"), - SecretKey: buildinfo.Getenv("STORAGE_SECRET_KEY"), - Bucket: buildinfo.Getenv("STORAGE_BUCKET"), + URL: objectStorageURL, + Region: buildinfo.Getenv("OBJECT_STORAGE_REGION"), + AccessKey: buildinfo.Getenv("OBJECT_STORAGE_ACCESS_KEY"), + SecretKey: buildinfo.Getenv("OBJECT_STORAGE_SECRET_KEY"), + Bucket: buildinfo.Getenv("OBJECT_STORAGE_BUCKET"), }, ) } else { - storageClient, err = storageFS.NewClient(buildinfo.Getenv("STORAGE_PATH")) + storageClient, err = storageFS.NewClient(buildinfo.Getenv("OBJECT_STORAGE_LOCAL_PATH")) } if err != nil { @@ -306,11 +304,18 @@ func main() { //nolint:maintidx // main performs linear wiring of all components warnDisabled(log, searchClient.Configured(), "search is disabled") warnDisabled(log, webchangeClient.Configured(), "changedetection integration is disabled") + serverHost, serverPort, err := parseServerAddress(buildinfo.Getenv("SERVER_ADDRESS")) + if err != nil { + fail(log, closers, "cannot read the configuration", err) + return + } + srv, err := server.NewServer( log, server.Options{ PublicURL: buildinfo.Getenv("SERVER_PUBLIC_URL"), - Port: _serverPort, + Host: serverHost, + Port: serverPort, Origins: origins, Assistant: server.AssistantCapability{ Status: assistantStatus, @@ -345,7 +350,7 @@ func main() { //nolint:maintidx // main performs linear wiring of all components hooksMan := hookMan.NewManager(log, dbc, githubMan, webchangeClient, notifMan) filesMan := fileMan.NewManager(log, dbc, storageClient, fileMan.Options{ - ChangelogRetention: documentChangelogRetention, + HistoryRetention: documentHistoryRetention, }) closers = append([]io.Closer{srv}, closers...) @@ -425,11 +430,8 @@ func newLogger() (*slog.Logger, func(), bool) { Level: level, })).With("version", buildinfo.Full().Version) + // an empty DSN disables sentry; sentryutil.Setup tolerates it. dsn := buildinfo.Getenv("SENTRY_DSN") - if dsn == "" && !buildinfo.IsDevEnv() { - log.Error("invalid sentry dsn") - return nil, nil, false - } fn, err := sentryutil.Setup(sentryutil.Config{ DSN: dsn, @@ -507,6 +509,27 @@ func parseDurationEnv(name string, def time.Duration) (time.Duration, error) { return res, nil } +// parseServerAddress splits a listen address into its host and port parts, +// falling back to all interfaces on the default port when the value is +// empty. An empty host part binds all interfaces. +func parseServerAddress(v string) (string, uint, error) { + if v == "" { + return "", _serverPort, nil + } + + host, rawPort, err := net.SplitHostPort(v) + if err != nil { + return "", 0, fmt.Errorf("invalid SERVER_ADDRESS: %w", err) + } + + port, err := strconv.ParseUint(rawPort, 10, 16) + if err != nil { + return "", 0, fmt.Errorf("invalid SERVER_ADDRESS: %w", err) + } + + return host, uint(port), nil +} + // warnDisabled announces a disabled integration at boot. func warnDisabled(log *slog.Logger, configured bool, msg string) { if configured { diff --git a/server/core/cmd/core/main_test.go b/server/core/cmd/core/main_test.go index 74fb8465..a55f7523 100644 --- a/server/core/cmd/core/main_test.go +++ b/server/core/cmd/core/main_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/oxynote/oxynote/server/core/pkg/ioutil" + "github.com/oxynote/oxynote/server/core/pkg/testutil" "github.com/stretchr/testify/assert" "go.uber.org/goleak" ) @@ -47,6 +48,54 @@ func Test_parseOrigins(t *testing.T) { } } +func Test_parseServerAddress(t *testing.T) { + t.Parallel() + + cc := map[string]struct { + Val string + Host string + Port uint + Err error + }{ + "Unset value binds all interfaces on the default port": { + Port: 8080, + }, + "Loopback address": { + Val: "127.0.0.1:8180", + Host: "127.0.0.1", + Port: 8180, + }, + "Port without a host": { + Val: ":9090", + Port: 9090, + }, + "Missing port": { + Val: "127.0.0.1", + Err: assert.AnError, + }, + "Non-numeric port": { + Val: "127.0.0.1:http", + Err: assert.AnError, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + host, port, err := parseServerAddress(c.Val) + testutil.AssertEqualError(t, c.Err, err) + + if err != nil { + return + } + + assert.Equal(t, c.Host, host) + assert.Equal(t, c.Port, port) + }) + } +} + func Test_fail(t *testing.T) { cc := map[string]struct { CloseErr error diff --git a/server/core/internal/db/db.go b/server/core/internal/db/db.go index b7526853..0dbad02b 100644 --- a/server/core/internal/db/db.go +++ b/server/core/internal/db/db.go @@ -86,10 +86,10 @@ type Options struct { // Zero means no limit. MaxNotifications uint64 - // MaxDocumentChangelogs specifies the maximum number of changelog - // snapshots to keep per document branch. + // MaxDocumentHistoryEntries specifies the maximum number of history + // entries to keep per document branch. // Zero means no limit. - MaxDocumentChangelogs uint64 + MaxDocumentHistoryEntries uint64 // MaxSlackMessages specifies the maximum number of Slack messages to // keep per organization. It bounds the listing as well, which can @@ -97,10 +97,10 @@ type Options struct { // Zero means no limit. MaxSlackMessages uint64 - // DocumentChangelogRetention specifies how long changelog snapshots - // are kept before the file manager trims them. + // DocumentHistoryRetention specifies how long history entries are + // kept before the file manager trims them. // Zero means no age limit. - DocumentChangelogRetention time.Duration + DocumentHistoryRetention time.Duration // DataSourceCredentialsSigningSecret is the secret used to sign // data source credentials. diff --git a/server/core/internal/db/document.go b/server/core/internal/db/document.go index 2431fcf6..372738cf 100644 --- a/server/core/internal/db/document.go +++ b/server/core/internal/db/document.go @@ -256,7 +256,7 @@ func (a *agent) UpdateDocument(ctx context.Context, doc document.Document) error return err } - return a.insertDocumentBranchChangelog(ctx, tx, doc.ID, doc.BranchID, doc.Changelog()) + return a.insertDocumentBranchHistoryEntry(ctx, tx, doc.ID, doc.BranchID, doc.HistoryEntry()) }) } diff --git a/server/core/internal/db/document_branch.go b/server/core/internal/db/document_branch.go index 71a891c7..a4d5ced5 100644 --- a/server/core/internal/db/document_branch.go +++ b/server/core/internal/db/document_branch.go @@ -187,7 +187,7 @@ func (a *agent) DeleteDocumentBranchByID(ctx context.Context, branchID xid.ID, o } // UpdateDocumentBranchMetadata updates the name and protection status of a branch. -// It does not modify content or insert a changelog entry. +// It does not modify content or insert a history entry. func (a *agent) UpdateDocumentBranchMetadata(ctx context.Context, doc document.Document) error { q, args := a.builder.Update("document_branches"). SetMap(map[string]any{ @@ -305,23 +305,23 @@ func (a *agent) selectBranchSummary(b sq.SelectBuilder) sq.SelectBuilder { ).From("document_branches") } -// insertDocumentBranchChangelog inserts a changelog entry for a branch update +// insertDocumentBranchHistoryEntry inserts a history entry for a branch update // through the given executor. The entry is keyed by branch id rather than // branch name so that renaming a branch cannot break the reference. -func (a *agent) insertDocumentBranchChangelog( +func (a *agent) insertDocumentBranchHistoryEntry( ctx context.Context, ex sqlx.ExecerContext, docID, branchID xid.ID, - clog document.Changelog, + entry document.HistoryEntry, ) error { - q, args := a.builder.Insert("document_branch_changelogs"). + q, args := a.builder.Insert("document_branch_history_entries"). SetMap(map[string]any{ - "id": clog.ID, + "id": entry.ID, "fk_document_id": docID, "fk_branch_id": branchID, - "content": clog.Content, - "raw_content": clog.RawContent, - "created_at": clog.CreatedAt, + "content": entry.Content, + "raw_content": entry.RawContent, + "created_at": entry.CreatedAt, }). Suffix("ON CONFLICT (id) DO UPDATE SET " + "content = excluded.content, raw_content = excluded.raw_content, created_at = excluded.created_at"). @@ -331,29 +331,29 @@ func (a *agent) insertDocumentBranchChangelog( return err } - return a.trimDocumentBranchChangelogs(ctx, ex, branchID) + return a.trimDocumentBranchHistoryEntries(ctx, ex, branchID) } -// trimDocumentBranchChangelogs keeps only the newest snapshots of a branch. +// trimDocumentBranchHistoryEntries keeps only the newest entries of a branch. // Age trimming lives in the file manager instead, since it has to reach // branches that stopped inserting altogether. -func (a *agent) trimDocumentBranchChangelogs(ctx context.Context, ex sqlx.ExecerContext, branchID xid.ID) error { +func (a *agent) trimDocumentBranchHistoryEntries(ctx context.Context, ex sqlx.ExecerContext, branchID xid.ID) error { // a zero limit means unlimited retention; without this guard the // subquery below would emit LIMIT 0 and the delete would drop every // snapshot of the branch. - if a.opts.MaxDocumentChangelogs == 0 { + if a.opts.MaxDocumentHistoryEntries == 0 { return nil } b := a.builder.Select("id"). - From("document_branch_changelogs"). + From("document_branch_history_entries"). Where(sq.Eq{"fk_branch_id": branchID}). OrderBy("created_at DESC"). - Limit(a.opts.MaxDocumentChangelogs). + Limit(a.opts.MaxDocumentHistoryEntries). Prefix("id NOT IN ("). Suffix(")") - q, args := a.builder.Delete("document_branch_changelogs").Where(sq.And{ + q, args := a.builder.Delete("document_branch_history_entries").Where(sq.And{ b, sq.Eq{"fk_branch_id": branchID}, }).MustSql() diff --git a/server/core/internal/db/document_branch_test.go b/server/core/internal/db/document_branch_test.go index 83e015cf..12a6b0b4 100644 --- a/server/core/internal/db/document_branch_test.go +++ b/server/core/internal/db/document_branch_test.go @@ -524,7 +524,7 @@ func Test_agent_UpdateDocumentBranchMetadata(t *testing.T) { "Successful update of an edited branch": func(t *testing.T, db *DB) tcase { branch := prepDocumentBranches(t, db, 1, nil)[0] - // an edit leaves a changelog entry behind, which used to pin + // an edit leaves a history entry behind, which used to pin // the branch name in place. require.NoError(t, db.UpdateDocument(context.Background(), *branch)) @@ -645,38 +645,38 @@ func Test_agent_FetchDocumentUnsafeByBranchID(t *testing.T) { testutil.AssertFilterEqual(t, branch, res) } -// prepChangelogs writes count snapshots of the document's branch, one per +// prepHistoryEntries writes count entries of the document's branch, one per // aggregation bucket so each lands as its own entry, oldest first. -func prepChangelogs(t *testing.T, db *DB, doc *document.Document, count int) []document.Changelog { +func prepHistoryEntries(t *testing.T, db *DB, doc *document.Document, count int) []document.HistoryEntry { t.Helper() - res := make([]document.Changelog, count) + res := make([]document.HistoryEntry, count) base := timeutil.Now().Truncate(time.Second) for i := range count { doc.UpdatedAt = base.Add(-time.Duration(count-i) * time.Hour) - clog := doc.Changelog() + entry := doc.HistoryEntry() - require.NoError(t, db.insertDocumentBranchChangelog( + require.NoError(t, db.insertDocumentBranchHistoryEntry( context.Background(), db.sql, doc.ID, doc.BranchID, - clog, + entry, )) - res[i] = clog + res[i] = entry } return res } -// countChangelogs returns how many snapshots the branch currently has. -func countChangelogs(t *testing.T, db *DB, branchID xid.ID) int { +// countHistoryEntries returns how many entries the branch currently has. +func countHistoryEntries(t *testing.T, db *DB, branchID xid.ID) int { t.Helper() q, args := db.builder.Select("COUNT(*)"). - From("document_branch_changelogs"). + From("document_branch_history_entries"). Where(sq.Eq{"fk_branch_id": branchID}). MustSql() @@ -687,16 +687,16 @@ func countChangelogs(t *testing.T, db *DB, branchID xid.ID) int { return count } -func Test_agent_trimDocumentBranchChangelogs(t *testing.T) { +func Test_agent_trimDocumentBranchHistoryEntries(t *testing.T) { cc := map[string]struct { Max uint64 Remaining int }{ - "Zero keeps every snapshot": { + "Zero keeps every entry": { Max: 0, Remaining: 4, }, - "Only the newest snapshots survive": { + "Only the newest entries survive": { Max: 2, Remaining: 2, }, @@ -713,14 +713,14 @@ func Test_agent_trimDocumentBranchChangelogs(t *testing.T) { db := prepTempDB(t) doc := prepDocuments(t, db, 1, nil)[0] - clogs := prepChangelogs(t, db, doc, 4) + entries := prepHistoryEntries(t, db, doc, 4) - db.opts.MaxDocumentChangelogs = c.Max + db.opts.MaxDocumentHistoryEntries = c.Max - require.NoError(t, db.trimDocumentBranchChangelogs(context.Background(), db.sql, doc.BranchID)) - assert.Equal(t, c.Remaining, countChangelogs(t, db, doc.BranchID)) + require.NoError(t, db.trimDocumentBranchHistoryEntries(context.Background(), db.sql, doc.BranchID)) + assert.Equal(t, c.Remaining, countHistoryEntries(t, db, doc.BranchID)) - if c.Max == 0 || c.Remaining == len(clogs) { + if c.Max == 0 || c.Remaining == len(entries) { return } @@ -728,102 +728,102 @@ func Test_agent_trimDocumentBranchChangelogs(t *testing.T) { var ids []string q, args := db.builder.Select("id"). - From("document_branch_changelogs"). + From("document_branch_history_entries"). Where(sq.Eq{"fk_branch_id": doc.BranchID}). MustSql() require.NoError(t, db.sql.Select(&ids, q, args...)) - assert.ElementsMatch(t, []string{clogs[2].ID, clogs[3].ID}, ids) + assert.ElementsMatch(t, []string{entries[2].ID, entries[3].ID}, ids) }) } } -func Test_agent_DeleteExpiredDocumentBranchChangelogs(t *testing.T) { +func Test_agent_DeleteExpiredDocumentBranchHistoryEntries(t *testing.T) { t.Parallel() db := prepTempDB(t) doc := prepDocuments(t, db, 1, nil)[0] - clogs := prepChangelogs(t, db, doc, 4) - require.Equal(t, 4, countChangelogs(t, db, doc.BranchID)) + entries := prepHistoryEntries(t, db, doc, 4) + require.Equal(t, 4, countHistoryEntries(t, db, doc.BranchID)) // nothing is old enough yet. - require.NoError(t, db.DeleteExpiredDocumentBranchChangelogs( + require.NoError(t, db.DeleteExpiredDocumentBranchHistoryEntries( context.Background(), - clogs[0].CreatedAt.Add(-time.Second), + entries[0].CreatedAt.Add(-time.Second), )) - assert.Equal(t, 4, countChangelogs(t, db, doc.BranchID)) + assert.Equal(t, 4, countHistoryEntries(t, db, doc.BranchID)) - // everything created before the newest snapshot goes. - require.NoError(t, db.DeleteExpiredDocumentBranchChangelogs( + // everything created before the newest entry goes. + require.NoError(t, db.DeleteExpiredDocumentBranchHistoryEntries( context.Background(), - clogs[3].CreatedAt, + entries[3].CreatedAt, )) - assert.Equal(t, 1, countChangelogs(t, db, doc.BranchID)) + assert.Equal(t, 1, countHistoryEntries(t, db, doc.BranchID)) } -func Test_agent_insertDocumentBranchChangelog(t *testing.T) { +func Test_agent_insertDocumentBranchHistoryEntry(t *testing.T) { type tcase struct { - DocumentID xid.ID - BranchID xid.ID - Changelog document.Changelog - Trimmed int - Err error + DocumentID xid.ID + BranchID xid.ID + HistoryEntry document.HistoryEntry + Trimmed int + Err error } cc := map[string]func(*testing.T, *DB) tcase{ "Non-existent branch": func(t *testing.T, db *DB) tcase { doc := prepDocuments(t, db, 1, nil)[0] - clog := doc.Changelog() + entry := doc.HistoryEntry() return tcase{ - DocumentID: doc.ID, - BranchID: xid.New(), - Changelog: clog, - Err: assert.AnError, + DocumentID: doc.ID, + BranchID: xid.New(), + HistoryEntry: entry, + Err: assert.AnError, } }, "Successful insert": func(t *testing.T, db *DB) tcase { doc := prepDocuments(t, db, 1, nil)[0] return tcase{ - DocumentID: doc.ID, - BranchID: doc.BranchID, - Changelog: doc.Changelog(), + DocumentID: doc.ID, + BranchID: doc.BranchID, + HistoryEntry: doc.HistoryEntry(), } }, - "Insert trims the branch down to the retained snapshots": func(t *testing.T, db *DB) tcase { + "Insert trims the branch down to the retained entries": func(t *testing.T, db *DB) tcase { doc := prepDocuments(t, db, 1, nil)[0] - prepChangelogs(t, db, doc, 3) + prepHistoryEntries(t, db, doc, 3) - db.opts.MaxDocumentChangelogs = 2 + db.opts.MaxDocumentHistoryEntries = 2 doc.UpdatedAt = timeutil.Now().Truncate(time.Second) return tcase{ - DocumentID: doc.ID, - BranchID: doc.BranchID, - Changelog: doc.Changelog(), - Trimmed: 2, + DocumentID: doc.ID, + BranchID: doc.BranchID, + HistoryEntry: doc.HistoryEntry(), + Trimmed: 2, } }, "Successful update of an existing entry": func(t *testing.T, db *DB) tcase { doc := prepDocuments(t, db, 1, nil)[0] - require.NoError(t, db.insertDocumentBranchChangelog( + require.NoError(t, db.insertDocumentBranchHistoryEntry( context.Background(), db.sql, doc.ID, doc.BranchID, - doc.Changelog(), + doc.HistoryEntry(), )) doc.RawContent = []byte("updated raw content") return tcase{ - DocumentID: doc.ID, - BranchID: doc.BranchID, - Changelog: doc.Changelog(), + DocumentID: doc.ID, + BranchID: doc.BranchID, + HistoryEntry: doc.HistoryEntry(), } }, } @@ -835,18 +835,18 @@ func Test_agent_insertDocumentBranchChangelog(t *testing.T) { db := prepTempDB(t) c := cfn(t, db) - err := db.insertDocumentBranchChangelog(context.Background(), db.sql, c.DocumentID, c.BranchID, c.Changelog) + err := db.insertDocumentBranchHistoryEntry(context.Background(), db.sql, c.DocumentID, c.BranchID, c.HistoryEntry) testutil.RequireEqualError(t, c.Err, err) if c.Trimmed != 0 { - assert.Equal(t, c.Trimmed, countChangelogs(t, db, c.BranchID)) + assert.Equal(t, c.Trimmed, countHistoryEntries(t, db, c.BranchID)) } if err != nil { return } - var clog document.Changelog + var entry document.HistoryEntry q, args := db.builder.Select( `id AS "id"`, @@ -854,14 +854,14 @@ func Test_agent_insertDocumentBranchChangelog(t *testing.T) { `content AS "content"`, `raw_content AS "raw_content"`, `created_at AS "created_at"`, - ).From("document_branch_changelogs"). + ).From("document_branch_history_entries"). Where(sq.Eq{ - "id": c.Changelog.ID, + "id": c.HistoryEntry.ID, }).MustSql() - err = db.sql.Get(&clog, q, args...) + err = db.sql.Get(&entry, q, args...) require.NoError(t, err) - testutil.AssertFilterEqual(t, c.Changelog, clog) + testutil.AssertFilterEqual(t, c.HistoryEntry, entry) }) } } diff --git a/server/core/internal/db/document_file.go b/server/core/internal/db/document_file.go index 1114a5d4..01202cdd 100644 --- a/server/core/internal/db/document_file.go +++ b/server/core/internal/db/document_file.go @@ -106,7 +106,7 @@ func (a *agent) DeleteDocumentFile(ctx context.Context, id string) error { } // CheckDocumentFileReferenced reports whether the file id still appears in -// the document's live branch content, in a retained changelog snapshot, or +// the document's live branch content, in a retained history entry, or // in a comment or comment reply. // // The id is matched as a substring of the serialized content rather than by @@ -119,7 +119,7 @@ func (a *agent) CheckDocumentFileReferenced(ctx context.Context, id string, docu SELECT 1 FROM document_branches WHERE fk_document_id = $1 AND position($2 in content::text) > 0 UNION ALL - SELECT 1 FROM document_branch_changelogs + SELECT 1 FROM document_branch_history_entries WHERE fk_document_id = $1 AND position($2 in content::text) > 0 UNION ALL SELECT 1 FROM document_comments @@ -139,12 +139,12 @@ func (a *agent) CheckDocumentFileReferenced(ctx context.Context, id string, docu return referenced, nil } -// DeleteExpiredDocumentBranchChangelogs removes changelog snapshots created +// DeleteExpiredDocumentBranchHistoryEntries removes history entries created // before the given time. Age trimming happens here rather than on insert -// because an idle branch never inserts again, and its snapshots would +// because an idle branch never inserts again, and its entries would // otherwise pin the files they reference forever. -func (a *agent) DeleteExpiredDocumentBranchChangelogs(ctx context.Context, before time.Time) error { - q, args := a.builder.Delete("document_branch_changelogs"). +func (a *agent) DeleteExpiredDocumentBranchHistoryEntries(ctx context.Context, before time.Time) error { + q, args := a.builder.Delete("document_branch_history_entries"). Where(sq.Lt{"created_at": before}). MustSql() diff --git a/server/core/internal/db/document_file_test.go b/server/core/internal/db/document_file_test.go index 98471ac7..a09fd613 100644 --- a/server/core/internal/db/document_file_test.go +++ b/server/core/internal/db/document_file_test.go @@ -208,16 +208,16 @@ func Test_agent_CheckDocumentFileReferenced(t *testing.T) { return "file-ref", doc.ID, true }, - "Referenced by a changelog snapshot": func(t *testing.T, db *DB) (string, xid.ID, bool) { + "Referenced by a history entry": func(t *testing.T, db *DB) (string, xid.ID, bool) { doc := prepDocuments(t, db, 1, nil)[0] doc.Content.Content[0].Attrs = document.Attributes{"uid": "file-ref"} - require.NoError(t, db.insertDocumentBranchChangelog( + require.NoError(t, db.insertDocumentBranchHistoryEntry( context.Background(), db.sql, doc.ID, doc.BranchID, - doc.Changelog(), + doc.HistoryEntry(), )) return "file-ref", doc.ID, true diff --git a/server/core/internal/db/document_test.go b/server/core/internal/db/document_test.go index 7adfff16..d64536b0 100644 --- a/server/core/internal/db/document_test.go +++ b/server/core/internal/db/document_test.go @@ -704,7 +704,7 @@ func Test_agent_UpdateDocument(t *testing.T) { assert.Equal(t, assert.AnError, err) }) - t.Run("Changelogs are kept per branch", func(t *testing.T) { + t.Run("HistoryEntries are kept per branch", func(t *testing.T) { t.Parallel() db := prepTempDB(t) @@ -722,7 +722,7 @@ func Test_agent_UpdateDocument(t *testing.T) { var logs uint64 - q, args := db.builder.Select("COUNT(*)").From("document_branch_changelogs"). + q, args := db.builder.Select("COUNT(*)").From("document_branch_history_entries"). Where(sq.Eq{ "fk_document_id": branches[0].ID, }).MustSql() @@ -779,7 +779,7 @@ func Test_agent_UpdateDocument(t *testing.T) { var logs uint64 - q, args = db.builder.Select("COUNT(*)").From("document_branch_changelogs"). + q, args = db.builder.Select("COUNT(*)").From("document_branch_history_entries"). Where(sq.Eq{ "fk_document_id": c.Document.ID, }).MustSql() diff --git a/server/core/internal/db/migrations/001_initial.sql b/server/core/internal/db/migrations/001_initial.sql index 4798cc18..847be83f 100644 --- a/server/core/internal/db/migrations/001_initial.sql +++ b/server/core/internal/db/migrations/001_initial.sql @@ -295,7 +295,7 @@ CREATE INDEX document_branches_fk_organization_id_idx ON document_branches (fk_o CREATE INDEX document_branches_fk_created_by_idx ON document_branches (fk_created_by); CREATE INDEX document_branches_fk_last_updated_by_idx ON document_branches (fk_last_updated_by); -CREATE TABLE document_branch_changelogs ( +CREATE TABLE document_branch_history_entries ( id TEXT PRIMARY KEY, fk_document_id TEXT NOT NULL REFERENCES documents ON DELETE CASCADE, fk_branch_id TEXT NOT NULL REFERENCES document_branches(id) ON DELETE CASCADE, @@ -303,8 +303,8 @@ CREATE TABLE document_branch_changelogs ( raw_content BYTEA NULL, created_at TIMESTAMP NOT NULL ); -CREATE INDEX document_branch_changelogs_fk_document_id_idx ON document_branch_changelogs (fk_document_id); -CREATE INDEX document_branch_changelogs_fk_branch_id_idx ON document_branch_changelogs (fk_branch_id); +CREATE INDEX document_branch_history_entries_fk_document_id_idx ON document_branch_history_entries (fk_document_id); +CREATE INDEX document_branch_history_entries_fk_branch_id_idx ON document_branch_history_entries (fk_branch_id); CREATE TABLE document_hooks ( id TEXT PRIMARY KEY, @@ -449,7 +449,7 @@ DROP TABLE document_maintainers; DROP TABLE document_comment_replies; DROP TABLE document_comments; DROP TABLE document_hooks; -DROP TABLE document_branch_changelogs; +DROP TABLE document_branch_history_entries; DROP TABLE document_branches; DROP TABLE documents; DROP TABLE data_sources; diff --git a/server/core/internal/document/document.go b/server/core/internal/document/document.go index db645965..03edf2ab 100644 --- a/server/core/internal/document/document.go +++ b/server/core/internal/document/document.go @@ -33,8 +33,9 @@ const ( // _aggregationDuration is the duration for which document changes are aggregated. _aggregationDuration = 30 * time.Minute - // _changelogTimeLayout is the layout used for timestamps in changelogs. - _changelogTimeLayout = "2006-01-02T15:04:05" + // _historyEntryTimeLayout is the layout used for timestamps in history + // entry IDs. + _historyEntryTimeLayout = "2006-01-02T15:04:05" ) // Branch holds branch-specific content and metadata for a document. @@ -221,7 +222,7 @@ type BranchSummary struct { } // ApplyBranchUpdate returns a new Document with only the name and protection -// status updated. It does not modify content or the changelog. +// status updated. It does not modify content or the history. func (d Document) ApplyBranchUpdate(name null.String, protected null.Bool, updatedBy string) Document { nd := d @@ -239,20 +240,20 @@ func (d Document) ApplyBranchUpdate(name null.String, protected null.Bool, updat return nd } -// Changelog returns a changelog entry for the current state of the document. +// HistoryEntry returns a history entry for the current state of the document. // Entries aggregate per branch and per time bucket: the branch is part of the // ID so that two branches of one document edited within the same bucket keep // separate entries instead of overwriting each other. -func (d Document) Changelog() Changelog { +func (d Document) HistoryEntry() HistoryEntry { id := fmt.Sprintf( "%s-%s-%s", d.ID, d.BranchID, d.UpdatedAt.Truncate(_aggregationDuration). - Format(_changelogTimeLayout), + Format(_historyEntryTimeLayout), ) - return Changelog{ + return HistoryEntry{ ID: id, DocumentID: d.ID, Content: d.Content, @@ -279,23 +280,23 @@ func (d Document) Search() map[string]search.Block { return res } -// Changelog represents a changelog entry for a document. -type Changelog struct { - // ID is the unique identifier for the changelog entry. +// HistoryEntry represents one entry in a document's history. +type HistoryEntry struct { + // ID is the unique identifier for the entry. ID string `json:"id" db:"id"` // DocumentID is the identifier for the document associated - // with this changelog entry. + // with this entry. DocumentID xid.ID `json:"documentId" db:"fk_document_id"` - // Content is the content of the document at the time of the changelog entry. + // Content is the content of the document at the time of the entry. Content RootBlock `json:"content" db:"content"` // RawContent is the raw content of the document, typically in // a text format. RawContent []byte `json:"rawContent" db:"raw_content"` - // CreatedAt is the timestamp when the changelog entry was created. + // CreatedAt is the timestamp when the entry was created. CreatedAt time.Time `json:"createdAt" db:"created_at"` } diff --git a/server/core/internal/document/document_test.go b/server/core/internal/document/document_test.go index ad0eaed9..c91bd9f4 100644 --- a/server/core/internal/document/document_test.go +++ b/server/core/internal/document/document_test.go @@ -234,27 +234,27 @@ func Test_Document_ApplyBranchUpdate(t *testing.T) { } } -func Test_Document_Changelog(t *testing.T) { +func Test_Document_HistoryEntry(t *testing.T) { t.Parallel() doc := stubDocument() doc.UpdatedAt = time.Date(2026, 1, 1, 10, 44, 30, 0, time.UTC) - cl := doc.Changelog() + entry := doc.HistoryEntry() // the timestamp is truncated to the 30-minute aggregation window. - assert.Equal(t, doc.ID.String()+"-"+doc.BranchID.String()+"-2026-01-01T10:30:00", cl.ID) - assert.Equal(t, doc.ID, cl.DocumentID) - assert.Equal(t, doc.Content, cl.Content) - assert.Equal(t, doc.RawContent, cl.RawContent) - assert.Equal(t, doc.UpdatedAt, cl.CreatedAt) + assert.Equal(t, doc.ID.String()+"-"+doc.BranchID.String()+"-2026-01-01T10:30:00", entry.ID) + assert.Equal(t, doc.ID, entry.DocumentID) + assert.Equal(t, doc.Content, entry.Content) + assert.Equal(t, doc.RawContent, entry.RawContent) + assert.Equal(t, doc.UpdatedAt, entry.CreatedAt) // a second branch of the same document updated within the same window // keeps an entry of its own. other := doc other.BranchID = xid.New() - assert.NotEqual(t, cl.ID, other.Changelog().ID) + assert.NotEqual(t, entry.ID, other.HistoryEntry().ID) } func Test_Document_Search(t *testing.T) { diff --git a/server/core/internal/document/file/file.go b/server/core/internal/document/file/file.go index eb9f9b94..e0cad476 100644 --- a/server/core/internal/document/file/file.go +++ b/server/core/internal/document/file/file.go @@ -71,7 +71,7 @@ type File struct { CreatedAt time.Time `json:"createdAt" db:"created_at"` // UnreferencedAt is the timestamp when the file was first observed - // to be referenced by no document content, changelog snapshot or + // to be referenced by no document content, history entry or // comment. Null while the file is still referenced. UnreferencedAt null.Time `json:"unreferencedAt" db:"unreferenced_at"` } diff --git a/server/core/internal/document/file/manager/0mock_db_test.go b/server/core/internal/document/file/manager/0mock_db_test.go index 5deb7737..30285e3b 100644 --- a/server/core/internal/document/file/manager/0mock_db_test.go +++ b/server/core/internal/document/file/manager/0mock_db_test.go @@ -29,8 +29,8 @@ var _ DB = &DBMock{} // DeleteDocumentFileFunc: func(ctx context.Context, id string) error { // panic("mock out the DeleteDocumentFile method") // }, -// DeleteExpiredDocumentBranchChangelogsFunc: func(ctx context.Context, before time.Time) error { -// panic("mock out the DeleteExpiredDocumentBranchChangelogs method") +// DeleteExpiredDocumentBranchHistoryEntriesFunc: func(ctx context.Context, before time.Time) error { +// panic("mock out the DeleteExpiredDocumentBranchHistoryEntries method") // }, // FetchPaginatedDocumentFilesFunc: func(ctx context.Context, offsetID string, limit int64) ([]file.File, error) { // panic("mock out the FetchPaginatedDocumentFiles method") @@ -51,8 +51,8 @@ type DBMock struct { // DeleteDocumentFileFunc mocks the DeleteDocumentFile method. DeleteDocumentFileFunc func(ctx context.Context, id string) error - // DeleteExpiredDocumentBranchChangelogsFunc mocks the DeleteExpiredDocumentBranchChangelogs method. - DeleteExpiredDocumentBranchChangelogsFunc func(ctx context.Context, before time.Time) error + // DeleteExpiredDocumentBranchHistoryEntriesFunc mocks the DeleteExpiredDocumentBranchHistoryEntries method. + DeleteExpiredDocumentBranchHistoryEntriesFunc func(ctx context.Context, before time.Time) error // FetchPaginatedDocumentFilesFunc mocks the FetchPaginatedDocumentFiles method. FetchPaginatedDocumentFilesFunc func(ctx context.Context, offsetID string, limit int64) ([]file.File, error) @@ -78,8 +78,8 @@ type DBMock struct { // ID is the id argument value. ID string } - // DeleteExpiredDocumentBranchChangelogs holds details about calls to the DeleteExpiredDocumentBranchChangelogs method. - DeleteExpiredDocumentBranchChangelogs []struct { + // DeleteExpiredDocumentBranchHistoryEntries holds details about calls to the DeleteExpiredDocumentBranchHistoryEntries method. + DeleteExpiredDocumentBranchHistoryEntries []struct { // Ctx is the ctx argument value. Ctx context.Context // Before is the before argument value. @@ -106,7 +106,7 @@ type DBMock struct { } lockCheckDocumentFileReferenced sync.RWMutex lockDeleteDocumentFile sync.RWMutex - lockDeleteExpiredDocumentBranchChangelogs sync.RWMutex + lockDeleteExpiredDocumentBranchHistoryEntries sync.RWMutex lockFetchPaginatedDocumentFiles sync.RWMutex lockUpdateDocumentFileUnreferencedAt sync.RWMutex } @@ -194,8 +194,8 @@ func (mock *DBMock) DeleteDocumentFileCalls() []struct { return calls } -// DeleteExpiredDocumentBranchChangelogs calls DeleteExpiredDocumentBranchChangelogsFunc. -func (mock *DBMock) DeleteExpiredDocumentBranchChangelogs(ctx context.Context, before time.Time) error { +// DeleteExpiredDocumentBranchHistoryEntries calls DeleteExpiredDocumentBranchHistoryEntriesFunc. +func (mock *DBMock) DeleteExpiredDocumentBranchHistoryEntries(ctx context.Context, before time.Time) error { callInfo := struct { Ctx context.Context Before time.Time @@ -203,23 +203,23 @@ func (mock *DBMock) DeleteExpiredDocumentBranchChangelogs(ctx context.Context, b Ctx: ctx, Before: before, } - mock.lockDeleteExpiredDocumentBranchChangelogs.Lock() - mock.calls.DeleteExpiredDocumentBranchChangelogs = append(mock.calls.DeleteExpiredDocumentBranchChangelogs, callInfo) - mock.lockDeleteExpiredDocumentBranchChangelogs.Unlock() - if mock.DeleteExpiredDocumentBranchChangelogsFunc == nil { + mock.lockDeleteExpiredDocumentBranchHistoryEntries.Lock() + mock.calls.DeleteExpiredDocumentBranchHistoryEntries = append(mock.calls.DeleteExpiredDocumentBranchHistoryEntries, callInfo) + mock.lockDeleteExpiredDocumentBranchHistoryEntries.Unlock() + if mock.DeleteExpiredDocumentBranchHistoryEntriesFunc == nil { var ( errOut error ) return errOut } - return mock.DeleteExpiredDocumentBranchChangelogsFunc(ctx, before) + return mock.DeleteExpiredDocumentBranchHistoryEntriesFunc(ctx, before) } -// DeleteExpiredDocumentBranchChangelogsCalls gets all the calls that were made to DeleteExpiredDocumentBranchChangelogs. +// DeleteExpiredDocumentBranchHistoryEntriesCalls gets all the calls that were made to DeleteExpiredDocumentBranchHistoryEntries. // Check the length with: // -// len(mockedDB.DeleteExpiredDocumentBranchChangelogsCalls()) -func (mock *DBMock) DeleteExpiredDocumentBranchChangelogsCalls() []struct { +// len(mockedDB.DeleteExpiredDocumentBranchHistoryEntriesCalls()) +func (mock *DBMock) DeleteExpiredDocumentBranchHistoryEntriesCalls() []struct { Ctx context.Context Before time.Time } { @@ -227,9 +227,9 @@ func (mock *DBMock) DeleteExpiredDocumentBranchChangelogsCalls() []struct { Ctx context.Context Before time.Time } - mock.lockDeleteExpiredDocumentBranchChangelogs.RLock() - calls = mock.calls.DeleteExpiredDocumentBranchChangelogs - mock.lockDeleteExpiredDocumentBranchChangelogs.RUnlock() + mock.lockDeleteExpiredDocumentBranchHistoryEntries.RLock() + calls = mock.calls.DeleteExpiredDocumentBranchHistoryEntries + mock.lockDeleteExpiredDocumentBranchHistoryEntries.RUnlock() return calls } diff --git a/server/core/internal/document/file/manager/manager.go b/server/core/internal/document/file/manager/manager.go index df4f9889..e9c6cd41 100644 --- a/server/core/internal/document/file/manager/manager.go +++ b/server/core/internal/document/file/manager/manager.go @@ -40,11 +40,11 @@ type Manager struct { // Options holds all file manager options. type Options struct { - // ChangelogRetention specifies how long document changelog snapshots - // are kept. Snapshots pin the files they reference, so trimming them - // is what releases a file removed from live content. + // HistoryRetention specifies how long document history entries are + // kept. Entries pin the files they reference, so trimming them is + // what releases a file removed from live content. // Zero disables age-based trimming. - ChangelogRetention time.Duration + HistoryRetention time.Duration } // NewManager creates a new Manager with the given database interface. @@ -81,13 +81,13 @@ func (m *Manager) Start(ctx context.Context) { ).Start(ctx) } -// processFiles trims expired changelog snapshots and then reclaims every +// processFiles trims expired history entries and then reclaims every // file that no longer has anything referring to it. // -// The snapshots go first: a file released by an expiring snapshot is then +// The entries go first: a file released by an expiring entry is then // reclaimed in the same pass instead of waiting for the next one. func (m *Manager) processFiles(ctx context.Context) error { - if err := m.trimChangelogs(ctx); err != nil { + if err := m.trimHistoryEntries(ctx); err != nil { return err } @@ -192,21 +192,21 @@ func (m *Manager) deleteFile(ctx context.Context, f file.File) { } } -// trimChangelogs removes changelog snapshots that outlived their retention. -// Snapshot count is trimmed on insert instead; age has to be handled here, +// trimHistoryEntries removes history entries that outlived their retention. +// Entry count is trimmed on insert instead; age has to be handled here, // because a branch that stopped being edited never inserts again and its -// snapshots would pin their files forever. -func (m *Manager) trimChangelogs(ctx context.Context) error { - if m.opts.ChangelogRetention == 0 { +// entries would pin their files forever. +func (m *Manager) trimHistoryEntries(ctx context.Context) error { + if m.opts.HistoryRetention == 0 { return nil } - err := m.db.DeleteExpiredDocumentBranchChangelogs( + err := m.db.DeleteExpiredDocumentBranchHistoryEntries( ctx, - timeutil.Now().Add(-m.opts.ChangelogRetention), + timeutil.Now().Add(-m.opts.HistoryRetention), ) if err != nil { - return fmt.Errorf("deleting expired document branch changelogs: %w", err) + return fmt.Errorf("deleting expired document branch history entries: %w", err) } return nil @@ -221,7 +221,7 @@ type DB interface { FetchPaginatedDocumentFiles(ctx context.Context, offsetID string, limit int64) ([]file.File, error) // CheckDocumentFileReferenced should report whether the file is still - // referenced by the document's content, changelogs or comments. + // referenced by the document's content, history entries or comments. CheckDocumentFileReferenced(ctx context.Context, id string, documentID xid.ID) (bool, error) // UpdateDocumentFileUnreferencedAt should set or clear the moment the @@ -231,9 +231,9 @@ type DB interface { // DeleteDocumentFile should remove the document file. DeleteDocumentFile(ctx context.Context, id string) error - // DeleteExpiredDocumentBranchChangelogs should remove changelog - // snapshots created before the given time. - DeleteExpiredDocumentBranchChangelogs(ctx context.Context, before time.Time) error + // DeleteExpiredDocumentBranchHistoryEntries should remove history + // entries created before the given time. + DeleteExpiredDocumentBranchHistoryEntries(ctx context.Context, before time.Time) error } // Storer defines the object storage operations required by the Manager. diff --git a/server/core/internal/document/file/manager/manager_test.go b/server/core/internal/document/file/manager/manager_test.go index 0a10ecba..013d2c79 100644 --- a/server/core/internal/document/file/manager/manager_test.go +++ b/server/core/internal/document/file/manager/manager_test.go @@ -34,7 +34,7 @@ func Test_NewManager(t *testing.T) { db := &DBMock{} storer := &StorerMock{} - opts := Options{ChangelogRetention: time.Hour} + opts := Options{HistoryRetention: time.Hour} m := NewManager(slog.New(slog.DiscardHandler), db, storer, opts) require.NotNil(t, m) @@ -100,7 +100,7 @@ func Test_Manager_processFiles(t *testing.T) { wasTrimCalled := func(count int) check { return func(t *testing.T, db *DBMock, _ *StorerMock, _ error) { - assert.Len(t, db.DeleteExpiredDocumentBranchChangelogsCalls(), count) + assert.Len(t, db.DeleteExpiredDocumentBranchHistoryEntriesCalls(), count) } } @@ -124,14 +124,14 @@ func Test_Manager_processFiles(t *testing.T) { Opts Options Checks []check }{ - "Error returned by db.DeleteExpiredDocumentBranchChangelogs": { + "Error returned by db.DeleteExpiredDocumentBranchHistoryEntries": { DB: &DBMock{ - DeleteExpiredDocumentBranchChangelogsFunc: func(context.Context, time.Time) error { + DeleteExpiredDocumentBranchHistoryEntriesFunc: func(context.Context, time.Time) error { return assert.AnError }, }, Storer: &StorerMock{}, - Opts: Options{ChangelogRetention: time.Hour}, + Opts: Options{HistoryRetention: time.Hour}, Checks: checks( hasError(assert.AnError), wasTrimCalled(1), @@ -472,7 +472,7 @@ func Test_Manager_deleteFile(t *testing.T) { } } -func Test_Manager_trimChangelogs(t *testing.T) { +func Test_Manager_trimHistoryEntries(t *testing.T) { cc := map[string]struct { DB *DBMock Opts Options @@ -484,19 +484,19 @@ func Test_Manager_trimChangelogs(t *testing.T) { Opts: Options{}, Calls: 0, }, - "Error returned by db.DeleteExpiredDocumentBranchChangelogs": { + "Error returned by db.DeleteExpiredDocumentBranchHistoryEntries": { DB: &DBMock{ - DeleteExpiredDocumentBranchChangelogsFunc: func(context.Context, time.Time) error { + DeleteExpiredDocumentBranchHistoryEntriesFunc: func(context.Context, time.Time) error { return assert.AnError }, }, - Opts: Options{ChangelogRetention: time.Hour}, + Opts: Options{HistoryRetention: time.Hour}, Calls: 1, Err: assert.AnError, }, "Successful trim": { DB: &DBMock{}, - Opts: Options{ChangelogRetention: time.Hour}, + Opts: Options{HistoryRetention: time.Hour}, Calls: 1, }, } @@ -507,11 +507,11 @@ func Test_Manager_trimChangelogs(t *testing.T) { m := NewManager(slog.New(slog.DiscardHandler), c.DB, &StorerMock{}, c.Opts) - err := m.trimChangelogs(context.Background()) + err := m.trimHistoryEntries(context.Background()) testutil.AssertEqualError(t, c.Err, err) - ff := c.DB.DeleteExpiredDocumentBranchChangelogsCalls() + ff := c.DB.DeleteExpiredDocumentBranchHistoryEntriesCalls() require.Len(t, ff, c.Calls) if c.Calls == 0 { diff --git a/server/core/internal/server/internal/document/http.go b/server/core/internal/server/internal/document/http.go index f4afa522..89e20f61 100644 --- a/server/core/internal/server/internal/document/http.go +++ b/server/core/internal/server/internal/document/http.go @@ -1587,7 +1587,7 @@ type BranchesDBAgent interface { DeleteDocumentBranchByID(ctx context.Context, branchID xid.ID, organizationID string) error // UpdateDocumentBranchMetadata should update the name and protection status of a branch - // without modifying content or inserting a changelog entry. + // without modifying content or inserting a history entry. UpdateDocumentBranchMetadata(ctx context.Context, doc documentCore.Document) error } diff --git a/server/core/internal/server/server.go b/server/core/internal/server/server.go index 46054c84..56227ce4 100644 --- a/server/core/internal/server/server.go +++ b/server/core/internal/server/server.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "net/url" + "strings" "sync" "time" @@ -48,6 +49,10 @@ type Options struct { // PublicURL is the public URL of the server. PublicURL string + // Host specifies the interface the server binds to. Empty binds + // all interfaces. + Host string + // Port specifies which port should be used by the server. Port uint @@ -210,7 +215,7 @@ func NewServer( srv.http = &http.Server{ ReadTimeout: time.Minute, ReadHeaderTimeout: time.Minute, - Addr: fmt.Sprintf(":%d", opts.Port), + Addr: fmt.Sprintf("%s:%d", opts.Host, opts.Port), Handler: srv.httpRouter(), ErrorLog: logutil.NewDebugWriter(log, "EOF").StdLogger(), } @@ -242,9 +247,15 @@ func (s *Server) Listen() { // Address returns the address of the server. func (s *Server) Address() string { - host := "localhost" - if s.http.Addr != ":80" { - host += s.http.Addr // port can be random + host := s.http.Addr + + // a host-less address binds all interfaces; localhost stands in for + // log readability. + if strings.HasPrefix(s.http.Addr, ":") { + host = "localhost" + if s.http.Addr != ":80" { + host += s.http.Addr // port can be random + } } addr := &url.URL{ diff --git a/server/core/internal/server/server_test.go b/server/core/internal/server/server_test.go index 0d3fa89b..5d65bd43 100644 --- a/server/core/internal/server/server_test.go +++ b/server/core/internal/server/server_test.go @@ -314,6 +314,10 @@ func Test_Server_Address(t *testing.T) { Addr: ":1234", Result: "http://localhost:1234", }, + "Host-qualified address is used verbatim": { + Addr: "127.0.0.1:8180", + Result: "http://127.0.0.1:8180", + }, } for cn, c := range cc { diff --git a/server/core/pkg/redisutil/redisutil.go b/server/core/pkg/redisutil/redisutil.go index a2c284a5..83d389c6 100644 --- a/server/core/pkg/redisutil/redisutil.go +++ b/server/core/pkg/redisutil/redisutil.go @@ -4,6 +4,8 @@ package redisutil import ( "context" "errors" + "fmt" + "net/url" "time" "github.com/gomodule/redigo/redis" @@ -19,24 +21,31 @@ const ( _dialTimeout = time.Second * 3 ) -// NewPool prepares a new redis connection pool. -func NewPool(network, address string) (*redis.Pool, error) { - if network == "" { - return nil, errors.New("invalid redis network") +// NewPool prepares a new redis connection pool. The URL follows the +// redis[s]://[username:password@]host:port[/db] form. +func NewPool(rawURL string) (*redis.Pool, error) { + if rawURL == "" { + return nil, errors.New("invalid redis url") } - if address == "" { - return nil, errors.New("invalid redis address") + // the URL is checked eagerly so a malformed value fails at boot + // instead of on the first dial. + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("invalid redis url: %w", err) + } + + if u.Scheme != "redis" && u.Scheme != "rediss" { + return nil, errors.New("invalid redis url scheme") } return &redis.Pool{ MaxIdle: _maxIdleConns, IdleTimeout: time.Minute, DialContext: func(ctx context.Context) (redis.Conn, error) { - return redis.DialContext( + return redis.DialURLContext( ctx, - network, - address, + rawURL, redis.DialConnectTimeout(_dialTimeout), redis.DialWriteTimeout(_dialTimeout), redis.DialReadTimeout(_dialTimeout), diff --git a/server/core/pkg/redisutil/redisutil_test.go b/server/core/pkg/redisutil/redisutil_test.go index db5c3dcd..efe930cd 100644 --- a/server/core/pkg/redisutil/redisutil_test.go +++ b/server/core/pkg/redisutil/redisutil_test.go @@ -10,18 +10,23 @@ import ( ) func Test_NewPool(t *testing.T) { - // Error, network is empty. - pool, err := NewPool("", "") - require.EqualError(t, err, "invalid redis network") + // Error, url is empty. + pool, err := NewPool("") + require.EqualError(t, err, "invalid redis url") require.Nil(t, pool) - // Error, address is empty. - pool, err = NewPool("127.0.0.1", "") - require.EqualError(t, err, "invalid redis address") + // Error, url is malformed. + pool, err = NewPool("redis://[::1") + require.Error(t, err) + require.Nil(t, pool) + + // Error, url scheme is not redis. + pool, err = NewPool("http://127.0.0.1:6379") + require.EqualError(t, err, "invalid redis url scheme") require.Nil(t, pool) // Success. - pool, err = NewPool("127.0.0.1", "8080") + pool, err = NewPool("redis://user:pass@127.0.0.1:1") require.NoError(t, err) require.NotNil(t, pool) diff --git a/web/app/components/editor/DocumentContainer.client.nuxt.test.ts b/web/app/components/editor/DocumentContainer.client.nuxt.test.ts index 821591b6..963a6f77 100644 --- a/web/app/components/editor/DocumentContainer.client.nuxt.test.ts +++ b/web/app/components/editor/DocumentContainer.client.nuxt.test.ts @@ -105,6 +105,16 @@ function bodyShown(wrapper: VueWrapper): boolean { return wrapper.findComponent({ name: "NameEditor" }).exists() } +// the test environment builds a PageTransitionEvent without honouring the +// persisted flag, so it is defined on the event directly. +function pagehide(persisted: boolean): Event { + const event = new Event("pagehide") + + Object.defineProperty(event, "persisted", { value: persisted }) + + return event +} + // the editor store, the auth and query caches and the recorded providers // are all shared, so these tests cannot interleave describe("", { concurrent: false }, () => { @@ -396,4 +406,32 @@ describe("", { concurrent: false }, () => { ]) expect(at(providers.created, 0).destroyed).toBe(true) }) + + // closing a tab unmounts nothing, so without this everyone else keeps + // seeing this user's caret until the socket is noticed as gone + it("closes every branch sync when the tab goes away", async ({ expect }) => { + useEditorStore().updatePreloadedBranchIds([TARGET_BRANCH_ID]) + await mountContainer() + + window.dispatchEvent(pagehide(false)) + + expect(providers.created.map((entry) => entry.destroyed)).toEqual([ + true, + true, + ]) + }) + + it("keeps the branch syncs when the page is only cached", async ({ + expect, + }) => { + useEditorStore().updatePreloadedBranchIds([TARGET_BRANCH_ID]) + await mountContainer() + + window.dispatchEvent(pagehide(true)) + + expect(providers.created.map((entry) => entry.destroyed)).toEqual([ + false, + false, + ]) + }) }) diff --git a/web/app/components/editor/DocumentContainer.client.vue b/web/app/components/editor/DocumentContainer.client.vue index be2a8d6c..59d215c6 100644 --- a/web/app/components/editor/DocumentContainer.client.vue +++ b/web/app/components/editor/DocumentContainer.client.vue @@ -86,12 +86,27 @@ import.meta.hot?.accept(() => { refreshGapDecorationsInBackground(contentEditor as Ref) }) -onBeforeUnmount(() => { +function destroyBranchProviders(): void { for (const [, entry] of branchProviders) { entry.provider.destroy() } branchProviders.clear() +} + +onBeforeUnmount(destroyBranchProviders) + +// closing the tab never unmounts anything, so without this the provider is +// only noticed as gone when its socket drops — and until then everyone else +// still sees this user's caret. pagehide is the event that survives a tab +// close; a bfcache eviction (persisted) is skipped, since the page can be +// restored and would come back with dead providers. +useEventListener(window, "pagehide", (event: PageTransitionEvent) => { + if (event.persisted) { + return + } + + destroyBranchProviders() }) watchImmediate( diff --git a/web/app/components/settings/ProfileSection.nuxt.test.ts b/web/app/components/settings/ProfileSection.nuxt.test.ts index a2fde8af..cbf702b9 100644 --- a/web/app/components/settings/ProfileSection.nuxt.test.ts +++ b/web/app/components/settings/ProfileSection.nuxt.test.ts @@ -94,6 +94,59 @@ describe("", { concurrent: false }, () => { expect(usernameInput(wrapper).element.value).toBe("ada") }) + it("fills the username field once a late session arrives", async ({ + expect, + }) => { + seedAuthSession(null) + + const wrapper = await mountSection() + expect(usernameInput(wrapper).element.value).toBe("") + + seedAuthSession({ + id: "u1", + email: "ada@oxynote.test", + name: "ada", + image: "https://cdn.test/ada.png", + }) + await nextTick() + + expect(usernameInput(wrapper).element.value).toBe("ada") + }) + + it("follows a name that changed since the field was filled", async ({ + expect, + }) => { + const wrapper = await mountSection() + expect(usernameInput(wrapper).element.value).toBe("ada") + + seedAuthSession({ + id: "u1", + email: "ada@oxynote.test", + name: "ada-renamed", + image: "https://cdn.test/ada.png", + }) + await nextTick() + + expect(usernameInput(wrapper).element.value).toBe("ada-renamed") + }) + + it("leaves a half-typed username alone when the session updates", async ({ + expect, + }) => { + const wrapper = await mountSection() + await usernameInput(wrapper).setValue("half-typed") + + seedAuthSession({ + id: "u1", + email: "ada@oxynote.test", + name: "ada-renamed", + image: "https://cdn.test/ada.png", + }) + await nextTick() + + expect(usernameInput(wrapper).element.value).toBe("half-typed") + }) + it("asks to change the email when its pencil is pressed", async ({ expect, }) => { diff --git a/web/app/components/settings/ProfileSection.vue b/web/app/components/settings/ProfileSection.vue index a6a887a2..691cfb7f 100644 --- a/web/app/components/settings/ProfileSection.vue +++ b/web/app/components/settings/ProfileSection.vue @@ -27,10 +27,7 @@ const { t } = useI18n({ useScope: "global" }) const formSchema = toTypedSchema( z.object({ - imageUrl: z - .string() - .trim() - .default(fetchAuthSession.state.value.data?.data?.user.image || ""), + imageUrl: z.string().trim().default(""), username: z .string() .trim() @@ -39,12 +36,21 @@ const formSchema = toTypedSchema( .regex(/^[a-zA-Z0-9-_]+$/, { message: t("settings.profile.errors.username-regex"), }) - .default(fetchAuthSession.state.value.data?.data?.user.name || ""), + .default(""), }), ) const form = useForm({ validationSchema: formSchema, }) + +// the session arrives asynchronously, so the form follows it rather than +// reading it once during setup. A dialog mounted before the query settles +// would otherwise keep showing an empty username for as long as it stays +// open, and one reopened after a save would show the value from before it. +// Resetting only while the form is clean leaves a half-typed edit alone. +const sessionUser = computed( + () => fetchAuthSession.state.value.data?.data?.user, +) const { uploadUserImage } = useUserAPI() const loading = ref<"avatar" | null>(null) const avatarInput = useTemplateRef("avatarInput") @@ -112,6 +118,19 @@ const onSubmit = form.handleSubmit(async (values) => { } }) +watchImmediate(sessionUser, (user) => { + if (!user || form.meta.value.dirty) { + return + } + + form.resetForm({ + values: { + imageUrl: user.image ?? "", + username: user.name, + }, + }) +}) + const { ignoreUpdates } = watchIgnorable( slackNotificationsEnabled, async (newValue, oldValue) => { diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts index 9da452b5..f385e0d0 100644 --- a/web/nuxt.config.ts +++ b/web/nuxt.config.ts @@ -73,6 +73,10 @@ export default defineNuxtConfig({ preset: isDesktopBuild ? "static" : (process.env.NITRO_PRESET ?? "cloudflare_pages"), + // NITRO_MINIFY=true terser-minifies the server bundle; the + // production docker image sets it to halve the shipped output. + // Unset keeps each preset's own default. + minify: process.env.NITRO_MINIFY === "true" ? true : undefined, }, modules: [