mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
add production docker image
This commit is contained in:
parent
9e97824187
commit
52bb4f08ac
18
.dockerignore
Normal file
18
.dockerignore
Normal file
|
|
@ -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
|
||||
32
.github/dependabot.yml
vendored
32
.github/dependabot.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
101
.github/workflows/qa-e2e-prod.yml
vendored
Normal file
101
.github/workflows/qa-e2e-prod.yml
vendored
Normal file
|
|
@ -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
|
||||
83
.github/workflows/qa-prod-launcher.yml
vendored
Normal file
83
.github/workflows/qa-prod-launcher.yml
vendored
Normal file
|
|
@ -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
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
31
CLAUDE.md
31
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 `"<documentId>-<branchIdentifier>"`, 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)
|
||||
|
||||
|
|
|
|||
92
Makefile
92
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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
docker/env/auth-realtime.example.env
vendored
6
docker/env/auth-realtime.example.env
vendored
|
|
@ -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
|
||||
|
|
|
|||
39
docker/env/core.example.env
vendored
39
docker/env/core.example.env
vendored
|
|
@ -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
|
||||
|
|
|
|||
129
docker/prod/CLAUDE.md
Normal file
129
docker/prod/CLAUDE.md
Normal file
|
|
@ -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: <https://github.com/hoppscotch/hoppscotch>
|
||||
(`aio_run.mjs`, `healthcheck.sh`, `prod.Dockerfile`)
|
||||
- Baserow all-in-one — bundled Caddy, single public-URL variable, secret
|
||||
files on the data volume: <https://gitlab.com/baserow/baserow>
|
||||
(`deploy/all-in-one/`)
|
||||
- Appsmith fat container — layered healthcheck, generated secrets on the
|
||||
volume: <https://github.com/appsmithorg/appsmith> (`deploy/docker/`)
|
||||
- grist-omnibus — the anti-pattern reference (no tini, no SIGTERM handling):
|
||||
<https://github.com/gristlabs/grist-omnibus>
|
||||
- tini: <https://github.com/krallin/tini> · Docker multi-process guidance:
|
||||
<https://docs.docker.com/engine/containers/multi-service_container/>
|
||||
- Caddy (Apache-2.0, trademarked name): <https://github.com/caddyserver/caddy>
|
||||
76
docker/prod/Caddyfile
Normal file
76
docker/prod/Caddyfile
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
156
docker/prod/Dockerfile
Normal file
156
docker/prod/Dockerfile
Normal file
|
|
@ -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"]
|
||||
115
docker/prod/README.md
Normal file
115
docker/prod/README.md
Normal file
|
|
@ -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`.
|
||||
85
docker/prod/docker-compose.example.yaml
Normal file
85
docker/prod/docker-compose.example.yaml
Normal file
|
|
@ -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 <team@example.com>
|
||||
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:
|
||||
33
docker/prod/healthcheck.mjs
Normal file
33
docker/prod/healthcheck.mjs
Normal file
|
|
@ -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)
|
||||
}
|
||||
4
docker/prod/launcher/.prettierignore
Normal file
4
docker/prod/launcher/.prettierignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pnpm-lock.yaml
|
||||
dist
|
||||
|
||||
*.md
|
||||
115
docker/prod/launcher/eslint.config.mjs
Normal file
115
docker/prod/launcher/eslint.config.mjs
Normal file
|
|
@ -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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
16
docker/prod/launcher/knip.ts
Normal file
16
docker/prod/launcher/knip.ts
Normal file
|
|
@ -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
|
||||
43
docker/prod/launcher/package.json
Normal file
43
docker/prod/launcher/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
2780
docker/prod/launcher/pnpm-lock.yaml
Normal file
2780
docker/prod/launcher/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
2
docker/prod/launcher/pnpm-workspace.yaml
Normal file
2
docker/prod/launcher/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
allowBuilds:
|
||||
esbuild: true
|
||||
6
docker/prod/launcher/prettier.config.js
Normal file
6
docker/prod/launcher/prettier.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
trailingComma: "all",
|
||||
useTabs: true,
|
||||
tabWidth: 8,
|
||||
semi: false,
|
||||
}
|
||||
16
docker/prod/launcher/src/baked.test.ts
Normal file
16
docker/prod/launcher/src/baked.test.ts
Normal file
|
|
@ -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: "",
|
||||
})
|
||||
})
|
||||
})
|
||||
33
docker/prod/launcher/src/baked.ts
Normal file
33
docker/prod/launcher/src/baked.ts
Normal file
|
|
@ -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__
|
||||
: ""
|
||||
558
docker/prod/launcher/src/env.test.ts
Normal file
558
docker/prod/launcher/src/env.test.ts
Normal file
|
|
@ -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 <team@example.com>",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.smtp).toEqual({
|
||||
host: "mail.example.com",
|
||||
port: "465",
|
||||
username: "user",
|
||||
password: "pa@ss",
|
||||
tls: "tls",
|
||||
})
|
||||
expect(config.emailFromAddress).toBe(
|
||||
"Oxynote <team@example.com>",
|
||||
)
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
629
docker/prod/launcher/src/env.ts
Normal file
629
docker/prod/launcher/src/env.ts
Normal file
|
|
@ -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<Record<SocialLoginProviderName, OAuthCredentials>>
|
||||
// the assistant family is passed through to core untouched, keyed by
|
||||
// the OXYNOTE_AI_ASSISTANT_ suffix; core owns its deep validation.
|
||||
aiAssistant: Record<string, string>
|
||||
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<typeof baseSchema>
|
||||
|
||||
// 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<Record<SocialLoginProviderName, OAuthCredentials>> {
|
||||
const login: Partial<
|
||||
Record<SocialLoginProviderName, OAuthCredentials>
|
||||
> = {}
|
||||
|
||||
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<string, string> {
|
||||
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<string, string | undefined>): 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,
|
||||
}
|
||||
}
|
||||
286
docker/prod/launcher/src/index.ts
Normal file
286
docker/prod/launcher/src/index.ts
Normal file
|
|
@ -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<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
async function probe(url: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
61
docker/prod/launcher/src/logging.test.ts
Normal file
61
docker/prod/launcher/src/logging.test.ts
Normal file
|
|
@ -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"]])
|
||||
})
|
||||
})
|
||||
43
docker/prod/launcher/src/logging.ts
Normal file
43
docker/prod/launcher/src/logging.ts
Normal file
|
|
@ -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 = ""
|
||||
},
|
||||
}
|
||||
}
|
||||
46
docker/prod/launcher/src/loopback.test.ts
Normal file
46
docker/prod/launcher/src/loopback.test.ts
Normal file
|
|
@ -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<boolean> =>
|
||||
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)
|
||||
})
|
||||
})
|
||||
28
docker/prod/launcher/src/loopback.ts
Normal file
28
docker/prod/launcher/src/loopback.ts
Normal file
|
|
@ -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<boolean>
|
||||
}
|
||||
|
||||
// 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<string[]> {
|
||||
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
|
||||
}
|
||||
346
docker/prod/launcher/src/mapping.test.ts
Normal file
346
docker/prod/launcher/src/mapping.test.ts
Normal file
|
|
@ -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 <team@example.com>",
|
||||
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 <team@example.com>",
|
||||
)
|
||||
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")
|
||||
})
|
||||
})
|
||||
223
docker/prod/launcher/src/mapping.ts
Normal file
223
docker/prod/launcher/src/mapping.ts
Normal file
|
|
@ -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<string, string>
|
||||
authRealtime: Record<string, string>
|
||||
web: Record<string, string>
|
||||
caddy: Record<string, string>
|
||||
}
|
||||
|
||||
// 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<string, string>,
|
||||
): Record<string, string> {
|
||||
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<string, string>,
|
||||
): 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<string, string> = {
|
||||
...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<string, string> = {
|
||||
...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<string, string> = {
|
||||
...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<string, string> = {
|
||||
...inheritedEnv,
|
||||
XDG_DATA_HOME: "/tmp/caddy/data",
|
||||
XDG_CONFIG_HOME: "/tmp/caddy/config",
|
||||
}
|
||||
|
||||
return { core, authRealtime, web, caddy }
|
||||
}
|
||||
144
docker/prod/launcher/src/secrets.test.ts
Normal file
144
docker/prod/launcher/src/secrets.test.ts
Normal file
|
|
@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
124
docker/prod/launcher/src/secrets.ts
Normal file
124
docker/prod/launcher/src/secrets.ts
Normal file
|
|
@ -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 }
|
||||
}
|
||||
111
docker/prod/launcher/src/sentry.test.ts
Normal file
111
docker/prod/launcher/src/sentry.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { describe, it, vi } from "vitest"
|
||||
import { createCrashReporter, type SentryClient } from "./sentry.js"
|
||||
|
||||
type InitOptions = Parameters<SentryClient["init"]>[0]
|
||||
|
||||
function harness(overrides: { flush?: () => Promise<boolean> } = {}): {
|
||||
init: ReturnType<typeof vi.fn>
|
||||
captureException: ReturnType<typeof vi.fn>
|
||||
flush: ReturnType<typeof vi.fn>
|
||||
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<boolean>>(() => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
72
docker/prod/launcher/src/sentry.ts
Normal file
72
docker/prod/launcher/src/sentry.ts
Normal file
|
|
@ -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<boolean>
|
||||
}
|
||||
|
||||
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<void>
|
||||
}
|
||||
|
||||
// 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.
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
279
docker/prod/launcher/src/supervisor.test.ts
Normal file
279
docker/prod/launcher/src/supervisor.test.ts
Normal file
|
|
@ -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<typeof vi.fn>
|
||||
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> = {}): 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<boolean>>()
|
||||
.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)
|
||||
})
|
||||
})
|
||||
})
|
||||
166
docker/prod/launcher/src/supervisor.ts
Normal file
166
docker/prod/launcher/src/supervisor.ts
Normal file
|
|
@ -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<string, string>
|
||||
// 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<string, string>,
|
||||
): ChildProcess
|
||||
// answers whether a GET to the URL returned 200.
|
||||
probe(url: string): Promise<boolean>
|
||||
sleep(ms: number): Promise<void>
|
||||
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<void>
|
||||
stopAll(): Promise<void>
|
||||
}
|
||||
|
||||
interface Child {
|
||||
spec: ChildSpec
|
||||
process: ChildProcess
|
||||
exited: Promise<number>
|
||||
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<void> {
|
||||
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<void> {
|
||||
deps.log(`[launcher] starting ${spec.name}`)
|
||||
|
||||
const process = deps.spawn(spec.command, spec.args, spec.env)
|
||||
|
||||
attachOutput(spec.name, process)
|
||||
|
||||
const exited = new Promise<number>((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<void> {
|
||||
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 }
|
||||
}
|
||||
82
docker/prod/launcher/src/test-helpers.ts
Normal file
82
docker/prod/launcher/src/test-helpers.ts
Normal file
|
|
@ -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<string, string | undefined> = {},
|
||||
): Record<string, string | undefined> {
|
||||
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> = {}): 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",
|
||||
}
|
||||
}
|
||||
23
docker/prod/launcher/tsconfig.json
Normal file
23
docker/prod/launcher/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
43
docker/prod/launcher/vitest.config.ts
Normal file
43
docker/prod/launcher/vitest.config.ts
Normal file
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
224
e2e/CLAUDE.md
224
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -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:
|
||||
109
e2e/docker-compose.prod.yaml
Normal file
109
e2e/docker-compose.prod.yaml
Normal file
|
|
@ -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 <team@oxynote.io>
|
||||
- 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"
|
||||
|
|
@ -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<void> {
|
||||
// make prints its own labelled step per image, so it is not wrapped
|
||||
// in a progress line of this file's own.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}/`
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
await page.goto(path)
|
||||
await page.goto(new URL(path, BASE_URL).href)
|
||||
await page.waitForFunction(
|
||||
() => "__vue_app__" in (document.getElementById("__nuxt") ?? {}),
|
||||
)
|
||||
|
|
|
|||
153
e2e/helpers/prod.ts
Normal file
153
e2e/helpers/prod.ts
Normal file
|
|
@ -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<ProbeResult> {
|
||||
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<string, string | string[] | undefined>
|
||||
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<RawResponse> {
|
||||
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<CommandResult> {
|
||||
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 })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -38,7 +38,12 @@ export async function makeReviewable(page: Page): Promise<void> {
|
|||
})
|
||||
.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<void> {
|
|||
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
return run("make", ["e2e-stack-build"], ROOT_DIR, "inherit")
|
||||
return run("make", [stack.buildTarget], ROOT_DIR, "inherit")
|
||||
}
|
||||
|
||||
export function stopStack(): Promise<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
},
|
||||
],
|
||||
})
|
||||
49
e2e/playwright.dev.config.ts
Normal file
49
e2e/playwright.dev.config.ts
Normal file
|
|
@ -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"] },
|
||||
},
|
||||
],
|
||||
})
|
||||
40
e2e/playwright.prod.config.ts
Normal file
40
e2e/playwright.prod.config.ts
Normal file
|
|
@ -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"] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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}$/)
|
||||
|
|
|
|||
222
e2e/tests/prod-trust-boundary.test.ts
Normal file
222
e2e/tests/prod-trust-boundary.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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_<name>.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_<name>.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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
7
server/auth-realtime/src/bundle.ts
Normal file
7
server/auth-realtime/src/bundle.ts
Normal file
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export interface Store {
|
|||
export interface DatabaseHandle {
|
||||
store: Store
|
||||
dialect: PostgresDialect
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
export function createStore(db: Kysely<Database>): Store {
|
||||
|
|
@ -109,8 +110,12 @@ export function createDatabase(dsn: string): DatabaseHandle {
|
|||
pool: new Pool({ connectionString: dsn }),
|
||||
})
|
||||
|
||||
const db = new Kysely<Database>({ dialect })
|
||||
|
||||
return {
|
||||
store: createStore(new Kysely<Database>({ dialect })),
|
||||
store: createStore(db),
|
||||
dialect,
|
||||
// destroy ends the underlying pg pool through the dialect.
|
||||
close: () => db.destroy(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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<string, string | undefined>): 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:
|
||||
|
|
|
|||
|
|
@ -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<typeof createClient> | 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<void>((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<void>((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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ export function testEnv(overrides: Partial<Env> = {}): 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",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
"dist",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/test-helpers.ts",
|
||||
"src/test-setup.ts"
|
||||
"src/test-setup.ts",
|
||||
"src/bundle.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",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user