add production docker image

This commit is contained in:
swithek 2026-08-27 21:10:45 +02:00
parent 9e97824187
commit 52bb4f08ac
107 changed files with 9140 additions and 467 deletions

18
.dockerignore Normal file
View 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

View File

@ -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"

View File

@ -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
View 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
View 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
View File

@ -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

View File

@ -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)

View File

@ -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).

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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
View 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
View 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
View 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
View 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`.

View 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:

View 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)
}

View File

@ -0,0 +1,4 @@
pnpm-lock.yaml
dist
*.md

View 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 },
],
},
},
)

View 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

View 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"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
allowBuilds:
esbuild: true

View File

@ -0,0 +1,6 @@
export default {
trailingComma: "all",
useTabs: true,
tabWidth: 8,
semi: false,
}

View 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: "",
})
})
})

View 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__
: ""

View 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")
})
})
})

View 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,
}
}

View 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)
})
})

View 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"]])
})
})

View 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 = ""
},
}
}

View 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)
})
})

View 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
}

View 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")
})
})

View 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 }
}

View 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",
)
})
})

View 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 }
}

View 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()
})
})

View 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.
}
},
}
}

View 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)
})
})
})

View 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 }
}

View 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",
}
}

View 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"]
}

View 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,
},
},
},
})

View File

@ -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

View File

@ -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:

View File

@ -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:

View 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"

View File

@ -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.

View File

@ -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
}

View File

@ -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}/`

View File

@ -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
View 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 })
})
})
}

View File

@ -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)
}

View File

@ -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,

View File

@ -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",

View File

@ -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"] },
},
],
})

View 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"] },
},
],
})

View 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"] },
},
],
})

View File

@ -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 },

View File

@ -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, {

View File

@ -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}$/)

View 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()
})
})

View File

@ -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

View File

@ -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

View File

@ -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"]

View File

@ -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",

View File

@ -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

View 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"

View File

@ -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()
})
})

View File

@ -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(),
}
}

View File

@ -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", () => {

View File

@ -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:

View File

@ -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())

View File

@ -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,
})
}

View File

@ -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",

View File

@ -5,6 +5,7 @@
"dist",
"src/**/*.test.ts",
"src/**/test-helpers.ts",
"src/test-setup.ts"
"src/test-setup.ts",
"src/bundle.ts"
]
}

View File

@ -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",
],

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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.

View File

@ -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())
})
}

View File

@ -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()

View File

@ -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)
})
}
}

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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;

View File

@ -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"`
}

View File

@ -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) {

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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.

View File

@ -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 {

View File

@ -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
}

View File

@ -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{

View File

@ -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