add lint and test scripts to server/auth-realtime

This commit is contained in:
swithek 2026-08-24 12:52:59 +02:00
parent b1f86cdd0c
commit 026cd56856
46 changed files with 9590 additions and 1432 deletions

83
.github/workflows/qa-auth-realtime.yml vendored Normal file
View File

@ -0,0 +1,83 @@
name: QA
on:
push:
branches:
- main
paths:
- server/auth-realtime/**
- .github/workflows/qa-auth-realtime.yml
pull_request:
branches:
- main
paths:
- server/auth-realtime/**
- .github/workflows/qa-auth-realtime.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-auth-realtime-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
jobs:
lint:
name: Linting auth-realtime
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: server/auth-realtime/package.json
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: server/auth-realtime/pnpm-lock.yaml
- name: Installing dependencies
run: pnpm install --frozen-lockfile
working-directory: server/auth-realtime
- name: Run check-lint
run: pnpm run check-lint
working-directory: server/auth-realtime
test:
name: Testing auth-realtime
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: server/auth-realtime/package.json
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: server/auth-realtime/pnpm-lock.yaml
- name: Installing dependencies
run: pnpm install --frozen-lockfile
working-directory: server/auth-realtime
- name: Run tests
run: pnpm run test
working-directory: server/auth-realtime

View File

@ -73,7 +73,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
- `web/` — Nuxt 4 + Vue 3 frontend; ships as both a web app (SSR) and an Electron desktop app. Own **pnpm** workspace (includes the forked `packages/lezer-promql`). Details: [web/CLAUDE.md](web/CLAUDE.md).
- `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/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).
- `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.

View File

@ -52,21 +52,20 @@ e2e-stack-stop:
@$(QUIET) "stopping the stack" $(E2E_COMPOSE) down -v
# lint gates of every component that has one, from one place. Fixing and
# checking mirror each component's own vocabulary: web and e2e pair
# lint/check-lint, auth-realtime folds its type/lint/format gates into
# qa-fix/qa (it has no tests), and core wraps golangci-lint. Datagen has
# no lint setup and is deliberately absent.
# checking mirror each component's own vocabulary: web, auth-realtime and
# e2e pair lint/check-lint, and core wraps golangci-lint. Datagen has no
# lint setup and is deliberately absent.
.PHONY: lint
lint:
@$(QUIET) "linting web" sh -c 'cd web && pnpm run lint'
@$(QUIET) "linting auth-realtime" sh -c 'cd server/auth-realtime && pnpm run qa-fix'
@$(QUIET) "linting auth-realtime" sh -c 'cd server/auth-realtime && pnpm run lint'
@$(QUIET) "linting core" sh -c 'cd server/core && make lint'
@$(QUIET) "linting e2e" sh -c 'cd e2e && pnpm run lint'
.PHONY: check-lint
check-lint:
@$(QUIET) "checking web" sh -c 'cd web && pnpm run check-lint'
@$(QUIET) "checking auth-realtime" sh -c 'cd server/auth-realtime && pnpm run qa'
@$(QUIET) "checking auth-realtime" sh -c 'cd server/auth-realtime && pnpm run check-lint'
@$(QUIET) "checking core" sh -c 'cd server/core && make check-lint'
@$(QUIET) "checking e2e" sh -c 'cd e2e && pnpm run check-lint'

View File

@ -151,7 +151,7 @@ cd server/core && go test ./... # needs Docker for the db package tests
cd server/core && make build # goreleaser snapshot: binaries + images
# server/auth-realtime
cd server/auth-realtime && pnpm run qa
cd server/auth-realtime && pnpm run qa # check-lint + test
```
See `server/README.md` for backend details.

View File

@ -34,11 +34,13 @@ cd server/core && make build # goreleaser release --snapshot --clean -> bin
# datagen
cd datagen && make build
# auth-realtime
cd server/auth-realtime && pnpm run dev # local watch mode (tsx + instrument.ts)
cd server/auth-realtime && pnpm run build # tsc only
cd server/auth-realtime && pnpm run qa # check-types + check-lint + check-fmt
cd server/auth-realtime && pnpm run qa-fix # check-types + lint --fix + prettier --write
# auth-realtime (full details in auth-realtime/CLAUDE.md)
cd server/auth-realtime && pnpm run dev # local watch mode (tsx + sentry.ts preloaded)
cd server/auth-realtime && pnpm run build # tsc -p tsconfig.build.json
cd server/auth-realtime && pnpm run check-lint # types + eslint + prettier + knip
cd server/auth-realtime && pnpm run lint # the fixing variant
cd server/auth-realtime && pnpm run test # vitest run --coverage
cd server/auth-realtime && pnpm run qa # check-lint + test; qa-fix = lint + test
```
The Go test/lint workflow is documented in [core/CLAUDE.md](core/CLAUDE.md)
@ -76,7 +78,7 @@ This is the model that's least obvious from a fresh read:
- Documents are organized into **branches** (`document_branches` table). Every document has at least one branch; the oldest = the default/main branch.
- The Hocuspocus `documentName` is encoded as `"<documentId>-<branchIdentifier>"`. `branchIdentifier` may be the literal string `"default"`, in which case `resolveBranchId` in `server/auth-realtime/src/hocuspocus.ts` looks up the default branch ID. Splitting on the **first** `-` (`indexOf("-")`) is intentional; XIDs do not contain dashes but the suffix path can.
- Branch content is stored two ways in `document_branches`: structured ProseMirror JSON in `content` (JSONB) and the canonical Yjs binary state in `raw_content` (base64-encoded over the wire). `raw_content` is authoritative for CRDT continuity.
- `onLoadDocument` / `onStoreDocument` round-trip through core's internal `/api/x/documents/{id}/branch/{branchId}` endpoints. **Do not** use `Y.applyUpdate` to seed a freshly-created doc with content from another doc — the differing `clientID`s CRDT-merge and silently duplicate content. The codebase's pattern is `replaceYdocContent` in `server/auth-realtime/src/ydocument.ts`, which deep-clones `Y.XmlElement`s (preserving non-string attrs that `XmlElement.clone()` drops). Read the comments at `server/auth-realtime/src/hocuspocus.ts:154` and `server/auth-realtime/src/routes.ts:73` before touching this area.
- `onLoadDocument` / `onStoreDocument` round-trip through core's internal `/api/x/documents/{id}/branch/{branchId}` endpoints. **Do not** use `Y.applyUpdate` to seed a freshly-created doc with content from another doc — the differing `clientID`s CRDT-merge and silently duplicate content. The codebase's pattern is `replaceYdocContent` in `server/auth-realtime/src/ydocument.ts`, which deep-clones `Y.XmlElement`s (preserving non-string attrs that `XmlElement.clone()` drops). Read the comments on `onLoadDocument` in `server/auth-realtime/src/hocuspocus.ts` and on `applyMergeToOpenDocument` in `server/auth-realtime/src/routes.ts` before touching this area.
- **The main branch is identified by its `"default"` flag, not by its name.** The tree join and `FetchMainBranchContent` key on the flag; `UpdateDocumentBranch` refuses to rename the default branch so the two never disagree. Anything that creates a document's first branch — `NewDocument`, `Duplicate` — must set `Default: true`, and forks must not.
- **Maintainers accumulate; nothing removes them.** The `maintainers` field on a branch update is *who edited in that persist*, not the document's maintainer set — auth-realtime's store hook sends the current session's editors and system writes send none. `UpsertDocumentMaintainers` only ever adds, so a diff against the stored set would drop everyone not editing at that moment. Removing a maintainer needs a path of its own; there is none today.
- Branch merging: `PUT /documents/:documentId/merge` is handled by auth-realtime, which proxies to core, then **directly mutates the in-memory target-branch Y.Doc** via `replaceYdocContent` (not `applyUpdate`) and immediately persists `rawContent` back through `/api/x/.../branch/:branchId` so a server restart can't reset the clientID and duplicate content on reconnect.
@ -253,11 +255,19 @@ change to a schema or a tool description has to be deliberate enough to update
that file — a silently altered description degrades the assistant in ways no
other test catches.
## Node formatting & TS
## auth-realtime
Prettier uses **tabs (width 8)**, no semicolons, trailing commas, double-quoted strings — see [auth-realtime/prettier.config.js](auth-realtime/prettier.config.js). ESLint extends `@eslint/js` recommended + `typescript-eslint` **strict** + **stylistic** + `eslint-config-prettier`; `@typescript-eslint/no-explicit-any` is **off**. See [auth-realtime/eslint.config.mjs](auth-realtime/eslint.config.mjs).
The service has its own [CLAUDE.md](auth-realtime/CLAUDE.md) covering its
composition-root structure, zod-parsed environment, type-aware lint setup, and
testing conventions. Two things worth knowing from here:
TypeScript is `module: NodeNext`, `target: ESNext`, fully strict — `strict`, `noImplicitAny`, `noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexedAccess`, `noImplicitOverride`, `verbatimModuleSyntax` all on. See [auth-realtime/tsconfig.json](auth-realtime/tsconfig.json). Path alias `@/*` points to `server/auth-realtime/src/*`.
**ES module imports must include the explicit `.js` extension** (NodeNext + ESM requirement), even when importing TypeScript files — e.g. `import { foo } from "./bar.js"` for a `./bar.ts` source file.
- **`src/index.ts` is the only module with side effects.** Everything else is
a factory taking its dependencies as an argument — which is what lets the
service be tested without a database, a redis, or a port. A change that
reads `process.env` or opens a connection outside `index.ts` breaks that.
- Prettier uses **tabs at width 8** (matching gofmt's convention on this side
of the repo), no semicolons, trailing commas, double quotes. ESLint runs
type-aware, and **ES module imports must include the explicit `.js`
extension** (NodeNext + ESM), even when importing TypeScript files — e.g.
`import { foo } from "./bar.js"` for a `./bar.ts` source file.

View File

@ -0,0 +1,338 @@
# CLAUDE.md
Guidance for `server/auth-realtime`. Shared working principles and the TS/JS
comment/whitespace rules live in the root [CLAUDE.md](../../CLAUDE.md);
backend architecture, the Caddy/port layout, and the document-storage model
live in [server/CLAUDE.md](../CLAUDE.md).
## Project
`@oxynote/auth-realtime` — a TypeScript ES-module service running Better Auth
and a Hocuspocus (Yjs) server in a single Hono process on port `8081`. It is a
thin layer: it owns sessions, the realtime document connection, and the
service-to-service endpoints core calls back into. Everything about documents
themselves belongs to core.
## Common commands
Package manager is **pnpm** (own workspace).
```bash
pnpm install # install dependencies
pnpm dev # tsx watch, sentry preloaded via --import
pnpm build # tsc -p tsconfig.build.json -> dist/
pnpm start # run the built service
pnpm check-lint # check-types + check-eslint + check-fmt +
# check-knip (read-only)
pnpm lint # fixing variant: check-types + knip --fix
# (removes dead exports/files!) + eslint --fix +
# prettier --write
pnpm test # vitest run --coverage
pnpm test-watch # vitest in watch mode
pnpm qa # check-lint + test; qa-fix = lint + test
```
## Composition root
**`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, or
listens on a port. Everything 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
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
interface
reporting.ts reported() / bestEffort(), the two ways a failure reaches
sentry
headers.ts normalizing whatever shape a transport hands over
auth.ts createAuth(deps) + the callbacks better-auth invokes
hocuspocus.ts createHocuspocus(deps) / createDocumentHooks(deps)
routes.ts createRoutes(deps) -> a Hono app
operations.ts pure: applying the assistant's edit ops to a Y.Doc
ydocument.ts pure: replacing a Y.Doc's content without CRDT merging
schema/ the ProseMirror schema, mirroring web's tiptap extensions
```
Rules that follow, and that a change must not erode:
- **A module never reaches for `process.env`.** It takes an `Env`. `loadEnv`
is called once, in `index.ts`.
- **A factory takes its dependencies as an argument**, narrowed to what it
actually uses. `createDocumentHooks` asks for a `SessionResolver` with one
method rather than the whole better-auth instance, and `createRoutes` asks
for a `DocumentRegistry` rather than the whole Hocuspocus server. That
narrowing is what lets a test drive them with a two-line stub.
- **A callback a framework will invoke is a named function, not an inline
arrow**, whenever the framework does not hand it back. The organization
plugin keeps its hooks to itself, so they live in
`createOrganizationHooks`; better-auth exposes `auth.options`, so the
email callbacks stay inline and the tests reach them there.
- Module-level mutable state belongs in the factory's closure (the maintainer
map in `createDocumentHooks`, the cached JWKS in `createRoutes`), never at
module scope, so nothing carries over between servers — or between tests.
- **No query builder leaves [db.ts](src/db.ts).** Every query the service
runs is a method on `Store`, named for what it answers rather than the SQL
it builds (`hasOAuthConsent`, `userOrganizationId`). A caller takes the
`Store`, so a table or column rename touches one file and a test stubs four
methods instead of a chain of builder calls.
- **A failure reaches sentry through
[reporting.ts](src/reporting.ts), not an inline `catch`.** `reported(fn)`
reports and rethrows, for work whose failure the caller must still see —
the frameworks this service hands callbacks to swallow the cause, so
without it a failed hook leaves only a status code. `bestEffort(fn)` reports
and swallows, for an effect whose failure must not fail work that has
already succeeded (persisting rawContent after a document is already
seeded, closing a direct connection). A `catch` that also builds a response
stays written out, because neither helper can express that.
`src/sentry.ts` is the exception: node loads it through `--import` before the
app graph exists, so it reads `process.env` directly and must not import
anything of ours. Sentry's own docs call this file `instrument.js` — the name
comes from "instrumentation", the APM term for the monkey-patching the SDK
does at load — but nothing outside `package.json` and the Dockerfile's `CMD`
depends on the filename, and `sentry.ts` matches how the rest of the
repository names its Sentry setup (`web/sentry.client.config.ts`,
`web/sentry.server.config.ts`).
## Environment
Every variable is declared in [src/env.ts](src/env.ts) and parsed once at boot
with zod. **A missing, malformed, or half-configured value is a boot error** —
the same policy core applies to its own env — so nothing surfaces later as an
`undefined` deep inside better-auth. Specifically:
- URLs are scheme-pinned to http/https: bare `z.url()` accepts `core:8080` as
a valid URL with a `core:` scheme, which would boot and then fail on use.
- Counters must be positive integers; a typo is an error rather than a `NaN`
limit that compares false against every count.
- Boolean flags accept only `"true"` and `"false"`, so a flag spelled `1`
cannot read as false while the operator believes it is set.
- A social provider needs both halves of its credentials or neither. One half
is a boot error rather than a broken OAuth redirect a user discovers.
- Docker's env files list every variable including the blank ones, so an
unset value arrives as `""`. `loadEnv` drops those before parsing, which is
what makes a required variable report as missing rather than empty.
Adding a variable means adding it to the schema, to the `Env` interface, and
to `docker/env/auth-realtime.example.env`.
## Formatting & TS
Prettier uses **tabs at width 8** (matching gofmt's convention on the backend
side), no semicolons, trailing commas, double quotes — see
[prettier.config.js](prettier.config.js).
ESLint ([eslint.config.mjs](eslint.config.mjs)) runs **type-aware**:
`eslint:recommended` plus typescript-eslint **strictTypeChecked** and
**stylisticTypeChecked**, resolved through the tsconfig by `projectService`.
Consequences worth knowing:
- Linting builds a full TS program, so a cold run takes a few seconds. Runs
are cached (`node_modules/.cache/eslint`); after cross-file type changes a
cached file can report stale results locally, and `rm -rf
node_modules/.cache/eslint` forces a full run. CI always runs cold.
- Root config files (`eslint.config.mjs`, `knip.ts`, `prettier.config.js`,
`vitest.config.ts`) sit outside the tsconfig project and opt out of typed
linting; the two type-aware rule overrides live in the `src/**` block for
the same reason, since applying them to an untyped file crashes the run.
- Every `eslint-disable` **must** state a reason after `--`. It is for false
positives only, never to avoid a fix. Stale disables are errors
(`reportUnusedDisableDirectives`), and eslint runs with `--max-warnings 0`.
- `@typescript-eslint/no-explicit-any` is off. The `no-unsafe-*` family is
**on**, and the codebase answers it by typing the boundary rather than
relaxing the rule: untrusted JSON is read as `unknown` and narrowed
(`readString`, `upstreamResponse`, `seedContent`), and tiptap's
`any`-typed extension API gets an explicit cast at each use, the same way
[web](../../web/app/components/editor/blocks/) does it.
TypeScript is `module: NodeNext`, `target: ESNext`, fully strict, with
`noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexedAccess`,
`noImplicitOverride`, `verbatimModuleSyntax`, and `allowUnreachableCode:
false`. Two configs: [tsconfig.json](tsconfig.json) covers all of `src/`
(tests included) for `check-types` and eslint;
[tsconfig.build.json](tsconfig.build.json) excludes the test files so `pnpm
build` cannot emit them into `dist/`.
**ES module imports must include the explicit `.js` extension** (NodeNext +
ESM requirement), even when importing TypeScript files — e.g. `import { foo }
from "./bar.js"` for a `./bar.ts` source file.
**knip** ([knip.ts](knip.ts)) guards dead exports, unused files, and unused
dependencies. `ignoreExportsUsedInFile` is on for interfaces and types only: a
type exported so another export in the same file can name it — a union
member, a table in the `Database` map, a factory's dependency bag — is part
of that file's contract, not a dead export. Values are deliberately left out,
because an exported const nobody imports is worth hearing about. Knip runs
with `--cache`, and the cache is keyed by knip version, **not** config —
after editing `knip.ts`, run `rm -rf node_modules/.cache/knip` or results are
stale.
## Testing
### Layout
- **Tests are co-located**, paired 1:1 with the file they test:
`ydocument.ts` → `ydocument.test.ts`. Never a parallel `tests/` mirror tree.
- **One environment, one suffix.** The service is plain node — no DOM, no
framework runtime — so every test is `.test.ts` running in vitest's node
environment. There is no suffix taxonomy to learn.
- When a test exercises behaviour spanning two source files, it lives with
the file that initiates the behaviour.
- **The test file mirrors the source file's order**: top-level `describe`
blocks appear in the same order as the functions they test appear in the
source, so the two files read side by side.
- Helpers used by more than one suite live in
[src/test-helpers.ts](src/test-helpers.ts) (`stubCore`, `stubStore`,
`testEnv`, `fragmentXml`). It is excluded from coverage and from the build.
Fixtures used by one suite stay file-local.
- [db.test.ts](src/db.test.ts) is the only suite that knows kysely builds a
query by chaining. It stubs the builder to pin the table and column names
each `Store` method touches; every other suite takes a `stubStore()`.
- **A yjs type must be added to a document before a test reads from it.**
A detached `Y.XmlElement` or `Y.XmlText` keeps its attributes and children
in prelim state, invisible to the public getters, and yjs answers the read
with a console warning rather than an error — so the fixture is silently
wrong and the test still passes. Build fixtures through an `attached()`
helper (see `ydocument.test.ts` and `operations.test.ts`).
[src/test-setup.ts](src/test-setup.ts) turns that warning into a failure so
it cannot be missed: vitest prints intercepted stderr only under the
verbose reporter, so in a piped or CI run the warning is invisible.
- `src/schema/` is out of scope for the suite: it is a declarative mirror of
web's tiptap extension list, and the behaviour that depends on it is
covered through the transformer in `ydocument` and `operations`. It is
excluded from coverage.
### Naming
**The rule: `describe` names the subject, `it` completes the sentence
"it …" with observable behaviour.**
```ts
describe("resolveBranchId", () => {
it("returns a concrete branch identifier without asking core")
it("resolves 'default' to the branch core flags as default")
it("throws when no branch is flagged as default")
})
```
Read it aloud: "resolveBranchId throws when no branch is flagged as default."
If the concatenation isn't a grammatical sentence, the name is off — reporters
print exactly that concatenation on failure.
- Every test file wraps its tests in a root `describe` naming the unit by its
real, greppable identifier. Never bare top-level `it` calls.
- Present tense, third person, **no "should"**.
- State behaviour, not implementation: `it("propagates a failed teardown so
the organization survives")`, not `it("calls core.teardownOrganization")`.
The name should survive a behaviour-preserving refactor.
- Be specific enough that a failure is informative. `it("works")` and
`it("handles errors")` are useless.
- One level of `describe` nesting is usually right, two is the ceiling.
### Parameterized tests
**Use `it.for` wherever cases differ only in inputs and expected outputs.**
The moment cases differ in setup, mock wiring, or assertions, they are
separate `it`s — divergent cases in a shared callback breed conditionals, and
neither a breakpoint nor a reporter line can target a single row.
- Case objects use the same field vocabulary everywhere: `name`, `input`,
`expected`, and the title interpolates them (`"rejects $name"`).
- Always `it.for`, never `it.each`: `.for` passes the test context as the
second callback argument, which the concurrency rule below requires.
- Hoist bulky case data into named builders above the table.
### Mocks & path coverage
- **Every exported function is covered** — a module's exports are its
testable contract.
- **Every path gets a test**: the success path and each failure path,
including one per collaborator that can fail. Name them after the
observable behaviour (`it("propagates a failed content fetch")`).
- **Dependencies are injected, not module-mocked.** The factory pattern above
exists so a test can pass a stub; module mocks are file-level singletons
that cannot vary per test and force suites out of concurrent execution.
[reporting.test.ts](src/reporting.test.ts) is the one exception, and shows
what one costs: sentry is a global reporter rather than an injected
dependency, so asserting that a failure reaches it needs
`vi.mock("@sentry/node")`, which in turn needs `{ concurrent: false }` on
every describe that counts its calls and a `beforeEach` clearing it —
vitest's `restoreMocks` does not reset a hand-made `vi.fn()`. Everywhere
else, `reported` and `bestEffort` are asserted through what they do to the
caller (rethrow or swallow), not through sentry.
- **No real IO.** No network, no database, no redis, no port. `createAuth` is
exercised against a lazily-constructed pg pool that is never dialled, and
everything else takes a stub.
- **One act per test**: arrange once, invoke the unit once, then assert as
many facets of that single outcome as needed. Needing a second invocation
is the real "and" smell — split the test.
- **Account for every injected dependency**: assert the calls that must have
happened (count and arguments) *and* the zero counts of the ones that must
not. `expect(core.fetchBranches).toHaveBeenCalledTimes(0)` after a
rejected connection is as load-bearing as any positive assertion.
- Test through the module's public exports. Never export something solely for
tests — an internal complex enough to need direct tests is extracted into
its own module (which is how `createOrganizationHooks` came to exist).
- Plain `expect` for guards and preconditions; `expect.soft` is permitted in
a final outcome-accounting block, where it reports the whole broken
accounting at once.
- A branch that genuinely cannot be reproduced carries a `// NOCOV: <lowercase
reason>.` comment as the first line inside the branch body. NOCOV is a
reviewer covenant, not a tool directive — it is deliberately not wired to
the coverage provider (whose ignore hints need the banned `/* */` comment
form), so coverage reports stay truthful and the marker explains the
residual gap in place. It never excuses a testable path.
### Independence & concurrency
- **Every test is completely independent**: it creates its own fresh state (a
new `Y.Doc`, a new stub, a new app) and never depends on execution order.
- **All tests run concurrently** (`sequence.concurrent: true`). Under
concurrency, assertions must use the `expect` from the local test context —
`it("…", ({ expect }) => …)` — because the global `expect` cannot reliably
attribute a failure to the right test when tests interleave. This applies
to every test in the suite, `it.for` rows included.
- Mock and global state cannot leak: the config sets `restoreMocks`,
`unstubGlobals`, and `unstubEnvs`, so spies, stubbed globals, and env stubs
are restored after every test without per-file cleanup.
### Determinism
- No `setTimeout`-based waiting. Await concrete signals — a promise the unit
returns, an emitted event.
- Time is driven, not waited on: `vi.useFakeTimers()` +
`advanceTimersByTime()`, `vi.setSystemTime()` for deterministic timestamps.
Assert exact values, never wall-clock deltas.
### Coverage
`pnpm test` runs with coverage and **fails under the thresholds** in
[vitest.config.ts](vitest.config.ts). They are set from the measured baseline
and ratcheted up as suites grow — **never lowered**. 100% is not the target:
genuinely untestable branches stay visible in the report, marked with NOCOV
in place. `src/index.ts` is excluded because evaluating it *is* starting the
service; what it wires together is covered through the factories it calls.
## Yjs
Read the document-storage section of [server/CLAUDE.md](../CLAUDE.md) before
touching branch content. The rule that matters most here: **never seed or
merge one Y.Doc from another with `Y.applyUpdate`** — the differing
`clientID`s CRDT-merge and silently duplicate every block. Use
`replaceYdocContent` ([src/ydocument.ts](src/ydocument.ts)), and persist
`rawContent` immediately after replacing it, so a restart cannot rebuild the
document under a new clientID. Both invariants are pinned by tests; if you
change that code, the tests in `ydocument.test.ts`, `hocuspocus.test.ts` and
`routes.test.ts` are the specification.
## Better Auth schema
The core migrations own all tables, including Better Auth's. The generated
`sql/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`. See the "Database" section of [server/CLAUDE.md](../CLAUDE.md).

View File

@ -9,7 +9,8 @@ WORKDIR /server
# pnpm-workspace.yaml carries the allowBuilds settings — without it the
# install refuses to run the esbuild install script.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json src ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json tsconfig.build.json ./
COPY src ./src
RUN pnpm install --frozen-lockfile && \
pnpm run build && \
@ -29,4 +30,4 @@ ENV NODE_ENV=production
USER hono
EXPOSE 8081
CMD ["node", "--import", "/server/dist/instrument.js", "/server/dist/index.js"]
CMD ["node", "--import", "/server/dist/sentry.js", "/server/dist/index.js"]

View File

@ -2,19 +2,114 @@
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.strict,
tseslint.configs.stylistic,
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 service'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,17 @@
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, a table in the Database map, a dependency bag on a
// factory — 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

@ -3,16 +3,22 @@
"type": "module",
"packageManager": "pnpm@11.1.3",
"scripts": {
"dev": "tsx watch --import ./src/instrument.ts src/index.ts",
"build": "tsc",
"start": "node --import ./dist/instrument.js dist/index.js",
"dev": "tsx watch --import ./src/sentry.ts src/index.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node --import ./dist/sentry.js dist/index.js",
"check-types": "tsc --noEmit",
"lint": "eslint --fix .",
"check-lint": "eslint .",
"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 .",
"qa": "pnpm run check-types && pnpm run check-lint && pnpm run check-fmt",
"qa-fix": "pnpm run check-types && pnpm run lint && pnpm run fmt"
"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": {
"@better-auth/electron": "^1.7.1",
@ -48,17 +54,22 @@
"nanoid": "^5.1.6",
"pg": "^8.16.3",
"redis": "^5.8.3",
"yjs": "^13.6.27"
"yjs": "^13.6.27",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/node": "^20.11.17",
"@types/pg": "^8.15.5",
"@vitest/coverage-v8": "^4.1.11",
"@vitest/eslint-plugin": "^1.6.27",
"eslint": "^9.33.0",
"eslint-config-prettier": "^10.1.8",
"knip": "^6.32.2",
"prettier": "3.6.2",
"tsx": "^4.7.1",
"typescript": "^5.9.2",
"typescript-eslint": "^8.40.0"
"typescript-eslint": "^8.40.0",
"vitest": "^4.1.11"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,760 @@
import { describe, it, vi } from "vitest"
import {
authMethods,
createAuth,
createOrganizationHooks,
createSecondaryStorage,
electronCallbackOverride,
invitationLink,
organizationClaims,
toPublicAuthUrl,
type SecondaryStorageClient,
} from "./auth.js"
import { createDatabase, type Store } from "./db.js"
import { stubCore, stubStore, testEnv, type StubCore } from "./test-helpers.js"
function stubRedis() {
return {
get: vi.fn().mockResolvedValue(null),
getDel: vi.fn().mockResolvedValue(null),
incr: vi.fn().mockResolvedValue(1),
expire: vi.fn().mockResolvedValue(1),
set: vi.fn().mockResolvedValue("OK"),
del: vi.fn().mockResolvedValue(1),
}
}
const GOOGLE = {
clientId: "google-id",
clientSecret: "google-secret",
}
// a real auth instance, built against a lazy pg pool that is never dialled.
// Its `options` are what better-auth would invoke at runtime, so the
// callbacks are asserted exactly as wired.
function buildAuth(
overrides: {
env?: Parameters<typeof createAuth>[0]["env"]
core?: StubCore
redis?: SecondaryStorageClient
store?: Store
} = {},
) {
const real = createDatabase("postgresql://u:p@localhost/d")
const core = overrides.core ?? stubCore()
const auth = createAuth({
env: overrides.env ?? testEnv(),
store: overrides.store ?? stubStore(),
dialect: real.dialect,
redis: overrides.redis ?? stubRedis(),
core,
})
return { auth, core }
}
describe("authMethods", () => {
it("always offers email and password", ({ expect }) => {
expect(authMethods(testEnv())).toEqual(["email-password"])
})
it("offers each provider that has credentials", ({ expect }) => {
const methods = authMethods(
testEnv({
socialProviders: {
google: GOOGLE,
slack: {
clientId: "slack-id",
clientSecret: "slack-secret",
},
},
}),
)
expect(methods).toEqual(["email-password", "google", "slack"])
})
})
describe("toPublicAuthUrl", () => {
// better-auth builds links from the bare origin, but the browser can
// only reach this service behind the proxy's /auth-realtime prefix
it("rewrites a link built on the bare origin onto the public prefix", ({
expect,
}) => {
const url = toPublicAuthUrl(
testEnv(),
"http://localhost:8080/api/auth/reset-password/tok",
)
expect(url).toBe(
"http://localhost:8080/auth-realtime/api/auth/reset-password/tok",
)
})
it("leaves a link on another origin alone", ({ expect }) => {
const url = toPublicAuthUrl(testEnv(), "http://elsewhere/reset")
expect(url).toBe("http://elsewhere/reset")
})
})
describe("electronCallbackOverride", () => {
const electronContext = {
path: "/sign-in/social",
query: { client_id: "electron" },
body: { provider: "google" },
}
// the electron plugin sends no callbackURL, so better-auth would
// redirect to the server root after the OAuth callback
it("redirects the desktop OAuth proxy to the handoff page", ({
expect,
}) => {
const override = electronCallbackOverride(
electronContext,
"http://localhost:8080",
)
expect(override).toEqual({
context: {
body: {
provider: "google",
callbackURL:
"http://localhost:8080/desktop-auth",
},
},
})
})
it.for([
{
name: "another path",
input: { ...electronContext, path: "/sign-in/email" },
},
{
name: "a non-electron client",
input: {
...electronContext,
query: { client_id: "web" },
},
},
{
name: "no query at all",
input: { ...electronContext, query: null },
},
{
name: "a callback the caller already chose",
input: {
...electronContext,
body: {
provider: "google",
callbackURL:
"http://localhost:8080/back",
},
},
},
])("leaves $name untouched", ({ input }, { expect }) => {
expect(
electronCallbackOverride(
input,
"http://localhost:8080",
),
).toBeUndefined()
})
})
describe("invitationLink", () => {
it("carries everything the accept-invite page renders before sign-in", ({
expect,
}) => {
const link = invitationLink(testEnv(), {
id: "inv-1",
email: "a@b.c",
inviter: { user: { name: "Ada" } },
organization: { id: "org-1", name: "Acme" },
})
expect(link).toBe(
"http://localhost:8080/accept-invite?id=inv-1&email=a@b.c&inviter=Ada&orgName=Acme&orgId=org-1",
)
})
})
describe("organizationClaims", () => {
it("binds the token to the user's organization", async ({ expect }) => {
const store = stubStore()
store.userOrganizationId.mockResolvedValue("org-1")
expect(
await organizationClaims(store, { id: "user-1" }),
).toEqual({ org_id: "org-1" })
})
it("claims nothing for a user in no organization", async ({
expect,
}) => {
const store = stubStore()
store.userOrganizationId.mockResolvedValue(null)
expect(
await organizationClaims(store, { id: "user-1" }),
).toEqual({})
})
it("claims nothing when the token has no user", async ({ expect }) => {
const store = stubStore()
expect(await organizationClaims(store, null)).toEqual({})
expect(store.userOrganizationId).toHaveBeenCalledTimes(0)
})
})
describe("createOrganizationHooks", () => {
describe("canCreateOrganization", () => {
it.for([
{ name: "below the limit", input: 99, expected: true },
{ name: "at the limit", input: 100, expected: false },
{ name: "past the limit", input: 101, expected: false },
])(
"answers $expected with the count $name",
async ({ input, expected }, { expect }) => {
const store = stubStore()
store.totalOrganizationCount.mockResolvedValue(
input,
)
const hooks = createOrganizationHooks({
env: testEnv({ maxOrganizations: 100 }),
store,
core: stubCore(),
})
expect(
await hooks.canCreateOrganization(),
).toBe(expected)
},
)
})
describe("afterCreateOrganization", () => {
it("asks core to initialize the new organization", async ({
expect,
}) => {
const core = stubCore()
const hooks = createOrganizationHooks({
env: testEnv(),
store: stubStore(),
core,
})
await hooks.afterCreateOrganization({
organization: { id: "org-1" },
})
expect(
core.initializeOrganization,
).toHaveBeenCalledWith("org-1")
})
})
describe("beforeDeleteOrganization", () => {
it("tears the organization down in core first", async ({
expect,
}) => {
const core = stubCore()
const hooks = createOrganizationHooks({
env: testEnv(),
store: stubStore(),
core,
})
await hooks.beforeDeleteOrganization({
organization: { id: "org-1" },
})
expect(core.teardownOrganization).toHaveBeenCalledWith(
"org-1",
)
})
// better-auth aborts the deletion on a throw, which is what
// keeps a half-torn-down organization from being deleted and
// orphaning everything core owns for it
it("propagates a failed teardown so the organization survives", async ({
expect,
}) => {
const failure = new Error("core unreachable")
const core = stubCore()
core.teardownOrganization.mockRejectedValue(failure)
const hooks = createOrganizationHooks({
env: testEnv(),
store: stubStore(),
core,
})
await expect(
hooks.beforeDeleteOrganization({
organization: { id: "org-1" },
}),
).rejects.toBe(failure)
})
})
describe("sendInvitationEmail", () => {
it("emails the invitee a link to the organization's invite page", async ({
expect,
}) => {
const core = stubCore()
const hooks = createOrganizationHooks({
env: testEnv(),
store: stubStore(),
core,
})
await hooks.sendInvitationEmail({
id: "inv-1",
email: "a@b.c",
inviter: { user: { name: "Ada" } },
organization: { id: "org-1", name: "Acme" },
})
expect(core.sendEmail).toHaveBeenCalledWith(
"organization_invitation",
{
email: "a@b.c",
organization: "Acme",
link: "http://localhost:8080/accept-invite?id=inv-1&email=a@b.c&inviter=Ada&orgName=Acme&orgId=org-1",
},
)
})
})
})
describe("createSecondaryStorage", () => {
it("reads through to the redis client", async ({ expect }) => {
const redis = stubRedis()
redis.get.mockResolvedValue("value")
expect(await createSecondaryStorage(redis).get("key")).toBe(
"value",
)
expect(redis.get).toHaveBeenCalledWith("key")
})
it("deletes on read for getAndDelete", async ({ expect }) => {
const redis = stubRedis()
redis.getDel.mockResolvedValue("value")
expect(
await createSecondaryStorage(redis).getAndDelete("key"),
).toBe("value")
expect(redis.getDel).toHaveBeenCalledWith("key")
expect(redis.get).toHaveBeenCalledTimes(0)
})
// NX applies the TTL only to a key that has none, so the window is
// fixed from creation and later increments cannot extend it
it("expires a counter a fixed window after its first increment", async ({
expect,
}) => {
const redis = stubRedis()
redis.incr.mockResolvedValue(3)
expect(
await createSecondaryStorage(redis).increment(
"key",
60,
),
).toBe(3)
expect(redis.expire).toHaveBeenCalledWith("key", 60, "NX")
})
it("writes with an expiry when given a ttl", async ({ expect }) => {
const redis = stubRedis()
await createSecondaryStorage(redis).set("key", "value", 60)
expect(redis.set).toHaveBeenCalledWith("key", "value", {
EX: 60,
})
})
it("writes without an expiry when given no ttl", async ({ expect }) => {
const redis = stubRedis()
await createSecondaryStorage(redis).set("key", "value")
expect(redis.set).toHaveBeenCalledWith("key", "value")
})
it("deletes the key", async ({ expect }) => {
const redis = stubRedis()
await createSecondaryStorage(redis).delete("key")
expect(redis.del).toHaveBeenCalledWith("key")
})
it.for([
{ name: "get", input: "get" as const },
{ name: "getAndDelete", input: "getDel" as const },
{ name: "increment", input: "incr" as const },
{ name: "set", input: "set" as const },
{ name: "delete", input: "del" as const },
])(
"propagates a redis failure from $name",
async ({ input }, { expect }) => {
const failure = new Error("redis unreachable")
const redis = stubRedis()
redis[input].mockRejectedValue(failure)
const storage = createSecondaryStorage(redis)
const calls = {
get: () => storage.get("key"),
getDel: () => storage.getAndDelete("key"),
incr: () => storage.increment("key", 60),
set: () => storage.set("key", "value"),
del: () => storage.delete("key"),
}
await expect(calls[input]()).rejects.toBe(failure)
},
)
})
describe("createAuth", () => {
it("matches on the bare origin so the proxy's stripped prefix lines up", ({
expect,
}) => {
const { auth } = buildAuth()
expect(auth.options.baseURL).toBe("http://localhost:8080")
expect(auth.options.basePath).toBe("/api/auth")
})
it("registers no social provider without credentials", ({ expect }) => {
const { auth } = buildAuth()
expect(Object.keys(auth.options.socialProviders)).toEqual([])
})
// a provider registered with half its credentials would send users
// into a broken OAuth redirect instead of failing at sign-in
it("registers a configured provider with a browser-reachable redirect", ({
expect,
}) => {
const { auth } = buildAuth({
env: testEnv({ socialProviders: { google: GOOGLE } }),
})
expect(auth.options.socialProviders.google).toEqual({
clientId: "google-id",
clientSecret: "google-secret",
redirectURI:
"http://localhost:8080/auth-realtime/api/auth/callback/google",
})
})
it.for([
{ name: "enabled", input: true, expected: true },
{ name: "disabled", input: false, expected: false },
])(
"leaves rate limiting $name as configured",
({ input, expected }, { expect }) => {
const { auth } = buildAuth({
env: testEnv({ rateLimitEnabled: input }),
})
expect(auth.options.rateLimit.enabled).toBe(expected)
},
)
it("trusts the configured origins", ({ expect }) => {
const { auth } = buildAuth({
env: testEnv({
trustedOrigins: [
"http://localhost:8080",
"oxynote://",
],
}),
})
expect(auth.options.trustedOrigins).toEqual([
"http://localhost:8080",
"oxynote://",
])
})
describe("email callbacks", () => {
it("sends a password reset on the public link", async ({
expect,
}) => {
const { auth, core } = buildAuth()
await auth.options.emailAndPassword.sendResetPassword({
user: { email: "a@b.c" },
url: "http://localhost:8080/api/auth/reset/tok",
token: "tok",
} as never)
expect(core.sendEmail).toHaveBeenCalledWith(
"password_reset",
{
email: "a@b.c",
link: "http://localhost:8080/auth-realtime/api/auth/reset/tok",
},
)
})
// a duplicate signup gets better-auth's synthetic success so a
// browser cannot probe which addresses have accounts; the real
// owner is told through their inbox
it("tells the existing owner rather than the browser about a duplicate signup", async ({
expect,
}) => {
const { auth, core } = buildAuth()
await auth.options.emailAndPassword.onExistingUserSignUp(
{ user: { email: "a@b.c" } } as never,
)
expect(core.sendEmail).toHaveBeenCalledWith(
"account_exists",
{
email: "a@b.c",
link: "http://localhost:8080/login",
},
)
})
it("sends the signup verification on the public link", async ({
expect,
}) => {
const { auth, core } = buildAuth()
await auth.options.emailVerification.sendVerificationEmail(
{
user: { email: "a@b.c" },
url: "http://localhost:8080/api/auth/verify/tok",
token: "tok",
} as never,
)
expect(core.sendEmail).toHaveBeenCalledWith(
"signup_verification",
{
email: "a@b.c",
link: "http://localhost:8080/auth-realtime/api/auth/verify/tok",
},
)
})
it("confirms a change of address at the new address", async ({
expect,
}) => {
const { auth, core } = buildAuth()
await auth.options.user.changeEmail.sendChangeEmailConfirmation(
{
user: { email: "old@b.c" },
newEmail: "new@b.c",
url: "http://localhost:8080/api/auth/change/tok",
token: "tok",
} as never,
)
expect(core.sendEmail).toHaveBeenCalledWith(
"email_verification",
{
email: "new@b.c",
link: "http://localhost:8080/auth-realtime/api/auth/change/tok",
},
)
})
it("confirms an account deletion by email", async ({
expect,
}) => {
const { auth, core } = buildAuth()
await auth.options.user.deleteUser.sendDeleteAccountVerification(
{
user: { email: "a@b.c" },
url: "http://localhost:8080/api/auth/delete/tok",
token: "tok",
} as never,
)
expect(core.sendEmail).toHaveBeenCalledWith(
"user_deletion",
{
email: "a@b.c",
link: "http://localhost:8080/auth-realtime/api/auth/delete/tok",
},
)
})
it("welcomes a newly created user", async ({ expect }) => {
const { auth, core } = buildAuth()
await auth.options.databaseHooks.user.create.after({
email: "a@b.c",
} as never)
expect(core.sendEmail).toHaveBeenCalledWith(
"user_creation",
{ email: "a@b.c" },
)
})
it("propagates a failed send so better-auth fails the request", async ({
expect,
}) => {
const failure = new Error("core unreachable")
const core = stubCore()
core.sendEmail.mockRejectedValue(failure)
const { auth } = buildAuth({ core })
await expect(
auth.options.emailAndPassword.sendResetPassword(
{
user: { email: "a@b.c" },
url: "http://localhost:8080/api/auth/reset/tok",
token: "tok",
} as never,
),
).rejects.toBe(failure)
})
})
// core scopes every request by the session's organization, so a
// session that reaches the database without one is unusable
describe("session organization binding", () => {
it("stamps the user's organization onto a new session", async ({
expect,
}) => {
const store = stubStore()
store.userOrganizationId.mockResolvedValue("org-1")
const { auth } = buildAuth({ store })
const result =
await auth.options.databaseHooks.session.create.before(
{
userId: "user-1",
token: "t",
} as never,
)
expect(result).toMatchObject({
data: {
userId: "user-1",
activeOrganizationId: "org-1",
},
})
})
it("stamps null when the user belongs to no organization", async ({
expect,
}) => {
const store = stubStore()
store.userOrganizationId.mockResolvedValue(null)
const { auth } = buildAuth({ store })
const result =
await auth.options.databaseHooks.session.create.before(
{
userId: "user-1",
token: "t",
} as never,
)
expect(result).toMatchObject({
data: { activeOrganizationId: null },
})
})
it("re-resolves the organization when a session is updated", async ({
expect,
}) => {
const store = stubStore()
store.userOrganizationId.mockResolvedValue("org-2")
const { auth } = buildAuth({ store })
const result =
await auth.options.databaseHooks.session.update.before(
{ token: "t" } as never,
{
context: {
session: {
user: {
id: "user-1",
},
},
},
} as never,
)
expect(result).toMatchObject({
data: { activeOrganizationId: "org-2" },
})
})
it("leaves an update with no session context untouched", async ({
expect,
}) => {
const store = stubStore()
const { auth } = buildAuth({ store })
const result =
await auth.options.databaseHooks.session.update.before(
{ token: "t" } as never,
undefined as never,
)
expect(result).toEqual({ data: { token: "t" } })
expect(store.userOrganizationId).toHaveBeenCalledTimes(
0,
)
})
it("propagates a failed organization lookup", async ({
expect,
}) => {
const store = stubStore()
store.userOrganizationId.mockRejectedValue(
new Error("connection terminated"),
)
const { auth } = buildAuth({ store })
await expect(
auth.options.databaseHooks.session.create.before(
{
userId: "user-1",
token: "t",
} as never,
),
).rejects.toThrow("connection terminated")
})
})
describe("password policy", () => {
it("requires a verified address and a long passphrase", ({
expect,
}) => {
const { auth } = buildAuth()
expect(
auth.options.emailAndPassword
.requireEmailVerification,
).toBe(true)
expect(
auth.options.emailAndPassword.minPasswordLength,
).toBe(16)
// a reset happens outside any session, so every session
// of the account goes with it
expect(
auth.options.emailAndPassword
.revokeSessionsOnPasswordReset,
).toBe(true)
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,257 @@
import { describe, it, vi } from "vitest"
import { AxiosHeaders } from "axios"
import { createCoreClient, type HttpResponse } from "./core.js"
const BASE_URL = "http://core:8080"
function stubHttp(response: HttpResponse = { status: 200, data: null }) {
return {
get: vi.fn().mockResolvedValue(response),
post: vi.fn().mockResolvedValue(response),
put: vi.fn().mockResolvedValue(response),
}
}
describe("createCoreClient", () => {
describe("sendEmail", () => {
it("posts the template and its data to core's internal email endpoint", async ({
expect,
}) => {
const http = stubHttp()
await createCoreClient(BASE_URL, http).sendEmail(
"password_reset",
{ email: "a@b.c", link: "http://link" },
)
expect(http.post).toHaveBeenCalledTimes(1)
expect(http.post).toHaveBeenCalledWith(
"http://core:8080/api/x/email",
{
template: "password_reset",
data: {
email: "a@b.c",
link: "http://link",
},
},
)
expect.soft(http.get).toHaveBeenCalledTimes(0)
expect.soft(http.put).toHaveBeenCalledTimes(0)
})
it("propagates the transport's rejection untouched", async ({
expect,
}) => {
const failure = new Error("connect ECONNREFUSED")
const http = stubHttp()
http.post.mockRejectedValue(failure)
await expect(
createCoreClient(BASE_URL, http).sendEmail(
"user_creation",
{ email: "a@b.c" },
),
).rejects.toBe(failure)
})
})
describe("initializeOrganization", () => {
it("posts to the organization's initialize endpoint", async ({
expect,
}) => {
const http = stubHttp()
await createCoreClient(
BASE_URL,
http,
).initializeOrganization("org-1")
expect(http.post).toHaveBeenCalledTimes(1)
expect(http.post).toHaveBeenCalledWith(
"http://core:8080/api/x/organizations/org-1/initialize",
)
})
})
describe("teardownOrganization", () => {
it("posts to the organization's teardown endpoint", async ({
expect,
}) => {
const http = stubHttp()
await createCoreClient(
BASE_URL,
http,
).teardownOrganization("org-1")
expect(http.post).toHaveBeenCalledTimes(1)
expect(http.post).toHaveBeenCalledWith(
"http://core:8080/api/x/organizations/org-1/teardown",
)
})
// the organization survives a failed teardown rather than
// leaving core's resources orphaned, which only works if the
// rejection reaches better-auth's hook
it("propagates a failed teardown", async ({ expect }) => {
const failure = new Error("teardown failed")
const http = stubHttp()
http.post.mockRejectedValue(failure)
await expect(
createCoreClient(
BASE_URL,
http,
).teardownOrganization("org-1"),
).rejects.toBe(failure)
})
})
describe("fetchBranches", () => {
it("returns the branch list core responded with", async ({
expect,
}) => {
const branches = [
{ branchId: "branch-1", default: true },
{ branchId: "branch-2", default: false },
]
const http = stubHttp({ status: 200, data: branches })
const result = await createCoreClient(
BASE_URL,
http,
).fetchBranches("doc-1")
expect(result).toEqual(branches)
expect(http.get).toHaveBeenCalledTimes(1)
expect(http.get).toHaveBeenCalledWith(
"http://core:8080/api/x/documents/doc-1/branches",
undefined,
)
})
it("forwards the caller's headers when given them", async ({
expect,
}) => {
const http = stubHttp({ status: 200, data: [] })
const headers = new AxiosHeaders()
headers.set("cookie", "auth.session=abc")
await createCoreClient(BASE_URL, http).fetchBranches(
"doc-1",
{ headers },
)
expect(http.get).toHaveBeenCalledWith(
"http://core:8080/api/x/documents/doc-1/branches",
{ headers },
)
})
})
describe("fetchBranchContent", () => {
it("returns the branch core responded with", async ({
expect,
}) => {
const branch = {
documentName: "Runbook",
content: { type: "doc", content: [] },
icon: "lucide:file",
rawContent: "AAEC",
}
const http = stubHttp({ status: 200, data: branch })
const result = await createCoreClient(
BASE_URL,
http,
).fetchBranchContent("doc-1", "branch-1")
expect(result).toEqual(branch)
expect(http.get).toHaveBeenCalledTimes(1)
expect(http.get).toHaveBeenCalledWith(
"http://core:8080/api/x/documents/doc-1/branch/branch-1",
)
})
})
describe("storeBranchContent", () => {
it("puts the update to the branch's internal endpoint", async ({
expect,
}) => {
const http = stubHttp()
const update = {
name: "Runbook",
icon: "lucide:file",
content: { type: "doc", content: [] },
maintainers: ["user-1"],
rawContent: "AAEC",
system: false,
}
await createCoreClient(
BASE_URL,
http,
).storeBranchContent("doc-1", "branch-1", update)
expect(http.put).toHaveBeenCalledTimes(1)
expect(http.put).toHaveBeenCalledWith(
"http://core:8080/api/x/documents/doc-1/branch/branch-1",
update,
)
expect.soft(http.get).toHaveBeenCalledTimes(0)
expect.soft(http.post).toHaveBeenCalledTimes(0)
})
})
describe("mergeBranches", () => {
// the merge is the one call that goes to core's session-authed
// surface rather than /api/x, because core decides from the
// forwarded headers whether the caller may merge
it("puts to core's public merge route with the caller's headers", async ({
expect,
}) => {
const http = stubHttp({
status: 200,
data: {
documentName: "Runbook",
content: { type: "doc", content: [] },
icon: "lucide:file",
},
})
const headers = new AxiosHeaders()
headers.set("cookie", "auth.session=abc")
const result = await createCoreClient(
BASE_URL,
http,
).mergeBranches("doc-1", "branch-2", "branch-1", {
headers,
})
expect(http.put).toHaveBeenCalledTimes(1)
expect(http.put).toHaveBeenCalledWith(
"http://core:8080/api/documents/doc-1/merge",
{
fromBranchId: "branch-2",
toBranchId: "branch-1",
},
{ headers },
)
expect(result.status).toBe(200)
expect(result.data.documentName).toBe("Runbook")
})
it("reports the status core answered with", async ({
expect,
}) => {
const http = stubHttp({ status: 409, data: {} })
const result = await createCoreClient(
BASE_URL,
http,
).mergeBranches("doc-1", "branch-2", "branch-1", {})
expect(result.status).toBe(409)
})
})
})

View File

@ -0,0 +1,179 @@
import axios from "axios"
import type { AxiosHeaders } from "axios"
// the four emails core renders on this service's behalf. Naming them here
// keeps a typo in a template name a compile error rather than a silently
// dropped email.
export type EmailTemplate =
| "email_verification"
| "password_reset"
| "account_exists"
| "signup_verification"
| "organization_invitation"
| "user_deletion"
| "user_creation"
export interface BranchSummary {
branchId: string
default: boolean
}
export interface BranchContent {
documentName: string
// prosemirror JSON. Left unknown because nothing on this side
// validates it — the editor schema is the only thing that can.
content: unknown
icon: string
// the canonical Yjs state, base64-encoded. Absent on a branch that
// has never been opened by an editor.
rawContent?: string | null
}
export interface BranchUpdate {
name: string
icon: string
content: unknown
maintainers: string[]
rawContent: string
system: boolean
}
export interface MergedDocument {
documentName: string
content: unknown
icon: string
}
export interface RequestOptions {
headers?: AxiosHeaders
}
export interface HttpResponse {
status: number
data: unknown
}
// the minimum of axios this service uses. Declaring it structurally lets a
// test hand createCoreClient a plain object instead of intercepting a
// real axios instance. Response bodies arrive as unknown: this client is
// the one place that states what core returns, and it does so by naming
// the shape at each call rather than trusting a generic parameter.
export interface HttpClient {
get(url: string, config?: RequestOptions): Promise<HttpResponse>
post(
url: string,
data?: unknown,
config?: RequestOptions,
): Promise<HttpResponse>
put(
url: string,
data?: unknown,
config?: RequestOptions,
): Promise<HttpResponse>
}
// every call this service makes into core. Transport only: a failed
// request rejects with the underlying error untouched, because the callers
// differ in what they do about it — the merge route forwards core's status
// to its own caller, while the hocuspocus store hook swallows it and warns
// the connected editors instead.
export interface CoreClient {
sendEmail(
template: EmailTemplate,
data: Record<string, string>,
): Promise<void>
initializeOrganization(organizationId: string): Promise<void>
teardownOrganization(organizationId: string): Promise<void>
fetchBranches(
documentId: string,
options?: RequestOptions,
): Promise<BranchSummary[]>
fetchBranchContent(
documentId: string,
branchId: string,
): Promise<BranchContent>
storeBranchContent(
documentId: string,
branchId: string,
update: BranchUpdate,
): Promise<void>
mergeBranches(
documentId: string,
fromBranchId: string,
toBranchId: string,
options: RequestOptions,
): Promise<{ status: number; data: MergedDocument }>
}
export function createCoreClient(
baseUrl: string,
http: HttpClient = axios,
): CoreClient {
// /api/x is core's internal, sessionless surface — the reverse proxy
// blocks it at the front door, so these calls only work from inside
// the container network.
const internal = `${baseUrl}/api/x`
return {
async sendEmail(template, data) {
await http.post(`${internal}/email`, { template, data })
},
async initializeOrganization(organizationId) {
await http.post(
`${internal}/organizations/${organizationId}/initialize`,
)
},
async teardownOrganization(organizationId) {
await http.post(
`${internal}/organizations/${organizationId}/teardown`,
)
},
async fetchBranches(documentId, options) {
const response = await http.get(
`${internal}/documents/${documentId}/branches`,
options,
)
return response.data as BranchSummary[]
},
async fetchBranchContent(documentId, branchId) {
const response = await http.get(
`${internal}/documents/${documentId}/branch/${branchId}`,
)
return response.data as BranchContent
},
async storeBranchContent(documentId, branchId, update) {
await http.put(
`${internal}/documents/${documentId}/branch/${branchId}`,
update,
)
},
// the merge itself goes through core's session-authed surface,
// not /api/x: the caller's own headers are forwarded so core
// decides whether they may merge.
async mergeBranches(
documentId,
fromBranchId,
toBranchId,
options,
) {
const response = await http.put(
`${baseUrl}/api/documents/${documentId}/merge`,
{ fromBranchId, toBranchId },
options,
)
return {
status: response.status,
data: response.data as MergedDocument,
}
},
}
}

View File

@ -1,38 +0,0 @@
import { Pool } from "pg"
import { Kysely, PostgresDialect } from "kysely"
export const dialect = new PostgresDialect({
pool: new Pool({
connectionString: process.env.OXYNOTE_AUTH_REALTIME_DB_DSN,
}),
})
export const db = new Kysely<Database>({
dialect,
})
export interface Database {
users: UsersTable
organizations: OrganizationsTable
organization_members: OrganizationMembersTable
oauth_consents: OAuthConsentsTable
}
export interface UsersTable {
id: string
email: string
}
export interface OrganizationsTable {
id: string
}
export interface OrganizationMembersTable {
fk_user_id: string
fk_organization_id: string
}
export interface OAuthConsentsTable {
client_id: string
fk_user_id: string
}

View File

@ -0,0 +1,201 @@
import { describe, it, vi } from "vitest"
import type { Kysely } from "kysely"
import { 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
// needs to know that — everywhere else takes a Store.
function stubDb(row: unknown) {
const chain = {
select: vi.fn(() => chain),
where: vi.fn(() => chain),
executeTakeFirst: vi.fn().mockResolvedValue(row),
executeTakeFirstOrThrow: vi.fn().mockResolvedValue(row),
}
const selectFrom = vi.fn(() => chain)
const db = {
selectFrom,
fn: { countAll: () => ({ as: (alias: string) => alias }) },
} as unknown as Kysely<Database>
return { store: createStore(db), chain, selectFrom }
}
describe("createStore", () => {
describe("totalOrganizationCount", () => {
it("returns the count the query answered with", async ({
expect,
}) => {
const { store, selectFrom } = stubDb({ count: 7 })
expect(await store.totalOrganizationCount()).toBe(7)
expect(selectFrom).toHaveBeenCalledWith("organizations")
})
// the count decides whether another organization may be
// created, so an unanswered query must not read as zero
it("propagates a failed query", async ({ expect }) => {
const failure = new Error("connection terminated")
const { store, chain } = stubDb(null)
chain.executeTakeFirstOrThrow.mockRejectedValue(failure)
await expect(
store.totalOrganizationCount(),
).rejects.toBe(failure)
})
})
describe("userOrganizationId", () => {
it("returns the organization the user belongs to", async ({
expect,
}) => {
const { store, chain, selectFrom } = stubDb({
fk_organization_id: "org-1",
})
expect(await store.userOrganizationId("user-1")).toBe(
"org-1",
)
expect(selectFrom).toHaveBeenCalledWith(
"organization_members",
)
expect(chain.where).toHaveBeenCalledWith(
"fk_user_id",
"=",
"user-1",
)
expect(chain.select).toHaveBeenCalledWith(
"fk_organization_id",
)
})
it("returns null when the user belongs to no organization", async ({
expect,
}) => {
const { store } = stubDb(undefined)
expect(
await store.userOrganizationId("user-1"),
).toBeNull()
})
it("propagates a failed query", async ({ expect }) => {
const failure = new Error("connection terminated")
const { store, chain } = stubDb(undefined)
chain.executeTakeFirst.mockRejectedValue(failure)
await expect(
store.userOrganizationId("user-1"),
).rejects.toBe(failure)
})
})
describe("hasOAuthConsent", () => {
it("matches the consent on both the client and the user", async ({
expect,
}) => {
const { store, chain, selectFrom } = stubDb({
client_id: "client-1",
})
expect(
await store.hasOAuthConsent(
"client-1",
"user-1",
),
).toBe(true)
expect(selectFrom).toHaveBeenCalledWith(
"oauth_consents",
)
expect(chain.where).toHaveBeenCalledWith(
"client_id",
"=",
"client-1",
)
expect(chain.where).toHaveBeenCalledWith(
"fk_user_id",
"=",
"user-1",
)
})
// revoking a client deletes the row, which is what cuts off the
// access tokens it already holds
it("reports no consent when the row is gone", async ({
expect,
}) => {
const { store } = stubDb(undefined)
expect(
await store.hasOAuthConsent(
"client-1",
"user-1",
),
).toBe(false)
})
it("propagates a failed query", async ({ expect }) => {
const failure = new Error("connection terminated")
const { store, chain } = stubDb(undefined)
chain.executeTakeFirst.mockRejectedValue(failure)
await expect(
store.hasOAuthConsent("client-1", "user-1"),
).rejects.toBe(failure)
})
})
describe("isOrganizationMember", () => {
it("matches the membership on both the user and the organization", async ({
expect,
}) => {
const { store, chain, selectFrom } = stubDb({
fk_user_id: "user-1",
})
expect(
await store.isOrganizationMember(
"user-1",
"org-1",
),
).toBe(true)
expect(selectFrom).toHaveBeenCalledWith(
"organization_members",
)
expect(chain.where).toHaveBeenCalledWith(
"fk_user_id",
"=",
"user-1",
)
expect(chain.where).toHaveBeenCalledWith(
"fk_organization_id",
"=",
"org-1",
)
})
it("reports no membership when the user has left", async ({
expect,
}) => {
const { store } = stubDb(undefined)
expect(
await store.isOrganizationMember(
"user-1",
"org-1",
),
).toBe(false)
})
it("propagates a failed query", async ({ expect }) => {
const failure = new Error("connection terminated")
const { store, chain } = stubDb(undefined)
chain.executeTakeFirst.mockRejectedValue(failure)
await expect(
store.isOrganizationMember("user-1", "org-1"),
).rejects.toBe(failure)
})
})
})

View File

@ -0,0 +1,116 @@
import { Pool } from "pg"
import { Kysely, PostgresDialect } from "kysely"
// the columns this service reads. Better Auth owns writing these tables and
// core's migrations own creating them; what is declared here is only what a
// query below touches.
export interface Database {
users: UsersTable
organizations: OrganizationsTable
organization_members: OrganizationMembersTable
oauth_consents: OAuthConsentsTable
}
export interface UsersTable {
id: string
email: string
}
export interface OrganizationsTable {
id: string
}
export interface OrganizationMembersTable {
fk_user_id: string
fk_organization_id: string
}
export interface OAuthConsentsTable {
client_id: string
fk_user_id: string
}
// every query this service runs, named by what it answers rather than by
// the SQL it builds. Nothing outside this module holds a query builder, so
// a table or column rename is a change to one file, and a caller can be
// tested against four stubbed methods instead of a mocked chain.
export interface Store {
totalOrganizationCount(): Promise<number>
userOrganizationId(userId: string): Promise<string | null>
hasOAuthConsent(clientId: string, userId: string): Promise<boolean>
isOrganizationMember(
userId: string,
organizationId: string,
): Promise<boolean>
}
// better-auth needs the dialect rather than the query builder: handing it
// the Kysely instance alone leaves the CLI's schema generator unable to
// tell which database it is talking to.
export interface DatabaseHandle {
store: Store
dialect: PostgresDialect
}
export function createStore(db: Kysely<Database>): Store {
return {
async totalOrganizationCount() {
const res = await db
.selectFrom("organizations")
.select(db.fn.countAll<number>().as("count"))
.executeTakeFirstOrThrow()
return res.count
},
// a user belongs to at most one organization, which is what
// makes "the user's organization" a single value everywhere
// else in the service.
async userOrganizationId(userId) {
const res = await db
.selectFrom("organization_members")
.where("fk_user_id", "=", userId)
.select("fk_organization_id")
.executeTakeFirst()
return res ? res.fk_organization_id : null
},
async hasOAuthConsent(clientId, userId) {
const res = await db
.selectFrom("oauth_consents")
.where("client_id", "=", clientId)
.where("fk_user_id", "=", userId)
.select("client_id")
.executeTakeFirst()
return res !== undefined
},
async isOrganizationMember(userId, organizationId) {
const res = await db
.selectFrom("organization_members")
.where("fk_user_id", "=", userId)
.where(
"fk_organization_id",
"=",
organizationId,
)
.select("fk_user_id")
.executeTakeFirst()
return res !== undefined
},
}
}
export function createDatabase(dsn: string): DatabaseHandle {
const dialect = new PostgresDialect({
pool: new Pool({ connectionString: dsn }),
})
return {
store: createStore(new Kysely<Database>({ dialect })),
dialect,
}
}

View File

@ -0,0 +1,301 @@
import { describe, it } from "vitest"
import { loadEnv } from "./env.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.
function completeEnv(
overrides: Record<string, string | undefined> = {},
): Record<string, string | undefined> {
return {
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_BETTER_AUTH_BASE_URL:
"http://localhost:8080/auth-realtime",
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SECRET: "sup3rs3cr3t",
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_COOKIE_DOMAIN: "localhost",
OXYNOTE_AUTH_REALTIME_FRONTEND_URL: "http://localhost:8080",
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_ORGANIZATION_INVITATION_URL:
"http://localhost:8080/accept-invite",
OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS:
"http://localhost:8080,http://localhost:3000",
OXYNOTE_AUTH_REALTIME_MCP_RESOURCE:
"http://localhost:8080/core/api/mcp",
...overrides,
}
}
describe("loadEnv", () => {
it("maps every required variable onto the typed configuration", ({
expect,
}) => {
const env = loadEnv(completeEnv())
expect(env.coreUrl).toBe("http://core:8080")
expect(env.databaseDSN).toBe(
"postgresql://devuser:devpass@postgres/devdb",
)
expect(env.valkeyUrl).toBe("redis://valkey:6379")
expect(env.betterAuthSecret).toBe("sup3rs3cr3t")
expect(env.cookieDomain).toBe("localhost")
expect(env.frontendUrl).toBe("http://localhost:8080")
expect(env.organizationInvitationUrl).toBe(
"http://localhost:8080/accept-invite",
)
expect(env.mcpResource).toBe(
"http://localhost:8080/core/api/mcp",
)
})
it("reduces the public auth base URL to its origin", ({ expect }) => {
const env = loadEnv(completeEnv())
expect(env.publicAuthBaseUrl).toBe(
"http://localhost:8080/auth-realtime",
)
expect(env.authOrigin).toBe("http://localhost:8080")
})
it("derives the MCP token issuer from the auth origin", ({
expect,
}) => {
const env = loadEnv(completeEnv())
expect(env.mcpTokenIssuer).toBe(
"http://localhost:8080/api/auth",
)
})
it.for([
{
name: "a missing required variable",
input: { OXYNOTE_AUTH_REALTIME_DB_DSN: undefined },
expected: "OXYNOTE_AUTH_REALTIME_DB_DSN",
},
{
name: "a required variable present but empty",
input: { OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SECRET: "" },
expected: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SECRET",
},
{
name: "a URL variable that is not a URL",
input: {
OXYNOTE_AUTH_REALTIME_BACKEND_URL: "core:8080",
},
expected: "OXYNOTE_AUTH_REALTIME_BACKEND_URL",
},
])("names $name in the failure", ({ input, expected }, { expect }) => {
expect(() => loadEnv(completeEnv(input))).toThrow(expected)
})
it("reports every invalid variable at once rather than the first", ({
expect,
}) => {
expect(() =>
loadEnv(
completeEnv({
OXYNOTE_AUTH_REALTIME_DB_DSN: undefined,
OXYNOTE_AUTH_REALTIME_VALKEY_URL:
undefined,
}),
),
).toThrow(
/OXYNOTE_AUTH_REALTIME_DB_DSN[\s\S]*OXYNOTE_AUTH_REALTIME_VALKEY_URL/,
)
})
describe("counters", () => {
it("falls back to the defaults when unset", ({ expect }) => {
const env = loadEnv(completeEnv())
expect(env.maxOrganizations).toBe(100)
expect(env.maxOrganizationMembers).toBe(5)
})
it("reads the configured values", ({ expect }) => {
const env = loadEnv(
completeEnv({
OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS:
"50",
OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS:
"25",
}),
)
expect(env.maxOrganizations).toBe(50)
expect(env.maxOrganizationMembers).toBe(25)
})
it.for([
{ name: "a word", input: "many" },
{ name: "a fraction", input: "2.5" },
{ name: "zero", input: "0" },
{ name: "a negative count", input: "-1" },
])(
"rejects $name as an organization limit",
({ input }, { expect }) => {
expect(() =>
loadEnv(
completeEnv({
OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS:
input,
}),
),
).toThrow(
"OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS",
)
},
)
})
describe("rate limiting", () => {
it.for([
{ name: "unset", input: undefined, expected: true },
{ name: '"false"', input: "false", expected: true },
{ name: '"true"', input: "true", expected: false },
])(
"is enabled=$expected when the disable flag is $name",
({ input, expected }, { expect }) => {
const env = loadEnv(
completeEnv({
OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED:
input,
}),
)
expect(env.rateLimitEnabled).toBe(expected)
},
)
// a flag spelled any other way would read as false and leave
// the limiter on while the operator believes it is off
it("rejects a flag that is neither true nor false", ({
expect,
}) => {
expect(() =>
loadEnv(
completeEnv({
OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED:
"1",
}),
),
).toThrow("OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED")
})
})
describe("trusted origins", () => {
it("splits the list on commas", ({ expect }) => {
const env = loadEnv(completeEnv())
expect(env.trustedOrigins).toEqual([
"http://localhost:8080",
"http://localhost:3000",
])
})
it("is empty when the variable is unset", ({ expect }) => {
const env = loadEnv(
completeEnv({
OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS:
undefined,
}),
)
expect(env.trustedOrigins).toEqual([])
})
it("drops the empty entries a trailing comma produces", ({
expect,
}) => {
const env = loadEnv(
completeEnv({
OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS:
"http://localhost:8080,,oxynote://,",
}),
)
expect(env.trustedOrigins).toEqual([
"http://localhost:8080",
"oxynote://",
])
})
})
describe("social providers", () => {
it("registers none when no credentials are configured", ({
expect,
}) => {
const env = loadEnv(completeEnv())
expect(env.socialProviders).toEqual({})
})
it("registers a provider given both halves of its credentials", ({
expect,
}) => {
const env = loadEnv(
completeEnv({
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_ID:
"github-id",
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_SECRET:
"github-secret",
}),
)
expect(env.socialProviders).toEqual({
github: {
clientId: "github-id",
clientSecret: "github-secret",
},
})
})
it("registers each configured provider independently", ({
expect,
}) => {
const env = loadEnv(
completeEnv({
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_ID:
"google-id",
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_SECRET:
"google-secret",
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_ID:
"slack-id",
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_SECRET:
"slack-secret",
}),
)
expect(Object.keys(env.socialProviders).sort()).toEqual(
["google", "slack"],
)
})
it.for([
{
name: "the secret",
input: {
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_ID:
"google-id",
},
expected: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_SECRET",
},
{
name: "the client id",
input: {
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_SECRET:
"google-secret",
},
expected: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_ID",
},
])(
"fails when google is configured without $name",
({ input, expected }, { expect }) => {
expect(() =>
loadEnv(completeEnv(input)),
).toThrow(expected)
},
)
})
})

View File

@ -0,0 +1,240 @@
import { z } from "zod"
export type SocialProviderName = "google" | "github" | "slack"
export interface SocialProviderCredentials {
clientId: string
clientSecret: string
}
export interface Env {
// where core answers. The variable behind it is still spelled
// BACKEND_URL because that name is baked into every deployment's env
// file; only the field this side reads it through says "core".
coreUrl: string
databaseDSN: string
valkeyUrl: string
// the public base URL carries the reverse proxy's /auth-realtime
// prefix; authOrigin is the bare origin the proxy forwards to.
publicAuthBaseUrl: string
authOrigin: string
betterAuthSecret: string
cookieDomain: string
frontendUrl: string
organizationInvitationUrl: string
trustedOrigins: string[]
mcpResource: string
// MCP access tokens carry better-auth's resolved base URL (origin +
// basePath) as their issuer. The internal MCP session endpoint
// verifies against it.
mcpTokenIssuer: string
socialProviders: Partial<
Record<SocialProviderName, SocialProviderCredentials>
>
maxOrganizations: number
maxOrganizationMembers: number
rateLimitEnabled: boolean
}
// every URL here is one something dials or redirects a browser 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?$/ })
}
// an unset counter falls back to its default; a present one must be a
// positive integer, so a typo is a boot error instead of a NaN limit that
// compares false against every count.
function counter(fallback: number) {
return z
.string()
.optional()
.transform((value) =>
value === undefined ? fallback : Number(value),
)
.pipe(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) {
return z
.enum(["true", "false"])
.optional()
.transform((value) =>
value === undefined ? fallback : value === "true",
)
}
const SOCIAL_PROVIDERS = [
{
name: "google",
idKey: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_ID",
secretKey: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_SECRET",
},
{
name: "github",
idKey: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_ID",
secretKey: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_SECRET",
},
{
name: "slack",
idKey: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_ID",
secretKey: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_SECRET",
},
] as const satisfies readonly {
name: SocialProviderName
idKey: string
secretKey: string
}[]
// every variable the service reads, spelled out. Optional social
// credentials are the only ones allowed to be absent — a provider with
// neither half is left unregistered, and one with a single half is a boot
// error rather than a broken OAuth redirect discovered by a user.
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),
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
.string()
.min(1),
OXYNOTE_AUTH_REALTIME_FRONTEND_URL: httpUrl(),
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_ORGANIZATION_INVITATION_URL:
httpUrl(),
OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS: z.string().optional(),
OXYNOTE_AUTH_REALTIME_MCP_RESOURCE: httpUrl(),
OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS: counter(100),
OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS: counter(5),
// better-auth's limiter buckets by client IP, and sign-in and
// sign-up share one allowance of three requests per ten
// seconds. That is wrong for any caller whose traffic arrives
// from a single address on behalf of many users — an
// end-to-end test run, or a deployment fronted by a proxy that
// does not pass the original IP through — so it can be
// switched off and left to whatever sits in front.
OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED: flag(false),
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_ID: z
.string()
.optional(),
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_SECRET: z
.string()
.optional(),
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_ID: z
.string()
.optional(),
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_SECRET: z
.string()
.optional(),
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_ID: z
.string()
.optional(),
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_SECRET: z
.string()
.optional(),
})
.superRefine((values, ctx) => {
for (const provider of SOCIAL_PROVIDERS) {
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} needs both a client id and a client secret, or neither`,
})
}
})
type ParsedEnv = z.infer<typeof schema>
function socialProviders(
values: ParsedEnv,
): Partial<Record<SocialProviderName, SocialProviderCredentials>> {
const providers: Partial<
Record<SocialProviderName, SocialProviderCredentials>
> = {}
for (const provider of SOCIAL_PROVIDERS) {
const clientId = values[provider.idKey]
const clientSecret = values[provider.secretKey]
if (!clientId || !clientSecret) {
continue
}
providers[provider.name] = { clientId, clientSecret }
}
return providers
}
// loadEnv parses the process environment into the typed configuration the
// composition root hands to every factory. It is the only place that reads
// process.env, so a test builds an Env by calling this with a literal
// record instead of mutating the real environment.
export function loadEnv(source: Record<string, string | undefined>): Env {
// docker's env files list every variable the service reads, including
// the ones left blank, so an unset value arrives as "" rather than
// absent. Dropping those lets optional values fall back to their
// defaults and makes a required one report as missing instead of
// empty.
const present = Object.fromEntries(
Object.entries(source).filter(
([, value]) => value !== undefined && value !== "",
),
)
const result = schema.safeParse(present)
if (!result.success) {
throw new Error(
`invalid environment:\n${z.prettifyError(result.error)}`,
)
}
const values = result.data
const publicAuthBaseUrl =
values.OXYNOTE_AUTH_REALTIME_BETTER_AUTH_BASE_URL
const authOrigin = new URL(publicAuthBaseUrl).origin
return {
coreUrl: values.OXYNOTE_AUTH_REALTIME_BACKEND_URL,
databaseDSN: values.OXYNOTE_AUTH_REALTIME_DB_DSN,
valkeyUrl: values.OXYNOTE_AUTH_REALTIME_VALKEY_URL,
publicAuthBaseUrl,
authOrigin,
betterAuthSecret:
values.OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SECRET,
cookieDomain:
values.OXYNOTE_AUTH_REALTIME_BETTER_AUTH_COOKIE_DOMAIN,
frontendUrl: values.OXYNOTE_AUTH_REALTIME_FRONTEND_URL,
organizationInvitationUrl:
values.OXYNOTE_AUTH_REALTIME_BETTER_AUTH_ORGANIZATION_INVITATION_URL,
trustedOrigins: (
values.OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS ?? ""
)
.split(",")
.filter(Boolean),
mcpResource: values.OXYNOTE_AUTH_REALTIME_MCP_RESOURCE,
mcpTokenIssuer: `${authOrigin}/api/auth`,
socialProviders: socialProviders(values),
maxOrganizations:
values.OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS,
maxOrganizationMembers:
values.OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS,
rateLimitEnabled:
!values.OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED,
}
}

View File

@ -0,0 +1,378 @@
import { describe, it } from "vitest"
import { collectHeaderEntries, toAxiosHeaders, toHeaders } from "./headers.js"
const nonHeaderShapedCases: { name: string; input: unknown }[] = [
{ name: "null", input: null },
{ name: "undefined", input: undefined },
{ name: "a string", input: "content-type: text/plain" },
{ name: "a number", input: 7 },
]
const collectedValueCases: {
name: string
input: unknown
expected: string
}[] = [
{ name: "a string", input: "text/plain", expected: "text/plain" },
{ name: "a number", input: 42, expected: "42" },
{ name: "a boolean", input: false, expected: "false" },
{
name: "a bigint",
input: 9007199254740993n,
expected: "9007199254740993",
},
{ name: "an array of strings", input: ["a", "b"], expected: "a, b" },
{
name: "an array mixing primitives and dropped values",
input: ["a", 1, null, undefined],
expected: "a, 1",
},
{
name: "a nested array",
input: [["a", "b"], "c"],
expected: "a, b, c",
},
]
const droppedValueCases: { name: string; input: unknown }[] = [
{ name: "null", input: null },
{ name: "undefined", input: undefined },
{ name: "a plain object", input: { nested: "value" } },
{ name: "a symbol", input: Symbol("x-a") },
{ name: "an empty array", input: [] },
{ name: "an array of only dropped values", input: [null, undefined] },
]
describe("headers", () => {
describe("collectHeaderEntries", () => {
it.for(nonHeaderShapedCases)(
"returns null for $name",
({ input }, { expect }) => {
expect(collectHeaderEntries(input)).toBeNull()
},
)
it.for(collectedValueCases)(
"collects $name as a single header value",
({ input, expected }, { expect }) => {
expect(
collectHeaderEntries({ "x-a": input }),
).toEqual([["x-a", expected]])
},
)
it.for(droppedValueCases)(
"drops a header whose value is $name",
({ input }, { expect }) => {
expect(
collectHeaderEntries({ "x-a": input }),
).toEqual([])
},
)
it("collects the entries of a WHATWG Headers instance", ({
expect,
}) => {
const headers = new Headers({
"X-A": "1",
"Content-Type": "text/plain",
})
expect(collectHeaderEntries(headers)).toEqual([
["content-type", "text/plain"],
["x-a", "1"],
])
})
it("collects a repeated Headers entry as one comma-separated value", ({
expect,
}) => {
const headers = new Headers()
headers.append("X-A", "1")
headers.append("X-A", "2")
expect(collectHeaderEntries(headers)).toEqual([
["x-a", "1, 2"],
])
})
it("collects the entries of a Map", ({ expect }) => {
const headers = new Map<string, unknown>([
["x-a", "1"],
["x-b", ["2", "3"]],
["x-c", null],
])
expect(collectHeaderEntries(headers)).toEqual([
["x-a", "1"],
["x-b", "2, 3"],
])
})
it("collects the entries of an iterable that has no forEach", ({
expect,
}) => {
const headers = {
*[Symbol.iterator](): Generator<
[string, unknown]
> {
yield ["x-a", "1"]
yield ["x-b", ["2", "3"]]
yield ["x-c", undefined]
},
}
expect(collectHeaderEntries(headers)).toEqual([
["x-a", "1"],
["x-b", "2, 3"],
])
})
it("collects the entries of a plain object map", ({
expect,
}) => {
expect(
collectHeaderEntries({
"X-A": "1",
"content-type": "text/plain",
}),
).toEqual([
["X-A", "1"],
["content-type", "text/plain"],
])
})
it("collects the entries of a null-prototype object map", ({
expect,
}) => {
const headers = Object.assign(
Object.create(null) as object,
{
"x-a": "1",
},
)
expect(collectHeaderEntries(headers)).toEqual([
["x-a", "1"],
])
})
it("returns an empty list for an object with no own keys", ({
expect,
}) => {
expect(collectHeaderEntries({})).toEqual([])
})
// a symbol key stringifies to "Symbol(x-a)", which
// Headers.append rejects as an invalid header name
it("drops a symbol key of a plain object map", ({ expect }) => {
const key = Symbol("x-a")
expect(
collectHeaderEntries({
[key]: "1",
"x-b": "2",
}),
).toEqual([["x-b", "2"]])
})
})
describe("toHeaders", () => {
it("prefers the header-shaped headers argument over the request", ({
expect,
}) => {
const res = toHeaders(
{
rawHeaders: ["x-raw", "raw"],
headers: { "x-req": "req" },
},
{ "X-A": "1" },
)
expect([...res.entries()]).toEqual([["x-a", "1"]])
})
it("lowercases the collected header names", ({ expect }) => {
const res = toHeaders(null, {
"Content-Type": "text/plain",
})
expect(res.get("content-type")).toBe("text/plain")
})
it("combines repeated entries of an iterable into one value", ({
expect,
}) => {
const res = toHeaders(
null,
new Map<string, unknown>([["x-a", "1"]]),
)
res.append("x-a", "2")
expect(res.get("x-a")).toBe("1, 2")
})
it("takes an empty headers argument as header-shaped and ignores rawHeaders", ({
expect,
}) => {
const res = toHeaders({ rawHeaders: ["x-a", "1"] }, {})
expect([...res.entries()]).toEqual([])
})
it("falls back to rawHeaders when the headers argument is not header-shaped", ({
expect,
}) => {
const res = toHeaders(
{
rawHeaders: [
"X-A",
"1",
"Content-Type",
"text/plain",
],
headers: { "x-req": "req" },
},
null,
)
expect([...res.entries()]).toEqual([
["content-type", "text/plain"],
["x-a", "1"],
])
})
it("combines a repeated rawHeaders key into one value", ({
expect,
}) => {
const res = toHeaders(
{ rawHeaders: ["X-A", "1", "x-a", "2"] },
undefined,
)
expect(res.get("x-a")).toBe("1, 2")
})
it("skips a trailing rawHeaders key that has no value", ({
expect,
}) => {
const res = toHeaders(
{ rawHeaders: ["x-a", "1", "x-b"] },
null,
)
expect([...res.entries()]).toEqual([["x-a", "1"]])
})
it("skips a rawHeaders pair whose key or value is not a primitive", ({
expect,
}) => {
const res = toHeaders(
{
rawHeaders: [
{},
"1",
"x-b",
{},
"x-c",
"3",
],
},
null,
)
expect([...res.entries()]).toEqual([["x-c", "3"]])
})
it("falls back to req.headers when the request has no rawHeaders", ({
expect,
}) => {
const res = toHeaders(
{ headers: { "X-A": "1", "x-b": null } },
null,
)
expect([...res.entries()]).toEqual([["x-a", "1"]])
})
it("falls back to req.headers when rawHeaders is not an array", ({
expect,
}) => {
const res = toHeaders(
{
rawHeaders: "x-a: 1",
headers: { "x-b": "2" },
},
null,
)
expect([...res.entries()]).toEqual([["x-b", "2"]])
})
it("returns an empty Headers when the request is null", ({
expect,
}) => {
expect([...toHeaders(null, null).entries()]).toEqual([])
})
it("returns an empty Headers when the request is undefined", ({
expect,
}) => {
expect([
...toHeaders(undefined, undefined).entries(),
]).toEqual([])
})
it("returns an empty Headers when neither argument is header-shaped", ({
expect,
}) => {
expect([
...toHeaders(
"not a request",
"not headers",
).entries(),
]).toEqual([])
})
})
describe("toAxiosHeaders", () => {
it("sets each collected entry on the returned AxiosHeaders", ({
expect,
}) => {
const res = toAxiosHeaders({
"X-A": "1",
"content-type": "text/plain",
"x-count": 3,
})
expect(res.get("x-a")).toBe("1")
expect(res.get("Content-Type")).toBe("text/plain")
expect(res.get("x-count")).toBe("3")
})
it("sets the entries of a WHATWG Headers instance", ({
expect,
}) => {
const res = toAxiosHeaders(new Headers({ "X-A": "1" }))
expect(res.get("x-a")).toBe("1")
})
it("omits a header whose value is dropped", ({ expect }) => {
const res = toAxiosHeaders({ "x-a": "1", "x-b": null })
expect(res.get("x-b")).toBeUndefined()
expect(res.get("x-a")).toBe("1")
})
it.for(nonHeaderShapedCases)(
"returns empty AxiosHeaders for $name",
({ input }, { expect }) => {
expect(toAxiosHeaders(input).toJSON()).toEqual(
{},
)
},
)
})
})

View File

@ -0,0 +1,163 @@
import { AxiosHeaders } from "axios"
type HeaderEntry = [string, string]
interface ForEachHeaders {
forEach(callback: (value: unknown, key: string) => void): void
}
// a header value is a string, or a list of them. Anything else is not
// something a transport produced, and stringifying it would put
// "[object Object]" on the wire — so it is dropped instead.
function normalizeHeaderValue(value: unknown): string | null {
if (value == null) {
return null
}
if (Array.isArray(value)) {
const parts = (value as unknown[])
.map(normalizeHeaderValue)
.filter((part): part is string => part !== null)
return parts.length > 0 ? parts.join(", ") : null
}
if (typeof value === "string") {
return value
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value)
}
return null
}
function hasForEach(value: object): value is ForEachHeaders {
return typeof (value as Partial<ForEachHeaders>).forEach === "function"
}
function isEntryIterable(value: object): value is Iterable<[string, unknown]> {
return (
typeof (value as Partial<Iterable<unknown>>)[
Symbol.iterator
] === "function"
)
}
// hocuspocus hands the request headers through in whatever shape the
// underlying transport produced, so every accepted form is normalized to a
// list of pairs before anything reads them. Returns null when the input is
// not header-shaped at all, which is what makes the raw-request fallback in
// toHeaders reachable.
export function collectHeaderEntries(
requestHeaders: unknown,
): HeaderEntry[] | null {
if (!requestHeaders || typeof requestHeaders !== "object") {
return null
}
const entries: HeaderEntry[] = []
// WHATWG Headers or compatible
if (hasForEach(requestHeaders)) {
requestHeaders.forEach((v, k) => {
const value = normalizeHeaderValue(v)
if (value != null) {
entries.push([k, value])
}
})
return entries
}
// iterable header entries
if (isEntryIterable(requestHeaders)) {
for (const [k, v] of requestHeaders) {
const value = normalizeHeaderValue(v)
if (value != null) {
entries.push([k, value])
}
}
return entries
}
// plain object map. Only enumerable string keys: a symbol key
// stringifies to "Symbol(x)", which Headers.append rejects as an
// invalid header name — turning a tolerant reader into a throw.
const map = requestHeaders as Record<string, unknown>
for (const k of Object.keys(map)) {
const value = normalizeHeaderValue(map[k])
if (value != null) {
entries.push([k, value])
}
}
return entries
}
function property(source: unknown, key: string): unknown {
if (!source || typeof source !== "object") {
return undefined
}
return (source as Record<string, unknown>)[key]
}
export function toHeaders(req: unknown, requestHeaders: unknown): Headers {
const res = new Headers()
const entries = collectHeaderEntries(requestHeaders)
if (entries) {
for (const [k, v] of entries) {
res.append(k, v)
}
return res
}
// fallback: Node IncomingMessage.rawHeaders (always present on Node),
// a flat list alternating key and value
const rawHeaders = property(req, "rawHeaders")
if (Array.isArray(rawHeaders)) {
const raw = rawHeaders as unknown[]
for (let i = 0; i < raw.length; i += 2) {
const key = normalizeHeaderValue(raw[i])
const value = normalizeHeaderValue(raw[i + 1])
if (key != null && value != null) {
res.append(key, value)
}
}
return res
}
// last resort: regular req.headers
for (const [k, v] of collectHeaderEntries(property(req, "headers")) ??
[]) {
res.append(k, v)
}
return res
}
export function toAxiosHeaders(requestHeaders: unknown): AxiosHeaders {
const res = new AxiosHeaders()
const entries = collectHeaderEntries(requestHeaders)
if (entries) {
for (const [k, v] of entries) {
res.set(k, v)
}
return res
}
return res
}

View File

@ -0,0 +1,691 @@
import { describe, it, vi, type Mock } from "vitest"
import * as Y from "yjs"
import {
createDocumentHooks,
createHocuspocus,
parseDocumentName,
resolveBranchId,
type AuthSession,
} from "./hocuspocus.js"
import { replaceYdocContent, transformer } from "./ydocument.js"
import { fragmentXml, stubCore, type StubCore } from "./test-helpers.js"
type ConnectedDocument = Y.Doc & { broadcastStateless: Mock }
const SESSION: AuthSession = { user: { id: "user-1" } }
function stubAuth(session: AuthSession | null = SESSION) {
return { getSession: vi.fn().mockResolvedValue(session) }
}
// the hocuspocus Document adds the stateless broadcast the store hook
// reaches for when a persist fails
function connectedDocument(): ConnectedDocument {
const doc = new Y.Doc() as ConnectedDocument
doc.broadcastStateless = vi.fn()
return doc
}
function seededDocument(name = "Runbook"): ConnectedDocument {
const doc = connectedDocument()
replaceYdocContent(doc, {
name,
content: {
type: "doc",
content: [
{
type: "paragraph",
attrs: { uid: "p1" },
content: [
{
type: "text",
text: "Restart it",
},
],
},
],
},
icon: "lucide:file",
})
return doc
}
function hooksWith(core: StubCore, session: AuthSession | null = SESSION) {
return createDocumentHooks({ auth: stubAuth(session), core })
}
describe("parseDocumentName", () => {
it.for([
{
name: "a concrete branch id",
input: "doc1-branch1",
expected: {
documentId: "doc1",
branchIdentifier: "branch1",
},
},
{
name: "the default branch marker",
input: "doc1-default",
expected: {
documentId: "doc1",
branchIdentifier: "default",
},
},
{
name: "a branch identifier containing dashes",
input: "doc1-feature-x-2",
expected: {
documentId: "doc1",
branchIdentifier: "feature-x-2",
},
},
])(
"splits $name on the first dash",
({ input, expected }, { expect }) => {
expect(parseDocumentName(input)).toEqual(expected)
},
)
})
describe("resolveBranchId", () => {
it("returns a concrete branch identifier without asking core", async ({
expect,
}) => {
const core = stubCore()
const branchId = await resolveBranchId(core, "doc1", "branch1")
expect(branchId).toBe("branch1")
expect(core.fetchBranches).toHaveBeenCalledTimes(0)
})
// the main branch is identified by its default flag, not by being
// first in the list or by its name
it("resolves 'default' to the branch core flags as default", async ({
expect,
}) => {
const core = stubCore()
core.fetchBranches.mockResolvedValue([
{ branchId: "branch-2", default: false },
{ branchId: "branch-1", default: true },
])
const branchId = await resolveBranchId(core, "doc1", "default")
expect(branchId).toBe("branch-1")
expect(core.fetchBranches).toHaveBeenCalledWith("doc1")
})
it("throws when no branch is flagged as default", async ({
expect,
}) => {
const core = stubCore()
core.fetchBranches.mockResolvedValue([
{ branchId: "branch-2", default: false },
])
await expect(
resolveBranchId(core, "doc1", "default"),
).rejects.toThrow("no default branch found for document doc1")
})
it("propagates a failed branch lookup", async ({ expect }) => {
const failure = new Error("core unreachable")
const core = stubCore()
core.fetchBranches.mockRejectedValue(failure)
await expect(
resolveBranchId(core, "doc1", "default"),
).rejects.toBe(failure)
})
})
describe("createDocumentHooks", () => {
describe("onAuthenticate", () => {
it("returns the session for an authenticated connection", async ({
expect,
}) => {
const core = stubCore()
const auth = stubAuth()
const hooks = createDocumentHooks({ auth, core })
const result = await hooks.onAuthenticate({
documentName: "doc1-branch1",
request: {},
requestHeaders: { cookie: "auth.session=abc" },
token: "",
})
expect(result).toEqual({ session: SESSION })
expect(auth.getSession).toHaveBeenCalledTimes(1)
})
it("confirms the document exists with the caller's own headers", async ({
expect,
}) => {
const core = stubCore()
const hooks = hooksWith(core)
await hooks.onAuthenticate({
documentName: "doc1-branch1",
request: {},
requestHeaders: { cookie: "auth.session=abc" },
token: "",
})
expect(core.fetchBranches).toHaveBeenCalledTimes(1)
const [documentId, options] =
core.fetchBranches.mock.calls[0] ?? []
expect(documentId).toBe("doc1")
expect(options?.headers?.get("cookie")).toBe(
"auth.session=abc",
)
})
it("rejects a connection with no session", async ({
expect,
}) => {
const core = stubCore()
const hooks = hooksWith(core, null)
await expect(
hooks.onAuthenticate({
documentName: "doc1-branch1",
request: {},
requestHeaders: {},
token: "",
}),
).rejects.toThrow("not authenticated")
expect(core.fetchBranches).toHaveBeenCalledTimes(0)
})
// the e2e suite uses this token to drive the failure path
// without having to break the session
it("rejects the force-error token even with a valid session", async ({
expect,
}) => {
const core = stubCore()
const hooks = hooksWith(core)
await expect(
hooks.onAuthenticate({
documentName: "doc1-branch1",
request: {},
requestHeaders: {},
token: "force-error",
}),
).rejects.toThrow("not authenticated")
expect(core.fetchBranches).toHaveBeenCalledTimes(0)
})
it("propagates a failed document lookup", async ({
expect,
}) => {
const failure = new Error("document not found")
const core = stubCore()
core.fetchBranches.mockRejectedValue(failure)
const hooks = hooksWith(core)
await expect(
hooks.onAuthenticate({
documentName: "doc1-branch1",
request: {},
requestHeaders: {},
token: "",
}),
).rejects.toBe(failure)
})
})
describe("beforeHandleMessage", () => {
// a placeholder for re-checking the session mid-connection; it
// must still resolve, because hocuspocus awaits it on every
// inbound message
it("lets every message through", async ({ expect }) => {
const hooks = hooksWith(stubCore())
await expect(
hooks.beforeHandleMessage(),
).resolves.toBeUndefined()
})
})
describe("onChange", () => {
it("records the editing user as a maintainer of the document", async ({
expect,
}) => {
const core = stubCore()
const hooks = hooksWith(core)
await hooks.onChange({
context: { session: SESSION },
documentName: "doc1-branch1",
})
await hooks.onStoreDocument({
documentName: "doc1-branch1",
document: seededDocument(),
})
expect(
core.storeBranchContent.mock.calls[0]?.[2]
.maintainers,
).toEqual(["user-1"])
})
it("records each editor once", async ({ expect }) => {
const core = stubCore()
const hooks = hooksWith(core)
await hooks.onChange({
context: { session: SESSION },
documentName: "doc1-branch1",
})
await hooks.onChange({
context: { session: SESSION },
documentName: "doc1-branch1",
})
await hooks.onChange({
context: {
session: { user: { id: "user-2" } },
},
documentName: "doc1-branch1",
})
await hooks.onStoreDocument({
documentName: "doc1-branch1",
document: seededDocument(),
})
expect(
core.storeBranchContent.mock.calls[0]?.[2]
.maintainers,
).toEqual(["user-1", "user-2"])
})
it("ignores a change carrying no session", async ({
expect,
}) => {
const core = stubCore()
const hooks = hooksWith(core)
await hooks.onChange({
context: null,
documentName: "doc1-branch1",
})
await hooks.onStoreDocument({
documentName: "doc1-branch1",
document: seededDocument(),
})
expect(
core.storeBranchContent.mock.calls[0]?.[2]
.maintainers,
).toEqual([])
})
it("keeps each document's editors separate", async ({
expect,
}) => {
const core = stubCore()
const hooks = hooksWith(core)
await hooks.onChange({
context: { session: SESSION },
documentName: "doc1-branch1",
})
await hooks.onStoreDocument({
documentName: "doc2-branch1",
document: seededDocument(),
})
expect(
core.storeBranchContent.mock.calls[0]?.[2]
.maintainers,
).toEqual([])
})
})
describe("onLoadDocument", () => {
it("restores the stored Yjs state when core has one", async ({
expect,
}) => {
const stored = seededDocument("Stored name")
const core = stubCore()
core.fetchBranchContent.mockResolvedValue({
documentName: "Stored name",
content: { type: "doc", content: [] },
icon: "lucide:file",
rawContent: Buffer.from(
Y.encodeStateAsUpdate(stored),
).toString("base64"),
})
const hooks = hooksWith(core)
const ydoc = await hooks.onLoadDocument({
documentName: "doc1-branch1",
})
expect(fragmentXml(ydoc, "content")).toContain(
"Restart it",
)
// the state is already persisted; writing it back would
// only churn the row
expect(core.storeBranchContent).toHaveBeenCalledTimes(0)
})
it("seeds a branch that has never been opened from its stored content", async ({
expect,
}) => {
const core = stubCore()
core.fetchBranchContent.mockResolvedValue({
documentName: "Runbook",
content: {
type: "doc",
content: [
{
type: "paragraph",
attrs: { uid: "p1" },
content: [
{
type: "text",
text: "From core",
},
],
},
],
},
icon: "lucide:file",
rawContent: null,
})
const hooks = hooksWith(core)
const ydoc = await hooks.onLoadDocument({
documentName: "doc1-branch1",
})
expect(fragmentXml(ydoc, "content")).toContain(
"From core",
)
expect(ydoc.getText("icon").toJSON()).toBe(
"lucide:file",
)
})
// without this the next restart would build a second Y.Doc with
// a different clientID, whose state CRDT-merges with the
// reconnecting clients' and duplicates every block
it("persists the seeded state immediately as a system write", async ({
expect,
}) => {
const core = stubCore()
const hooks = hooksWith(core)
await hooks.onLoadDocument({
documentName: "doc1-branch1",
})
expect(core.storeBranchContent).toHaveBeenCalledTimes(1)
const [documentId, branchId, update] =
core.storeBranchContent.mock.calls[0] ?? []
expect(documentId).toBe("doc1")
expect(branchId).toBe("branch1")
expect(update?.system).toBe(true)
expect(update?.maintainers).toEqual([])
expect(update?.rawContent).not.toBe("")
})
it("falls back to a placeholder when the stored content is not a document", async ({
expect,
}) => {
const core = stubCore()
core.fetchBranchContent.mockResolvedValue({
documentName: "Runbook",
content: { type: "fragment" },
icon: "lucide:file",
rawContent: null,
})
const hooks = hooksWith(core)
const ydoc = await hooks.onLoadDocument({
documentName: "doc1-branch1",
})
expect(fragmentXml(ydoc, "content")).toContain(
"Hello World!",
)
})
// a row with no content at all is broken, not empty. Seeding a
// placeholder would persist over whatever it should have held.
it.for([
{ name: "null", input: null },
{ name: "absent", input: undefined },
])(
"fails the load when the stored content is $name",
async ({ input }, { expect }) => {
const core = stubCore()
core.fetchBranchContent.mockResolvedValue({
documentName: "Runbook",
content: input,
icon: "lucide:file",
rawContent: null,
})
const hooks = hooksWith(core)
await expect(
hooks.onLoadDocument({
documentName: "doc1-branch1",
}),
).rejects.toThrow(
"document doc1 branch branch1 has no stored content",
)
expect(
core.storeBranchContent,
).toHaveBeenCalledTimes(0)
},
)
it("resolves the default branch before asking for its content", async ({
expect,
}) => {
const core = stubCore()
core.fetchBranches.mockResolvedValue([
{ branchId: "branch-1", default: true },
])
const hooks = hooksWith(core)
await hooks.onLoadDocument({
documentName: "doc1-default",
})
expect(core.fetchBranchContent).toHaveBeenCalledWith(
"doc1",
"branch-1",
)
})
// the editor is already usable at this point; failing the load
// would disconnect the client over a write it can retry
it("still returns the document when persisting the seed fails", async ({
expect,
}) => {
const core = stubCore()
core.storeBranchContent.mockRejectedValue(
new Error("core unreachable"),
)
const hooks = hooksWith(core)
const ydoc = await hooks.onLoadDocument({
documentName: "doc1-branch1",
})
expect(ydoc).toBeInstanceOf(Y.Doc)
})
it("propagates a failed content fetch", async ({ expect }) => {
const failure = new Error("core unreachable")
const core = stubCore()
core.fetchBranchContent.mockRejectedValue(failure)
const hooks = hooksWith(core)
await expect(
hooks.onLoadDocument({
documentName: "doc1-branch1",
}),
).rejects.toBe(failure)
})
})
describe("onStoreDocument", () => {
it("persists the document's name, icon and content as a user write", async ({
expect,
}) => {
const core = stubCore()
const hooks = hooksWith(core)
await hooks.onStoreDocument({
documentName: "doc1-branch1",
document: seededDocument("Runbook"),
})
expect(core.storeBranchContent).toHaveBeenCalledTimes(1)
const [documentId, branchId, update] =
core.storeBranchContent.mock.calls[0] ?? []
expect(documentId).toBe("doc1")
expect(branchId).toBe("branch1")
expect(update?.name).toBe("Runbook")
expect(update?.icon).toBe("lucide:file")
expect(update?.system).toBe(false)
expect(update?.rawContent).not.toBe("")
})
it("persists content the transformer can read back", async ({
expect,
}) => {
const core = stubCore()
const hooks = hooksWith(core)
const document = seededDocument()
await hooks.onStoreDocument({
documentName: "doc1-branch1",
document,
})
expect(
core.storeBranchContent.mock.calls[0]?.[2]
.content,
).toEqual(transformer.fromYdoc(document, "content"))
})
// the maintainers field is who edited during this persist, not
// the document's whole maintainer set, so it has to start empty
// again after every write
it("sends no maintainers on a second persist with no edits between", async ({
expect,
}) => {
const core = stubCore()
const hooks = hooksWith(core)
await hooks.onChange({
context: { session: SESSION },
documentName: "doc1-branch1",
})
await hooks.onStoreDocument({
documentName: "doc1-branch1",
document: seededDocument(),
})
await hooks.onStoreDocument({
documentName: "doc1-branch1",
document: seededDocument(),
})
expect(
core.storeBranchContent.mock.calls[1]?.[2]
.maintainers,
).toEqual([])
})
it("names an untitled document rather than persisting an empty name", async ({
expect,
}) => {
const core = stubCore()
const hooks = hooksWith(core)
await hooks.onStoreDocument({
documentName: "doc1-branch1",
document: connectedDocument(),
})
expect(
core.storeBranchContent.mock.calls[0]?.[2].name,
).toBe("Untitled Document")
})
// the editors keep their unsaved work either way; telling them
// the persist failed is the only thing left to do
it("warns the connected editors instead of throwing when the persist fails", async ({
expect,
}) => {
const core = stubCore()
core.storeBranchContent.mockRejectedValue(
new Error("core unreachable"),
)
const hooks = hooksWith(core)
const document = seededDocument()
await hooks.onStoreDocument({
documentName: "doc1-branch1",
document,
})
expect(
document.broadcastStateless,
).toHaveBeenCalledWith(
JSON.stringify({
type: "error",
code: "hocuspocus.store_failed",
}),
)
})
it("warns the connected editors when the branch cannot be resolved", async ({
expect,
}) => {
const core = stubCore()
core.fetchBranches.mockResolvedValue([])
const hooks = hooksWith(core)
const document = seededDocument()
await hooks.onStoreDocument({
documentName: "doc1-default",
document,
})
expect(
document.broadcastStateless,
).toHaveBeenCalledTimes(1)
expect(core.storeBranchContent).toHaveBeenCalledTimes(0)
})
})
})
describe("createHocuspocus", () => {
it("wires the document hooks onto the server", ({ expect }) => {
const server = createHocuspocus({
auth: stubAuth(),
core: stubCore(),
})
expect(typeof server.configuration.onAuthenticate).toBe(
"function",
)
expect(typeof server.configuration.onLoadDocument).toBe(
"function",
)
expect(typeof server.configuration.onStoreDocument).toBe(
"function",
)
})
})

View File

@ -1,24 +1,44 @@
import * as Sentry from "@sentry/node"
import * as Y from "yjs"
import axios from "axios"
import { nanoid } from "nanoid"
import { AxiosHeaders } from "axios"
import { Hocuspocus } from "@hocuspocus/server"
import { Logger } from "@hocuspocus/extension-logger"
import { auth } from "./auth.js"
import type { CoreClient } from "./core.js"
import { toAxiosHeaders, toHeaders } from "./headers.js"
import { bestEffort, reported } from "./reporting.js"
import { replaceYdocContent, transformer } from "./ydocument.js"
const url = process.env.OXYNOTE_AUTH_REALTIME_BACKEND_URL
export interface AuthSession {
user: { id: string }
}
const documentMaintainers = new Map<string, Set<string>>()
// the one thing the document hooks ask of better-auth. Narrowing it here
// keeps the hooks drivable from a test with a two-line stub instead of a
// configured auth instance.
export interface SessionResolver {
getSession(input: { headers: Headers }): Promise<AuthSession | null>
}
// parseDocumentName splits a document name of the form "documentId-branchId"
// into its component parts. branchId may be the special value "default".
function parseDocumentName(documentName: string): {
export interface DocumentHookDeps {
auth: SessionResolver
core: CoreClient
}
// hocuspocus's Document adds the stateless broadcast the store hook uses
// to tell connected editors that a persist failed.
interface ConnectedDocument extends Y.Doc {
broadcastStateless(payload: string): void
}
// parseDocumentName splits a document name of the form
// "documentId-branchIdentifier" into its component parts. The split is on
// the first dash: xids carry none, but a branch identifier may.
export function parseDocumentName(documentName: string): {
documentId: string
branchIdentifier: string
} {
const idx = documentName.indexOf("-")
return {
documentId: documentName.slice(0, idx),
branchIdentifier: documentName.slice(idx + 1),
@ -27,8 +47,9 @@ function parseDocumentName(documentName: string): {
// resolveBranchId resolves a branchIdentifier to a concrete branch ID.
// When the identifier is "default", it fetches the document's branches and
// returns the ID of the oldest one (the original branch).
async function resolveBranchId(
// returns the one flagged as the default.
export async function resolveBranchId(
core: CoreClient,
documentId: string,
branchIdentifier: string,
): Promise<string> {
@ -36,12 +57,7 @@ async function resolveBranchId(
return branchIdentifier
}
const response = await axios.get(
`${url}/api/x/documents/${documentId}/branches`,
)
const branches: { branchId: string; default: boolean }[] = response.data
const branches = await core.fetchBranches(documentId)
const defaultBranch = branches.find((b) => b.default)
if (!defaultBranch) {
@ -53,315 +69,269 @@ async function resolveBranchId(
return defaultBranch.branchId
}
export const hocuspocus = new Hocuspocus({
extensions: [new Logger()],
async onAuthenticate({ documentName, request, requestHeaders, token }) {
const { documentId } = parseDocumentName(documentName)
// the placeholder a branch gets when core has content for it that is not a
// prosemirror document — a branch that has never been written to.
function defaultDocumentContent() {
return {
type: "doc",
content: [
{
type: "paragraph",
attrs: { uid: nanoid() },
content: [
{ type: "text", text: "Hello World!" },
],
},
],
}
}
const session = await auth.api.getSession({
headers: toHeaders(request, requestHeaders),
})
if (!session || token === "force-error") {
throw new Error("not authenticated")
}
// core stores whatever the last persist wrote. Content that is not a
// prosemirror document belongs to a branch nobody has written to yet, and
// gets the placeholder — but a branch with no content at all is a broken
// row, and failing the load is what surfaces that. Seeding an empty
// document instead would persist over whatever the row should have held.
function seedContent(
content: unknown,
documentId: string,
branchId: string,
): unknown {
if (content === null || content === undefined) {
throw new Error(
`document ${documentId} branch ${branchId} has no stored content`,
)
}
// Verify the user has access to this document by checking the oldest
// branch. FetchDocumentBranchesUnsafe does not require an org filter —
// the session check above confirms identity and the branch-list call
// below confirms existence.
try {
await axios.get(
`${url}/api/x/documents/${documentId}/branches`,
{
if (
typeof content === "object" &&
(content as { type?: unknown }).type === "doc"
) {
return content
}
return defaultDocumentContent()
}
function documentTitle(doc: Y.Doc): string {
const name = transformer.fromYdoc(doc, "name") as {
content?: { content?: { text?: string }[] }[]
}
return name.content?.[0]?.content?.[0]?.text || "Untitled Document"
}
function encodeState(doc: Y.Doc): string {
return Buffer.from(Y.encodeStateAsUpdate(doc)).toString("base64")
}
export function createDocumentHooks({ auth, core }: DocumentHookDeps) {
// who edited each open document since its last persist. Held per
// hooks instance so nothing carries over between servers — or, in a
// test, between cases.
const documentMaintainers = new Map<string, Set<string>>()
return {
async onAuthenticate({
documentName,
request,
requestHeaders,
token,
}: {
documentName: string
request: any
requestHeaders: any
token: string
}) {
const { documentId } = parseDocumentName(documentName)
const session = await auth.getSession({
headers: toHeaders(request, requestHeaders),
})
if (!session || token === "force-error") {
throw new Error("not authenticated")
}
// verify the user has access to this document by
// listing its branches. The call needs no org filter —
// the session check above confirms identity and this
// confirms the document exists.
await reported(() =>
core.fetchBranches(documentId, {
headers: toAxiosHeaders(requestHeaders),
},
)
} catch (err) {
Sentry.captureException(err)
throw err
}
/* TODO check edit permissions
if (!canEdit) {
connectionConfig.readOnly = true // allow read but block edits
}
*/
return {
session: session,
}
},
// TODO kick users if auth becomes invalid later
async beforeHandleMessage() {
// periodically re-check better auth session and close the socket if
// needed
},
async onChange({ context, documentName }) {
const session = context?.session
const userId = session?.user?.id
if (!userId) {
return
}
let set = documentMaintainers.get(documentName)
if (!set) {
set = new Set()
documentMaintainers.set(documentName, set)
}
set.add(userId)
},
async onLoadDocument({ documentName }) {
try {
const { documentId, branchIdentifier } =
parseDocumentName(documentName)
const branchId = await resolveBranchId(
documentId,
branchIdentifier,
}),
)
const response = await axios.get(
`${url}/api/x/documents/${documentId}/branch/${branchId}`,
)
return { session }
},
// TODO kick users if auth becomes invalid later
beforeHandleMessage() {
// periodically re-check better auth session and close
// the socket if needed
return Promise.resolve()
},
onChange({
context,
documentName,
}: {
context?: { session?: AuthSession | null } | null
documentName: string
}) {
const userId = context?.session?.user.id
if (!userId) {
return Promise.resolve()
}
let set = documentMaintainers.get(documentName)
if (!set) {
set = new Set()
documentMaintainers.set(documentName, set)
}
set.add(userId)
return Promise.resolve()
},
onLoadDocument({
documentName,
}: {
documentName: string
}): Promise<Y.Doc> {
return reported(async () => {
const { documentId, branchIdentifier } =
parseDocumentName(documentName)
const branchId = await resolveBranchId(
core,
documentId,
branchIdentifier,
)
const branch = await core.fetchBranchContent(
documentId,
branchId,
)
if (branch.rawContent) {
const ydoc = new Y.Doc()
Y.applyUpdate(
ydoc,
new Uint8Array(
Buffer.from(
branch.rawContent,
"base64",
),
),
)
return ydoc
}
if (!response.data.rawContent) {
const ydoc = new Y.Doc()
replaceYdocContent(ydoc, {
name: response.data.documentName,
content:
response.data.content.type ==
"doc"
? response.data.content
: {
type: "doc",
content: [
{
type: "paragraph",
attrs: {
uid: nanoid(),
},
content: [
{
type: "text",
text: "Hello World!",
},
],
},
],
},
icon: response.data.icon,
name: branch.documentName,
content: seedContent(
branch.content,
documentId,
branchId,
),
icon: branch.icon,
})
// Persist rawContent immediately to prevent content duplication.
// Without this, if the server restarts before onStoreDocument runs,
// onLoadDocument would create a new Y.Doc with a different clientID,
// causing the reconnecting client's existing state to merge with the
// new state instead of being recognized as identical — duplicating
// all content.
const rawContent = Buffer.from(
Y.encodeStateAsUpdate(ydoc),
).toString("base64")
try {
await axios.put(
`${url}/api/x/documents/${documentId}/branch/${branchId}`,
// persist rawContent immediately to prevent
// content duplication. Without this, if the
// server restarts before onStoreDocument runs,
// onLoadDocument would create a new Y.Doc with a
// different clientID, causing the reconnecting
// client's existing state to merge with the new
// state instead of being recognized as identical
// — duplicating all content.
await bestEffort(() =>
core.storeBranchContent(
documentId,
branchId,
{
name: response.data
.documentName,
icon: response.data
.icon,
content: response.data
.content,
name: branch.documentName,
icon: branch.icon,
content: branch.content,
maintainers: [],
rawContent,
rawContent: encodeState(
ydoc,
),
system: true,
},
)
} catch (err) {
Sentry.captureException(err)
}
),
)
return ydoc
}
})
},
const ydoc = new Y.Doc()
async onStoreDocument(data: {
documentName: string
document: ConnectedDocument
}) {
try {
const { documentId, branchIdentifier } =
parseDocumentName(data.documentName)
const branchId = await resolveBranchId(
core,
documentId,
branchIdentifier,
)
const data = new Uint8Array(
Buffer.from(response.data.rawContent, "base64"),
)
// the field is who edited during this persist,
// not the document's maintainer set — core only
// ever adds to that set, so draining the map
// here is what keeps the two consistent.
const maintainers = Array.from(
documentMaintainers.get(
data.documentName,
) ?? [],
)
documentMaintainers.delete(data.documentName)
Y.applyUpdate(ydoc, data)
return ydoc
} catch (err) {
Sentry.captureException(err)
throw err
}
},
async onStoreDocument(data) {
try {
const { documentId, branchIdentifier } =
parseDocumentName(data.documentName)
const branchId = await resolveBranchId(
documentId,
branchIdentifier,
)
const maintainers = Array.from(
documentMaintainers.get(data.documentName) ??
[],
)
documentMaintainers.delete(data.documentName)
const name =
transformer.fromYdoc(data.document, "name")
.content[0]?.content[0]?.text ||
"Untitled Document"
await axios.put(
`${url}/api/x/documents/${documentId}/branch/${branchId}`,
{
name: name,
icon: data.document
.getText("icon")
.toString(),
content: transformer.fromYdoc(
data.document,
"content",
),
maintainers: maintainers,
rawContent: Buffer.from(
Y.encodeStateAsUpdate(
await core.storeBranchContent(
documentId,
branchId,
{
name: documentTitle(
data.document,
),
).toString("base64"),
system: false,
},
)
} catch (err) {
Sentry.captureException(err)
data.document.broadcastStateless(
JSON.stringify({
type: "error",
code: "hocuspocus.store_failed",
}),
)
}
},
})
type HeaderEntry = [string, string]
function normalizeHeaderValue(value: unknown): string | null {
if (value == null) {
return null
}
return Array.isArray(value) ? value.join(", ") : String(value)
}
function collectHeaderEntries(requestHeaders: any): HeaderEntry[] | null {
if (!requestHeaders) {
return null
}
// WHATWG Headers or compatible
if (typeof requestHeaders.forEach === "function") {
const entries: HeaderEntry[] = []
requestHeaders.forEach((v: string, k: string) => {
const value = normalizeHeaderValue(v)
if (value != null) {
entries.push([k, value])
}
})
return entries
}
// Iterable header entries
if (typeof requestHeaders[Symbol.iterator] === "function") {
const entries: HeaderEntry[] = []
for (const [k, v] of requestHeaders as any) {
const value = normalizeHeaderValue(v)
if (value != null) {
entries.push([k, value])
}
}
return entries
}
// Plain object map
if (typeof requestHeaders === "object") {
const entries: HeaderEntry[] = []
for (const k of Reflect.ownKeys(requestHeaders) as string[]) {
const value = normalizeHeaderValue(requestHeaders[k])
if (value != null) {
entries.push([k, value])
}
}
return entries
}
return null
}
function toHeaders(req: any, requestHeaders: any): Headers {
const res = new Headers()
const entries = collectHeaderEntries(requestHeaders)
if (entries) {
for (const [k, v] of entries) {
res.append(k, v)
}
return res
}
// fallback: Node IncomingMessage.rawHeaders (always present on Node)
if (req?.rawHeaders && Array.isArray(req.rawHeaders)) {
for (let i = 0; i < req.rawHeaders.length; i += 2) {
const k = String(req.rawHeaders[i])
const v = String(req.rawHeaders[i + 1])
res.append(k, v)
}
return res
}
// last resort: regular req.headers
if (req?.headers && typeof req.headers === "object") {
for (const k of Reflect.ownKeys(req.headers) as string[]) {
const v = req.headers[k as any]
if (v != null)
res.set(
k,
Array.isArray(v)
? v.join(", ")
: String(v),
icon: data.document
.getText("icon")
.toJSON(),
content: transformer.fromYdoc(
data.document,
"content",
) as unknown,
maintainers,
rawContent: encodeState(
data.document,
),
system: false,
},
)
}
} catch (err) {
Sentry.captureException(err)
return res
data.document.broadcastStateless(
JSON.stringify({
type: "error",
code: "hocuspocus.store_failed",
}),
)
}
},
}
return res
}
export function toAxiosHeaders(requestHeaders: any): AxiosHeaders {
const res = new AxiosHeaders()
const entries = collectHeaderEntries(requestHeaders)
if (entries) {
for (const [k, v] of entries) {
res.set(k, v)
}
return res
}
return res
export function createHocuspocus(deps: DocumentHookDeps): Hocuspocus {
return new Hocuspocus({
extensions: [new Logger()],
...createDocumentHooks(deps),
})
}

View File

@ -1,14 +1,40 @@
import type { IncomingMessage } from "node:http"
import { serve } from "@hono/node-server"
import { Hono } from "hono"
import { createNodeWebSocket } from "@hono/node-ws"
import { auth } from "./auth.js"
import { hocuspocus } from "./hocuspocus.js"
import routes from "./routes.js"
import { createClient } from "redis"
import { loadEnv } from "./env.js"
import { createDatabase } from "./db.js"
import { createCoreClient } from "./core.js"
import { createAuth } from "./auth.js"
import { createHocuspocus } from "./hocuspocus.js"
import { createRoutes } from "./routes.js"
// the composition root: the only module that reads the environment, opens
// connections, or listens. Everything below it is a factory taking what it
// needs, which is what makes the rest of the service testable without a
// database, a redis, or a port.
const env = loadEnv(process.env)
const { store, dialect } = createDatabase(env.databaseDSN)
const core = createCoreClient(env.coreUrl)
const redis = createClient({ url: env.valkeyUrl })
await redis.connect()
const auth = createAuth({ env, store, dialect, redis, core })
const hocuspocus = createHocuspocus({
auth: { getSession: (input) => auth.api.getSession(input) },
core,
})
const app = new Hono()
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
// kept as one object rather than destructured: both are methods bound to
// the adapter's own state.
const nodeWs = createNodeWebSocket({ app })
app.route("/api", routes)
app.route("/api", createRoutes({ env, auth, store, hocuspocus, core }))
// the RFC 9728 protected-resource metadata for the MCP surface is served
// by the mcp plugin's request hook, which matches root-anchored
@ -24,9 +50,15 @@ app.on(
)
app.get(
"/hocuspocus",
upgradeWebSocket((c) => ({
nodeWs.upgradeWebSocket((c) => ({
onOpen(_evt, ws) {
hocuspocus.handleConnection(ws.raw, c.req.raw as any)
// hocuspocus wants node's IncomingMessage; hono hands
// over a fetch Request. Only the headers are read from
// it, and toHeaders accepts either shape.
hocuspocus.handleConnection(
ws.raw,
c.req.raw as unknown as IncomingMessage,
)
},
})),
)
@ -37,7 +69,7 @@ const server = serve(
port: 8081,
},
(info) => {
hocuspocus.hooks("onListen", {
void hocuspocus.hooks("onListen", {
instance: hocuspocus,
configuration: hocuspocus.configuration,
port: info.port,
@ -48,7 +80,7 @@ const server = serve(
)
},
)
injectWebSocket(server)
nodeWs.injectWebSocket(server)
// graceful shutdown
process.on("SIGINT", () => {

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,9 @@ import { transformer, cloneXmlElement } from "./ydocument.js"
/** Canonical attribute name carried on every editor block. */
const UID_ATTR = "uid"
/** The sides an insert may name, as a widened list for validation. */
const INSERT_POSITIONS: string[] = ["before", "after"]
/**
* One batched operation against a document. Shapes mirror the Go
* canonical/op definitions; the Node side only validates the
@ -177,29 +180,59 @@ export function applyOperations(doc: Y.Doc, ops: Operation[]): ApplyResult {
function applyOperation(doc: Y.Doc, op: Operation): void {
switch (op.kind) {
case "insert":
return opInsert(doc, op)
opInsert(doc, op)
return
case "append":
return opAppend(doc, op)
opAppend(doc, op)
return
case "prepend":
return opPrepend(doc, op)
opPrepend(doc, op)
return
case "replace":
return opReplace(doc, op)
opReplace(doc, op)
return
case "update_text":
return opUpdateText(doc, op)
opUpdateText(doc, op)
return
case "update_attrs":
return opUpdateAttrs(doc, op)
opUpdateAttrs(doc, op)
return
case "delete":
return opDelete(doc, op)
opDelete(doc, op)
return
case "set_name":
return opSetName(doc, op)
opSetName(doc, op)
return
case "set_icon":
return opSetIcon(doc, op)
opSetIcon(doc, op)
return
default: {
// operations arrive as unvalidated JSON, so the switch
// can be handed a kind this service does not implement
// — which means the two sides have drifted apart.
// Falling through would report the no-op as applied.
const { kind } = op as { kind?: unknown }
throw new Error(
`unknown operation kind: ${String(kind)}`,
)
}
}
}
/* ---------------- per-op handlers ---------------- */
function opInsert(doc: Y.Doc, op: InsertOp): void {
// the position is whatever the JSON payload carried, so anything but
// the two sides is rejected rather than silently taken as "after".
// Checked against a list because comparing the declared union against
// its own members reads as dead code to the type checker.
if (!INSERT_POSITIONS.includes(op.position)) {
throw new Error(
`insert position must be "before" or "after", got: ${op.position}`,
)
}
const found = findByUid(doc.getXmlFragment("content"), op.reference_uid)
if (!found) {
throw new Error(`reference_uid not found: ${op.reference_uid}`)
@ -415,6 +448,7 @@ export function pmBlockToY(block: PMNode): Y.XmlElement {
* content is empty so callers can skip the insert.
*/
function buildInlineText(content: PMInline[]): Y.XmlText | null {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- operations arrive as unvalidated JSON, so the declared type is a claim about the caller rather than a guarantee about the value
if (!content || content.length === 0) {
return null
}
@ -423,6 +457,7 @@ function buildInlineText(content: PMInline[]): Y.XmlText | null {
let cursor = 0
for (const node of content) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- same as above: the node kind is whatever the JSON payload carried, not what the type says
if (node.type !== "text" || !node.text) {
continue
}

View File

@ -0,0 +1,110 @@
import { beforeEach, describe, it, vi } from "vitest"
import * as Sentry from "@sentry/node"
import { bestEffort, reported } from "./reporting.js"
vi.mock("@sentry/node", () => ({
captureException: vi.fn(),
}))
const captureException = vi.mocked(Sentry.captureException)
// the sentry module mock is a per-file singleton, so call-count assertions
// on it cannot be isolated across interleaving tests. vitest 4's
// restoreMocks does not reset a hand-made vi.fn(), so it is cleared here.
describe("reported", { concurrent: false }, () => {
beforeEach(() => {
captureException.mockClear()
})
it("returns what the work resolved to", async ({ expect }) => {
expect(await reported(() => Promise.resolve("value"))).toBe(
"value",
)
expect(captureException).toHaveBeenCalledTimes(0)
})
it("reports the failure and lets the caller see it", async ({
expect,
}) => {
const failure = new Error("core unreachable")
await expect(
reported(() => Promise.reject(failure)),
).rejects.toBe(failure)
expect(captureException).toHaveBeenCalledTimes(1)
expect(captureException).toHaveBeenCalledWith(failure)
})
// axios rejects with its own error shape rather than an Error, and
// nothing here inspects the value on its way past
it("reports a rejection that is not an Error", async ({ expect }) => {
const rejection = { response: { status: 500 } }
const run = vi.fn().mockRejectedValue(rejection)
await expect(reported(run)).rejects.toBe(rejection)
expect(captureException).toHaveBeenCalledWith(rejection)
})
it("reports a synchronous throw from the work itself", async ({
expect,
}) => {
const failure = new Error("built the request wrong")
await expect(
reported(() => {
throw failure
}),
).rejects.toBe(failure)
expect(captureException).toHaveBeenCalledTimes(1)
})
})
describe("bestEffort", { concurrent: false }, () => {
beforeEach(() => {
captureException.mockClear()
})
it("runs the effect and reports nothing when it succeeds", async ({
expect,
}) => {
const effect = vi.fn().mockResolvedValue(undefined)
await bestEffort(effect)
expect(effect).toHaveBeenCalledTimes(1)
expect(captureException).toHaveBeenCalledTimes(0)
})
// the work this effect belongs to has already succeeded, so a failure
// here must stay visible without reaching the caller
it("reports the failure and resolves anyway", async ({ expect }) => {
const failure = new Error("core unreachable")
await expect(
bestEffort(() => Promise.reject(failure)),
).resolves.toBeUndefined()
expect(captureException).toHaveBeenCalledWith(failure)
})
it("reports a synchronous throw from the effect itself", async ({
expect,
}) => {
const failure = new Error("nothing to disconnect")
await expect(
bestEffort(() => {
throw failure
}),
).resolves.toBeUndefined()
expect(captureException).toHaveBeenCalledWith(failure)
})
// hocuspocus's disconnect is declared as returning void or a promise,
// so the helper has to accept an effect that returns neither
it("accepts an effect that returns nothing", async ({ expect }) => {
const effect = vi.fn()
await expect(bestEffort(effect)).resolves.toBeUndefined()
expect(captureException).toHaveBeenCalledTimes(0)
})
})

View File

@ -0,0 +1,27 @@
import * as Sentry from "@sentry/node"
// runs work whose failure the caller must still see, reporting the error on
// its way out. The frameworks this service hands callbacks to swallow the
// cause — better-auth turns a rejected hook into a failed request and
// hocuspocus into a refused connection — so without this the only trace of
// what went wrong is a status code.
export async function reported<T>(run: () => Promise<T>): Promise<T> {
try {
return await run()
} catch (err) {
Sentry.captureException(err)
throw err
}
}
// runs an effect whose failure must not fail the caller: the work it
// belongs to has already succeeded and the caller has nothing useful to do
// about it. The error is reported and swallowed, so the failure is visible
// without being fatal.
export async function bestEffort(run: () => unknown): Promise<void> {
try {
await run()
} catch (err) {
Sentry.captureException(err)
}
}

View File

@ -0,0 +1,965 @@
import { describe, it, vi } from "vitest"
import * as Y from "yjs"
import { SignJWT, exportJWK, generateKeyPair, type JWTPayload } from "jose"
import { createRoutes, type DocumentRegistry } from "./routes.js"
import { replaceYdocContent } from "./ydocument.js"
import {
fragmentXml,
stubCore,
stubStore,
testEnv,
type StubCore,
type StubStore,
} from "./test-helpers.js"
const ENV = testEnv()
function stubAuth() {
return {
handler: vi
.fn()
.mockResolvedValue(
new Response("handled by better-auth"),
),
api: { getJwks: vi.fn().mockResolvedValue({ keys: [] }) },
}
}
function stubRegistry(
overrides: Partial<{
documents: Map<string, Y.Doc>
transact: (fn: (doc: Y.Doc) => void) => Promise<void>
open: () => Promise<never>
}> = {},
) {
const documents = overrides.documents ?? new Map<string, Y.Doc>()
const disconnect = vi.fn()
const transact = vi.fn(
overrides.transact ??
((fn: (doc: Y.Doc) => void) => {
fn(new Y.Doc())
return Promise.resolve()
}),
)
const openDirectConnection = vi.fn(
overrides.open ??
(() => Promise.resolve({ transact, disconnect })),
)
return {
registry: {
documents,
openDirectConnection,
} as unknown as DocumentRegistry,
openDirectConnection,
transact,
disconnect,
}
}
function build(
overrides: {
env?: typeof ENV
auth?: ReturnType<typeof stubAuth>
store?: StubStore
hocuspocus?: DocumentRegistry
core?: StubCore
} = {},
) {
const auth = overrides.auth ?? stubAuth()
const core = overrides.core ?? stubCore()
const store = overrides.store ?? stubStore()
const app = createRoutes({
env: overrides.env ?? ENV,
auth,
store,
hocuspocus: overrides.hocuspocus ?? stubRegistry().registry,
core,
})
return { app, auth, core, store }
}
function seededDocument(text: string): Y.Doc {
const doc = new Y.Doc()
replaceYdocContent(doc, {
name: "Runbook",
content: {
type: "doc",
content: [
{
type: "paragraph",
attrs: { uid: "p1" },
content: [{ type: "text", text }],
},
],
},
icon: "lucide:file",
})
return doc
}
// a real key pair, so the mcp session route runs its actual signature,
// issuer and audience checks rather than a stubbed verifier
async function mcpKeys() {
const { privateKey, publicKey } = await generateKeyPair("ES256", {
extractable: true,
})
const jwk = await exportJWK(publicKey)
return {
privateKey,
jwks: { keys: [{ ...jwk, kid: "test-key", alg: "ES256" }] },
}
}
async function mcpToken(
privateKey: CryptoKey,
claims: JWTPayload = {},
overrides: { issuer?: string; audience?: string } = {},
) {
return new SignJWT({
azp: "client-1",
org_id: "org-1",
scope: "documents:read documents:write",
...claims,
})
.setProtectedHeader({ alg: "ES256", kid: "test-key" })
.setSubject(claims.sub ?? "user-1")
.setIssuer(overrides.issuer ?? ENV.mcpTokenIssuer)
.setAudience(overrides.audience ?? ENV.mcpResource)
.setExpirationTime("1h")
.sign(privateKey)
}
// a store answering for a user whose consent still stands but who has
// left the organization the token names
function memberlessStore() {
const store = stubStore()
store.isOrganizationMember.mockResolvedValue(false)
return store
}
function bearer(token: string) {
return { headers: { Authorization: `Bearer ${token}` } }
}
describe("createRoutes", () => {
describe("GET /", () => {
it("answers so a health check can see the service is up", async ({
expect,
}) => {
const { app } = build()
const res = await app.request("/")
expect(res.status).toBe(200)
expect(await res.text()).toBe("Hello Hono!")
})
})
describe("GET /auth-config", () => {
it("publishes the sign-in methods the service actually accepts", async ({
expect,
}) => {
const { app } = build({
env: testEnv({
socialProviders: {
github: {
clientId: "id",
clientSecret: "secret",
},
},
}),
})
const res = await app.request("/auth-config")
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
methods: ["email-password", "github"],
})
})
})
describe("/auth/**", () => {
it("hands the raw request to better-auth", async ({
expect,
}) => {
const { app, auth } = build()
const res = await app.request("/auth/get-session")
expect(res.status).toBe(200)
expect(await res.text()).toBe("handled by better-auth")
expect(auth.handler).toHaveBeenCalledTimes(1)
})
})
describe("GET /organizations/stats", () => {
it("reports the slots left under the organization limit", async ({
expect,
}) => {
const store = stubStore()
store.totalOrganizationCount.mockResolvedValue(40)
const { app } = build({
store,
env: testEnv({ maxOrganizations: 50 }),
})
const res = await app.request("/organizations/stats")
expect(await res.json()).toEqual({ availableSlots: 10 })
})
})
describe("PUT /documents/:documentId/merge", () => {
it.for([
{ name: "a body that is not JSON", input: "not json" },
{ name: "a body that is not an object", input: "null" },
{
name: "a body missing fromBranchId",
input: '{"toBranchId":"b1"}',
},
{
name: "a body missing toBranchId",
input: '{"fromBranchId":"b2"}',
},
{
name: "branch ids that are not strings",
input: '{"fromBranchId":1,"toBranchId":2}',
},
])("rejects $name", async ({ input }, { expect }) => {
const { app, core } = build()
const res = await app.request("/documents/doc1/merge", {
method: "PUT",
body: input,
headers: { "Content-Type": "application/json" },
})
expect(res.status).toBe(400)
expect(core.mergeBranches).toHaveBeenCalledTimes(0)
})
// core validates the session and authorizes the merge; this
// service only mirrors the result into the live document
it("forwards the caller's headers to core", async ({
expect,
}) => {
const { app, core } = build()
await app.request("/documents/doc1/merge", {
method: "PUT",
body: JSON.stringify({
fromBranchId: "b2",
toBranchId: "b1",
}),
headers: {
"Content-Type": "application/json",
Cookie: "auth.session=abc",
},
})
expect(core.mergeBranches).toHaveBeenCalledTimes(1)
const [documentId, from, to, options] =
core.mergeBranches.mock.calls[0] ?? []
expect(documentId).toBe("doc1")
expect(from).toBe("b2")
expect(to).toBe("b1")
expect(options?.headers?.get("cookie")).toBe(
"auth.session=abc",
)
})
it("replaces the open target document with the merged content", async ({
expect,
}) => {
const target = seededDocument("Before the merge")
const { registry } = stubRegistry({
documents: new Map([["doc1-b1", target]]),
})
const core = stubCore()
core.mergeBranches.mockResolvedValue({
status: 200,
data: {
documentName: "Runbook",
content: {
type: "doc",
content: [
{
type: "paragraph",
attrs: {
uid: "p9",
},
content: [
{
type: "text",
text: "After the merge",
},
],
},
],
},
icon: "lucide:file",
},
})
const { app } = build({ hocuspocus: registry, core })
await app.request("/documents/doc1/merge", {
method: "PUT",
body: JSON.stringify({
fromBranchId: "b2",
toBranchId: "b1",
}),
headers: { "Content-Type": "application/json" },
})
const content = fragmentXml(target, "content")
expect(content).toContain("After the merge")
expect(content).not.toContain("Before the merge")
})
// core sets rawContent to nil when it merges, so a restart
// before the next store would rebuild the doc under a new
// clientID and duplicate every block on reconnect
it("persists the merged state immediately as a system write", async ({
expect,
}) => {
const target = seededDocument("Before the merge")
const { registry } = stubRegistry({
documents: new Map([["doc1-b1", target]]),
})
const { app, core } = build({ hocuspocus: registry })
await app.request("/documents/doc1/merge", {
method: "PUT",
body: JSON.stringify({
fromBranchId: "b2",
toBranchId: "b1",
}),
headers: { "Content-Type": "application/json" },
})
expect(core.storeBranchContent).toHaveBeenCalledTimes(1)
const [documentId, branchId, update] =
core.storeBranchContent.mock.calls[0] ?? []
expect(documentId).toBe("doc1")
expect(branchId).toBe("b1")
expect(update?.system).toBe(true)
expect(update?.maintainers).toEqual([])
})
it("leaves nothing to mirror when the branch has no open document", async ({
expect,
}) => {
const { app, core } = build()
const res = await app.request("/documents/doc1/merge", {
method: "PUT",
body: JSON.stringify({
fromBranchId: "b2",
toBranchId: "b1",
}),
headers: { "Content-Type": "application/json" },
})
expect(res.status).toBe(200)
expect(core.storeBranchContent).toHaveBeenCalledTimes(0)
})
it("does not touch the open document when core refuses the merge", async ({
expect,
}) => {
const target = seededDocument("Before the merge")
const { registry } = stubRegistry({
documents: new Map([["doc1-b1", target]]),
})
const core = stubCore()
core.mergeBranches.mockResolvedValue({
status: 409,
data: {
documentName: "",
content: null,
icon: "",
},
})
const { app } = build({ hocuspocus: registry, core })
const res = await app.request("/documents/doc1/merge", {
method: "PUT",
body: JSON.stringify({
fromBranchId: "b2",
toBranchId: "b1",
}),
headers: { "Content-Type": "application/json" },
})
expect(res.status).toBe(409)
expect(fragmentXml(target, "content")).toContain(
"Before the merge",
)
expect(core.storeBranchContent).toHaveBeenCalledTimes(0)
})
it("answers 200 even when persisting the merged state fails", async ({
expect,
}) => {
const target = seededDocument("Before the merge")
const { registry } = stubRegistry({
documents: new Map([["doc1-b1", target]]),
})
const core = stubCore()
core.storeBranchContent.mockRejectedValue(
new Error("core unreachable"),
)
const { app } = build({ hocuspocus: registry, core })
const res = await app.request("/documents/doc1/merge", {
method: "PUT",
body: JSON.stringify({
fromBranchId: "b2",
toBranchId: "b1",
}),
headers: { "Content-Type": "application/json" },
})
expect(res.status).toBe(200)
})
// the caller gets core's own answer rather than a generic 500,
// so a rejected merge reads the same through the proxy as it
// would directly
it("forwards core's status and body when core rejects the merge", async ({
expect,
}) => {
const core = stubCore()
core.mergeBranches.mockRejectedValue({
response: {
status: 403,
data: { error: "not a maintainer" },
},
})
const { app } = build({ core })
const res = await app.request("/documents/doc1/merge", {
method: "PUT",
body: JSON.stringify({
fromBranchId: "b2",
toBranchId: "b1",
}),
headers: { "Content-Type": "application/json" },
})
expect(res.status).toBe(403)
expect(await res.json()).toEqual({
error: "not a maintainer",
})
})
it.for([
{
name: "a plain transport failure",
input: new Error("connect ECONNREFUSED"),
},
{
name: "a rejection carrying no response",
input: { message: "boom" },
},
{
name: "a response with no usable status",
input: {
response: { status: "403", data: {} },
},
},
{
name: "a rejection that is not an object",
input: "boom",
},
])("answers 500 for $name", async ({ input }, { expect }) => {
const core = stubCore()
core.mergeBranches.mockRejectedValue(input)
const { app } = build({ core })
const res = await app.request("/documents/doc1/merge", {
method: "PUT",
body: JSON.stringify({
fromBranchId: "b2",
toBranchId: "b1",
}),
headers: {
"Content-Type": "application/json",
},
})
expect(res.status).toBe(500)
expect(await res.json()).toEqual({
error: "failed to merge",
})
})
})
describe("GET /internal/mcp/session", () => {
it.for([
{ name: "no Authorization header", input: undefined },
{ name: "a non-bearer scheme", input: "Basic abc" },
{ name: "an empty bearer", input: "Bearer" },
])("rejects $name", async ({ input }, { expect }) => {
const { app } = build()
const res = await app.request(
"/internal/mcp/session",
input
? { headers: { Authorization: input } }
: {},
)
expect(res.status).toBe(401)
expect(await res.json()).toEqual({
error: "missing bearer token",
})
})
it("rejects a token that is not a signed JWT", async ({
expect,
}) => {
const { app } = build()
const res = await app.request(
"/internal/mcp/session",
bearer("not-a-jwt"),
)
expect(res.status).toBe(401)
expect(await res.json()).toEqual({
error: "invalid token",
})
})
it.for([
{ name: "another issuer", input: "issuer" as const },
{
name: "another audience",
input: "audience" as const,
},
])(
"rejects a token minted for $name",
async ({ input }, { expect }) => {
const { privateKey, jwks } = await mcpKeys()
const auth = stubAuth()
auth.api.getJwks.mockResolvedValue(jwks)
const { app } = build({ auth })
const token = await mcpToken(
privateKey,
{},
{ [input]: "http://evil.example" },
)
const res = await app.request(
"/internal/mcp/session",
bearer(token),
)
expect(res.status).toBe(401)
},
)
it.for([
{ name: "no subject", input: { sub: "" } },
{ name: "no client", input: { azp: undefined } },
{
name: "no organization",
input: { org_id: undefined },
},
])(
"rejects a token with $name",
async ({ input }, { expect }) => {
const { privateKey, jwks } = await mcpKeys()
const auth = stubAuth()
auth.api.getJwks.mockResolvedValue(jwks)
const { app } = build({
auth,
})
const token = await mcpToken(privateKey, input)
const res = await app.request(
"/internal/mcp/session",
bearer(token),
)
expect(res.status).toBe(401)
expect(await res.json()).toEqual({
error: "invalid token",
})
},
)
// revoking a client from the settings UI has to cut off the
// access tokens it already holds, which is why the consent row
// is checked on every call rather than only at issuance
it("rejects a token whose consent has been revoked", async ({
expect,
}) => {
const { privateKey, jwks } = await mcpKeys()
const auth = stubAuth()
auth.api.getJwks.mockResolvedValue(jwks)
const store = stubStore()
store.hasOAuthConsent.mockResolvedValue(false)
const { app } = build({ auth, store })
const token = await mcpToken(privateKey)
const res = await app.request(
"/internal/mcp/session",
bearer(token),
)
expect(res.status).toBe(401)
expect(await res.json()).toEqual({
error: "consent revoked",
})
})
it("rejects a token for an organization the user has left", async ({
expect,
}) => {
const { privateKey, jwks } = await mcpKeys()
const auth = stubAuth()
auth.api.getJwks.mockResolvedValue(jwks)
const { app } = build({
auth,
store: memberlessStore(),
})
const token = await mcpToken(privateKey)
const res = await app.request(
"/internal/mcp/session",
bearer(token),
)
expect(res.status).toBe(401)
expect(await res.json()).toEqual({
error: "not an organization member",
})
})
it("describes the session behind a valid token", async ({
expect,
}) => {
const { privateKey, jwks } = await mcpKeys()
const auth = stubAuth()
auth.api.getJwks.mockResolvedValue(jwks)
const { app } = build({
auth,
})
const token = await mcpToken(privateKey)
const res = await app.request(
"/internal/mcp/session",
bearer(token),
)
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({
userId: "user-1",
organizationId: "org-1",
clientId: "client-1",
scopes: ["documents:read", "documents:write"],
})
})
it("reports no scopes for a token that carries none", async ({
expect,
}) => {
const { privateKey, jwks } = await mcpKeys()
const auth = stubAuth()
auth.api.getJwks.mockResolvedValue(jwks)
const { app } = build({
auth,
})
const token = await mcpToken(privateKey, {
scope: undefined,
})
const res = await app.request(
"/internal/mcp/session",
bearer(token),
)
const body = (await res.json()) as { scopes: string[] }
expect(body.scopes).toEqual([])
})
it("answers 500 when the consent lookup fails", async ({
expect,
}) => {
const { privateKey, jwks } = await mcpKeys()
const auth = stubAuth()
auth.api.getJwks.mockResolvedValue(jwks)
const store = stubStore()
store.hasOAuthConsent.mockRejectedValue(
new Error("connection terminated"),
)
const { app } = build({ auth, store })
const token = await mcpToken(privateKey)
const res = await app.request(
"/internal/mcp/session",
bearer(token),
)
expect(res.status).toBe(500)
expect(await res.json()).toEqual({
error: "token validation failed",
})
})
it("fetches the key set once across repeated calls", async ({
expect,
}) => {
const { privateKey, jwks } = await mcpKeys()
const auth = stubAuth()
auth.api.getJwks.mockResolvedValue(jwks)
const { app } = build({
auth,
})
const token = await mcpToken(privateKey)
await app.request(
"/internal/mcp/session",
bearer(token),
)
await app.request(
"/internal/mcp/session",
bearer(token),
)
expect(auth.api.getJwks).toHaveBeenCalledTimes(1)
})
// a signing key rotates without warning, so a cached key set
// that no longer verifies is refreshed once before the token is
// called invalid
it("refetches the key set once before rejecting after a rotation", async ({
expect,
}) => {
const stale = await mcpKeys()
const current = await mcpKeys()
const auth = stubAuth()
auth.api.getJwks
.mockResolvedValueOnce(stale.jwks)
.mockResolvedValue(current.jwks)
const { app } = build({
auth,
})
const token = await mcpToken(current.privateKey)
const res = await app.request(
"/internal/mcp/session",
bearer(token),
)
expect(res.status).toBe(200)
expect(auth.api.getJwks).toHaveBeenCalledTimes(2)
})
})
describe("POST /internal/documents/:documentId/branches/:branchId/operations", () => {
const path = "/internal/documents/doc1/branches/b1/operations"
it.for([
{ name: "a body that is not JSON", input: "not json" },
{ name: "a body with no operations", input: "{}" },
{
name: "an operations field that is not an array",
input: '{"operations":"append"}',
},
])("rejects $name", async ({ input }, { expect }) => {
const { registry, openDirectConnection } =
stubRegistry()
const { app } = build({ hocuspocus: registry })
const res = await app.request(path, {
method: "POST",
body: input,
headers: { "Content-Type": "application/json" },
})
expect(res.status).toBe(400)
expect(openDirectConnection).toHaveBeenCalledTimes(0)
})
it("applies the batch to the branch's live document", async ({
expect,
}) => {
const doc = new Y.Doc()
const { registry, openDirectConnection, disconnect } =
stubRegistry({
transact: (fn) => {
fn(doc)
return Promise.resolve()
},
})
const { app } = build({ hocuspocus: registry })
const res = await app.request(path, {
method: "POST",
body: JSON.stringify({
operations: [
{
kind: "set_icon",
icon: "lucide:siren",
},
],
}),
headers: { "Content-Type": "application/json" },
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
applied: 1,
errors: [],
})
expect(openDirectConnection).toHaveBeenCalledWith(
"doc1-b1",
)
expect(doc.getText("icon").toJSON()).toBe(
"lucide:siren",
)
expect(disconnect).toHaveBeenCalledTimes(1)
})
it("reports a failing operation without failing the request", async ({
expect,
}) => {
const { registry } = stubRegistry()
const { app } = build({ hocuspocus: registry })
const res = await app.request(path, {
method: "POST",
body: JSON.stringify({
operations: [
{
kind: "delete",
block_uid: "missing",
},
],
}),
headers: { "Content-Type": "application/json" },
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
applied: number
errors: { index: number }[]
}
expect(body.applied).toBe(0)
expect(body.errors).toHaveLength(1)
expect(body.errors[0]?.index).toBe(0)
})
it("answers 500 when the document cannot be opened", async ({
expect,
}) => {
const { registry } = stubRegistry({
open: () =>
Promise.reject(
new Error("no such document"),
),
})
const { app } = build({ hocuspocus: registry })
const res = await app.request(path, {
method: "POST",
body: JSON.stringify({ operations: [] }),
headers: { "Content-Type": "application/json" },
})
expect(res.status).toBe(500)
expect(await res.json()).toEqual({
error: "failed to open document",
})
})
it("releases the connection when applying the batch throws", async ({
expect,
}) => {
const { registry, disconnect } = stubRegistry({
transact: () =>
Promise.reject(
new Error("transaction failed"),
),
})
const { app } = build({ hocuspocus: registry })
const res = await app.request(path, {
method: "POST",
body: JSON.stringify({ operations: [] }),
headers: { "Content-Type": "application/json" },
})
expect(res.status).toBe(500)
expect(await res.json()).toEqual({
error: "failed to apply operations",
})
expect(disconnect).toHaveBeenCalledTimes(1)
})
it("still answers when releasing the connection fails", async ({
expect,
}) => {
const { registry, disconnect } = stubRegistry()
disconnect.mockRejectedValue(
new Error("already closed"),
)
const { app } = build({ hocuspocus: registry })
const res = await app.request(path, {
method: "POST",
body: JSON.stringify({ operations: [] }),
headers: { "Content-Type": "application/json" },
})
expect(res.status).toBe(200)
})
})
describe("cors", () => {
it("allows a trusted origin to send credentials", async ({
expect,
}) => {
const { app } = build()
const res = await app.request("/auth-config", {
headers: { Origin: "http://localhost:8080" },
})
expect(
res.headers.get("Access-Control-Allow-Origin"),
).toBe("http://localhost:8080")
expect(
res.headers.get(
"Access-Control-Allow-Credentials",
),
).toBe("true")
})
it("does not answer an untrusted origin with its own origin", async ({
expect,
}) => {
const { app } = build()
const res = await app.request("/auth-config", {
headers: { Origin: "http://evil.example" },
})
expect(
res.headers.get("Access-Control-Allow-Origin"),
).not.toBe("http://evil.example")
})
})
})

View File

@ -3,343 +3,440 @@ import { Hono } from "hono"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import { cors } from "hono/cors"
import { createLocalJWKSet, jwtVerify } from "jose"
import {
auth,
totalOrganizationCount,
AUTH_METHODS,
MAX_ORGANIZATIONS,
MCP_RESOURCE,
MCP_TOKEN_ISSUER,
} from "./auth.js"
import { db } from "./database.js"
import * as Y from "yjs"
import { hocuspocus, toAxiosHeaders } from "./hocuspocus.js"
import type { Store } from "./db.js"
import type { CoreClient, MergedDocument } from "./core.js"
import type { Env } from "./env.js"
import { authMethods } from "./auth.js"
import { toAxiosHeaders } from "./headers.js"
import { bestEffort } from "./reporting.js"
import { replaceYdocContent } from "./ydocument.js"
import { applyOperations, type Operation } from "./operations.js"
import axios from "axios"
const app = new Hono()
const backendUrl = process.env.OXYNOTE_AUTH_REALTIME_BACKEND_URL
app.use(
"*",
cors({
origin: (
(process.env.OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS ||
"") as string
).split(","),
allowMethods: [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
],
allowHeaders: ["Content-Type", "Authorization"],
credentials: true,
}),
)
app.get("/", (c) => {
return c.text("Hello Hono!")
})
app.on(["POST", "GET"], "/auth/**", (c) => {
return auth.handler(c.req.raw)
})
// public auth configuration for the login/signup pages: which social
// providers are configured (and, later, other signup-related capability
// flags). Lives outside the better-auth /auth/** namespace.
app.get("/auth-config", (c) => {
return c.json({ methods: AUTH_METHODS })
})
app.get("/organizations/stats", async (c) => {
const count = await totalOrganizationCount()
return c.json({ availableSlots: MAX_ORGANIZATIONS - count })
})
// auth is delegated: the caller's headers (including the session cookie)
// are forwarded to core, whose middleware validates the session and
// authorizes the merge. The in-memory Y.Doc mutation below only runs when
// core responds 200, so an unauthenticated caller changes nothing.
app.put("/documents/:documentId/merge", async (c) => {
const documentId = c.req.param("documentId")
let fromBranchId: string
let toBranchId: string
try {
const body = await c.req.json()
fromBranchId = body.fromBranchId
toBranchId = body.toBranchId
} catch {
return c.json({ error: "invalid request body" }, 400)
// the two things the routes ask of better-auth: the request handler
// mounted under /auth/**, and the local JWKS the MCP session endpoint
// verifies bearer tokens against.
export interface AuthHandler {
handler(request: Request): Promise<Response>
api: {
getJwks(): Promise<Parameters<typeof createLocalJWKSet>[0]>
}
if (!fromBranchId || !toBranchId) {
return c.json(
{ error: "fromBranchId and toBranchId are required" },
400,
)
}
try {
const response = await axios.put(
`${backendUrl}/api/documents/${documentId}/merge`,
{ fromBranchId, toBranchId },
{
headers: toAxiosHeaders(c.req.raw.headers),
},
)
if (response.status === 200) {
const mergedData = {
name: response.data.documentName,
content: response.data.content,
icon: response.data.icon,
}
// update the in-memory target branch Y.Doc using direct cloning to
// avoid Y.applyUpdate merge semantics that cause content
// duplication when source and target docs have different
// client origins.
const toDocument = hocuspocus.documents.get(
`${documentId}-${toBranchId}`,
)
if (toDocument) {
replaceYdocContent(toDocument, mergedData)
// persist rawContent immediately because MergeBranches
// sets rawContent to nil in the database. Without
// this, a server restart before onStoreDocument
// would trigger onLoadDocument's first-time path,
// creating a new Y.Doc with a different clientID
// that CRDT-merges with reconnecting clients and
// duplicates content.
const rawContent = Buffer.from(
Y.encodeStateAsUpdate(toDocument),
).toString("base64")
try {
await axios.put(
`${backendUrl}/api/x/documents/${documentId}/branch/${toBranchId}`,
{
name: mergedData.name,
icon: mergedData.icon,
content: mergedData.content,
maintainers: [],
rawContent,
system: true,
},
)
} catch (err) {
Sentry.captureException(err)
}
}
}
return c.json(
response.data,
response.status as ContentfulStatusCode,
)
} catch (error: any) {
Sentry.captureException(error)
if (error.response) {
return c.json(
error.response.data,
error.response.status,
)
}
return c.json({ error: "failed to merge" }, 500)
}
})
// cached JWKS for MCP access-token verification. Cleared on a failed
// verification so a key rotation is picked up on the next request.
let mcpKeySet: ReturnType<typeof createLocalJWKSet> | null = null
async function mcpTokenKeySet(): Promise<ReturnType<typeof createLocalJWKSet>> {
if (!mcpKeySet) {
const jwks = await auth.api.getJwks()
mcpKeySet = createLocalJWKSet(jwks)
}
return mcpKeySet
}
// Server-to-server endpoint called by core to validate an MCP bearer token.
// Signature, issuer, audience and expiry are checked against the local JWKS;
// on top of that the token is only as alive as the consent that produced it,
// so revoking a client from the settings UI cuts off its outstanding access
// tokens immediately. Like the other /internal routes, this is protected by
// the container network plus Caddy's 403 on /auth-realtime/api/internal/*.
app.get("/internal/mcp/session", async (c) => {
const header = c.req.header("Authorization") ?? ""
if (!header.toLowerCase().startsWith("bearer ")) {
return c.json({ error: "missing bearer token" }, 401)
export interface DirectConnection {
transact(transaction: (doc: Y.Doc) => void): Promise<void>
disconnect(): void | Promise<void>
}
// the part of the hocuspocus server the routes reach into: the live
// in-memory documents, and the direct connections the operations endpoint
// opens against them.
export interface DocumentRegistry {
documents: { get(documentName: string): Y.Doc | undefined }
openDirectConnection(documentName: string): Promise<DirectConnection>
}
export interface RouteDeps {
env: Env
auth: AuthHandler
store: Store
hocuspocus: DocumentRegistry
core: CoreClient
}
// request bodies arrive as unvalidated JSON, so every field a route reads
// is taken through a narrowing read rather than trusted from a type
// annotation.
function readString(source: unknown, key: string): string | undefined {
if (!source || typeof source !== "object") {
return undefined
}
const token = header.slice("bearer ".length).trim()
const value = (source as Record<string, unknown>)[key]
let payload
try {
payload = (
await jwtVerify(token, await mcpTokenKeySet(), {
issuer: MCP_TOKEN_ISSUER,
audience: MCP_RESOURCE,
})
).payload
} catch {
// the cached key set may predate a key rotation; retry once
// with a fresh set before rejecting.
mcpKeySet = null
return typeof value === "string" ? value : undefined
}
try {
payload = (
await jwtVerify(token, await mcpTokenKeySet(), {
issuer: MCP_TOKEN_ISSUER,
audience: MCP_RESOURCE,
})
).payload
} catch {
return c.json({ error: "invalid token" }, 401)
}
// an axios rejection carrying core's own response, which the merge route
// forwards verbatim so the caller sees core's status and error body rather
// than a generic 500.
function upstreamResponse(
error: unknown,
): { status: ContentfulStatusCode; data: any } | undefined {
if (!error || typeof error !== "object") {
return undefined
}
const userId = payload.sub
const clientId =
typeof payload.azp === "string" ? payload.azp : undefined
const organizationId =
typeof payload.org_id === "string" ? payload.org_id : undefined
const scopes =
typeof payload.scope === "string"
? payload.scope.split(" ").filter(Boolean)
: []
if (!userId || !clientId || !organizationId) {
return c.json({ error: "invalid token" }, 401)
const response = (error as { response?: unknown }).response
if (!response || typeof response !== "object") {
return undefined
}
try {
const consent = await db
.selectFrom("oauth_consents")
.where("client_id", "=", clientId)
.where("fk_user_id", "=", userId)
.select("client_id")
.executeTakeFirst()
if (!consent) {
return c.json({ error: "consent revoked" }, 401)
}
const member = await db
.selectFrom("organization_members")
.where("fk_user_id", "=", userId)
.where("fk_organization_id", "=", organizationId)
.select("fk_user_id")
.executeTakeFirst()
if (!member) {
return c.json(
{ error: "not an organization member" },
401,
)
}
} catch (err) {
Sentry.captureException(err)
return c.json({ error: "token validation failed" }, 500)
const { status, data } = response as {
status?: unknown
data?: unknown
}
if (typeof status !== "number") {
return undefined
}
return c.json({
userId,
organizationId,
clientId,
scopes,
expiresAt: payload.exp,
return { status: status as ContentfulStatusCode, data }
}
export function createRoutes({
env,
auth,
store,
hocuspocus,
core,
}: RouteDeps): Hono {
const app = new Hono()
app.use(
"*",
cors({
origin: env.trustedOrigins,
allowMethods: [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
],
allowHeaders: ["Content-Type", "Authorization"],
credentials: true,
}),
)
app.get("/", (c) => {
return c.text("Hello Hono!")
})
})
// Server-to-server endpoint called by the Go assistant to apply
// edit operations to a live Y.Doc. Each request opens (or reuses) a
// direct connection to the document, runs the batch inside a single
// Y.Doc transaction, and disconnects. Hocuspocus handles
// broadcasting the resulting update to subscribed clients and
// persisting via onStoreDocument.
//
// This route is service-to-service only, like the Go `/api/x/...`
// endpoints: core calls it directly over the container network, and
// the reverse proxy blocks `/auth-realtime/api/internal/*` at the
// front door.
app.post(
"/internal/documents/:documentId/branches/:branchId/operations",
async (c) => {
app.on(["POST", "GET"], "/auth/**", (c) => {
return auth.handler(c.req.raw)
})
// public auth configuration for the login/signup pages: which social
// providers are configured (and, later, other signup-related
// capability flags). Lives outside the better-auth /auth/**
// namespace.
app.get("/auth-config", (c) => {
return c.json({ methods: authMethods(env) })
})
app.get("/organizations/stats", async (c) => {
const count = await store.totalOrganizationCount()
return c.json({ availableSlots: env.maxOrganizations - count })
})
// auth is delegated: the caller's headers (including the session
// cookie) are forwarded to core, whose middleware validates the
// session and authorizes the merge. The in-memory Y.Doc mutation
// below only runs when core responds 200, so an unauthenticated
// caller changes nothing.
app.put("/documents/:documentId/merge", async (c) => {
const documentId = c.req.param("documentId")
const branchId = c.req.param("branchId")
let body: { operations?: Operation[] }
let body: unknown
try {
body = await c.req.json()
} catch {
return c.json({ error: "invalid request body" }, 400)
}
const ops = body.operations
if (!Array.isArray(ops)) {
const fromBranchId = readString(body, "fromBranchId")
const toBranchId = readString(body, "toBranchId")
if (!fromBranchId || !toBranchId) {
return c.json(
{ error: "operations array is required" },
{
error: "fromBranchId and toBranchId are required",
},
400,
)
}
// Hocuspocus addresses documents by `documentId-branchId`; the
// Go caller resolves "default" to a concrete branchId before
// sending so this path can be a direct lookup.
const documentName = `${documentId}-${branchId}`
let connection
try {
connection =
await hocuspocus.openDirectConnection(
documentName,
const response = await core.mergeBranches(
documentId,
fromBranchId,
toBranchId,
{ headers: toAxiosHeaders(c.req.raw.headers) },
)
if (response.status === 200) {
await applyMergeToOpenDocument(
documentId,
toBranchId,
response.data,
)
} catch (err) {
Sentry.captureException(err)
return c.json({ error: "failed to open document" }, 500)
}
return c.json(
response.data,
response.status as ContentfulStatusCode,
)
} catch (error) {
Sentry.captureException(error)
const upstream = upstreamResponse(error)
if (upstream) {
return c.json(upstream.data, upstream.status)
}
return c.json({ error: "failed to merge" }, 500)
}
})
// mirrors the merge into the target branch's live Y.Doc, if one is
// open. Direct cloning rather than Y.applyUpdate: source and target
// docs have different client origins, and merging them CRDT-style
// duplicates the content instead of replacing it.
async function applyMergeToOpenDocument(
documentId: string,
toBranchId: string,
merged: MergedDocument,
): Promise<void> {
const toDocument = hocuspocus.documents.get(
`${documentId}-${toBranchId}`,
)
if (!toDocument) {
return
}
replaceYdocContent(toDocument, {
name: merged.documentName,
content: merged.content,
icon: merged.icon,
})
// persist rawContent immediately because MergeBranches sets
// rawContent to nil in the database. Without this, a server
// restart before onStoreDocument would trigger
// onLoadDocument's first-time path, creating a new Y.Doc with
// a different clientID that CRDT-merges with reconnecting
// clients and duplicates content.
await bestEffort(() =>
core.storeBranchContent(documentId, toBranchId, {
name: merged.documentName,
icon: merged.icon,
content: merged.content,
maintainers: [],
rawContent: Buffer.from(
Y.encodeStateAsUpdate(toDocument),
).toString("base64"),
system: true,
}),
)
}
// cached JWKS for MCP access-token verification. Cleared on a failed
// verification so a key rotation is picked up on the next request.
let mcpKeySet: ReturnType<typeof createLocalJWKSet> | null = null
async function mcpTokenKeySet(): Promise<
ReturnType<typeof createLocalJWKSet>
> {
mcpKeySet ??= createLocalJWKSet(await auth.api.getJwks())
return mcpKeySet
}
async function verifyMCPToken(token: string) {
const options = {
issuer: env.mcpTokenIssuer,
audience: env.mcpResource,
}
try {
let result: {
applied: number
errors: { index: number; message: string }[]
} = {
applied: 0,
errors: [],
return (
await jwtVerify(
token,
await mcpTokenKeySet(),
options,
)
).payload
} catch {
// the cached key set may predate a key rotation; retry
// once with a fresh set before rejecting.
mcpKeySet = null
return (
await jwtVerify(
token,
await mcpTokenKeySet(),
options,
)
).payload
}
}
// Server-to-server endpoint called by core to validate an MCP bearer
// token. Signature, issuer, audience and expiry are checked against
// the local JWKS; on top of that the token is only as alive as the
// consent that produced it, so revoking a client from the settings UI
// cuts off its outstanding access tokens immediately. Like the other
// /internal routes, this is protected by the container network plus
// Caddy's 403 on /auth-realtime/api/internal/*.
app.get("/internal/mcp/session", async (c) => {
const header = c.req.header("Authorization") ?? ""
if (!header.toLowerCase().startsWith("bearer ")) {
return c.json({ error: "missing bearer token" }, 401)
}
const token = header.slice("bearer ".length).trim()
let payload
try {
payload = await verifyMCPToken(token)
} catch {
return c.json({ error: "invalid token" }, 401)
}
const userId = payload.sub
const clientId =
typeof payload.azp === "string"
? payload.azp
: undefined
const organizationId =
typeof payload.org_id === "string"
? payload.org_id
: undefined
const scopes =
typeof payload.scope === "string"
? payload.scope.split(" ").filter(Boolean)
: []
if (!userId || !clientId || !organizationId) {
return c.json({ error: "invalid token" }, 401)
}
try {
if (!(await store.hasOAuthConsent(clientId, userId))) {
return c.json({ error: "consent revoked" }, 401)
}
await connection.transact((doc) => {
result = applyOperations(doc, ops)
})
return c.json(result, 200)
if (
!(await store.isOrganizationMember(
userId,
organizationId,
))
) {
return c.json(
{ error: "not an organization member" },
401,
)
}
} catch (err) {
Sentry.captureException(err)
return c.json(
{ error: "failed to apply operations" },
500,
)
} finally {
return c.json({ error: "token validation failed" }, 500)
}
return c.json({
userId,
organizationId,
clientId,
scopes,
expiresAt: payload.exp,
})
})
// Server-to-server endpoint called by the Go assistant to apply
// edit operations to a live Y.Doc. Each request opens (or reuses) a
// direct connection to the document, runs the batch inside a single
// Y.Doc transaction, and disconnects. Hocuspocus handles
// broadcasting the resulting update to subscribed clients and
// persisting via onStoreDocument.
//
// This route is service-to-service only, like the Go `/api/x/...`
// endpoints: core calls it directly over the container network, and
// the reverse proxy blocks `/auth-realtime/api/internal/*` at the
// front door.
app.post(
"/internal/documents/:documentId/branches/:branchId/operations",
async (c) => {
const documentId = c.req.param("documentId")
const branchId = c.req.param("branchId")
let body: unknown
try {
await connection.disconnect()
body = await c.req.json()
} catch {
return c.json(
{ error: "invalid request body" },
400,
)
}
const operations =
body && typeof body === "object"
? (body as { operations?: unknown })
.operations
: undefined
if (!Array.isArray(operations)) {
return c.json(
{
error: "operations array is required",
},
400,
)
}
// each op is validated as it is applied — a malformed
// one lands in the batch's errors list rather than
// failing the request.
const ops = operations as Operation[]
// Hocuspocus addresses documents by
// `documentId-branchId`; the Go caller resolves
// "default" to a concrete branchId before sending so
// this path can be a direct lookup.
const documentName = `${documentId}-${branchId}`
let connection
try {
connection =
await hocuspocus.openDirectConnection(
documentName,
)
} catch (err) {
Sentry.captureException(err)
return c.json(
{ error: "failed to open document" },
500,
)
}
}
},
)
export default app
try {
let result: {
applied: number
errors: {
index: number
message: string
}[]
} = {
applied: 0,
errors: [],
}
await connection.transact((doc) => {
result = applyOperations(doc, ops)
})
return c.json(result, 200)
} catch (err) {
Sentry.captureException(err)
return c.json(
{ error: "failed to apply operations" },
500,
)
} finally {
await bestEffort(() => connection.disconnect())
}
},
)
return app
}

View File

@ -27,7 +27,7 @@ export const CalloutBlock = Node.create({
return element.getAttribute("data-icon")
},
renderHTML: (attrs) => ({
"data-icon": attrs.icon,
"data-icon": attrs.icon as string,
}),
},
}

View File

@ -52,9 +52,11 @@ export const CodeBlock = CodeBlockLowlight.extend<CodeBlockOptions>({
node,
HTMLAttributes,
}: {
node: any
node: { attrs: Record<string, unknown> }
HTMLAttributes: Record<string, any>
}) {
const language = node.attrs.language as string | null
return [
"pre",
mergeAttributes(HTMLAttributes, {
@ -63,10 +65,8 @@ export const CodeBlock = CodeBlockLowlight.extend<CodeBlockOptions>({
[
"code",
{
class: node.attrs.language
? this.options
.languageClassPrefix +
node.attrs.language
class: language
? `${this.options.languageClassPrefix ?? ""}${language}`
: null,
},
0,

View File

@ -14,7 +14,9 @@ export const FigmaBlock = Node.create({
element.getAttribute("data-src"),
renderHTML: (attrs) => {
if (!attrs.src) return {}
return { "data-src": attrs.src }
return {
"data-src": attrs.src as string,
}
},
},
width: {
@ -28,7 +30,10 @@ export const FigmaBlock = Node.create({
},
renderHTML: (attrs) => {
if (!attrs.width) return {}
return { "data-width": attrs.width }
return {
"data-width":
attrs.width as number,
}
},
},
height: {
@ -42,7 +47,10 @@ export const FigmaBlock = Node.create({
},
renderHTML: (attrs) => {
if (!attrs.height) return {}
return { "data-height": attrs.height }
return {
"data-height":
attrs.height as number,
}
},
},
}

View File

@ -49,7 +49,7 @@ import { AddedMark, DeletedMark, CommentMark } from "./marks.js"
const contentExtensionsWithIDs: Extensions = [
Paragraph,
Heading.configure({ levels: [1, 2, 3] }).extend({
marks: `${CommentMark.name}`,
marks: CommentMark.name,
}),
HorizontalRule,
Blockquote,
@ -115,7 +115,9 @@ export function getEditorExtensions(): Extensions {
UniqueID.configure({
types: contentExtensionsWithIDs.map((v) => v.name),
attributeName: "uid",
generateID: () => nanoid(),
// annotated because tiptap declares generateID as
// returning any, which propagates to the arrow
generateID: (): string => nanoid(),
}),
]
}

View File

@ -40,10 +40,6 @@ export const DeletedMark = Mark.create({
},
})
export interface CommentAttrs {
commentId: string
}
export const CommentMark = Mark.create({
name: "comment",
inclusive: false,
@ -58,7 +54,7 @@ export const CommentMark = Mark.create({
renderHTML: (attrs) => {
return {
"data-comment-id":
attrs.commentId,
attrs.commentId as string,
}
},
},

View File

@ -5,7 +5,7 @@ export const MermaidBlock = Node.create({
name: "mermaidBlock",
group: "block",
content: "text*",
marks: `${CommentMark.name}`,
marks: CommentMark.name,
code: true,
defining: true,
isolating: true,

View File

@ -7,7 +7,7 @@ import { CalloutBlock } from "./callout.js"
import { TitledCodeBlock } from "./code-block.js"
import { MetricBlock } from "./metric.js"
export const allowedLeftSideContent = [
const allowedLeftSideContent = [
Paragraph.name,
BulletList.name,
OrderedList.name,
@ -15,7 +15,7 @@ export const allowedLeftSideContent = [
CalloutBlock.name,
]
export const extraLeftSideContent = [ParameterList.name]
const extraLeftSideContent = [ParameterList.name]
export const SplitDocumentationLeftSide = Node.create({
name: "splitDocumentationLeftSide",

View File

@ -0,0 +1,83 @@
import { vi, type Mock } from "vitest"
import type * as Y from "yjs"
import type { CoreClient } from "./core.js"
import type { Store } from "./db.js"
import type { Env } from "./env.js"
export type StubStore = {
[K in keyof Store]: Mock<Store[K]>
}
// the store as its callers see it: four questions with answers. Defaults
// describe a user who belongs to an organization and a client whose
// consent still stands, so a test states only the condition it is about.
export function stubStore(): StubStore {
return {
totalOrganizationCount: vi.fn().mockResolvedValue(0),
userOrganizationId: vi.fn().mockResolvedValue("org-1"),
hasOAuthConsent: vi.fn().mockResolvedValue(true),
isOrganizationMember: vi.fn().mockResolvedValue(true),
}
}
// the serialized XML of one of a document's fragments. toJSON is the
// string-typed reader; toString comes off yjs's untyped base class.
export function fragmentXml(doc: Y.Doc, name: string): string {
return doc.getXmlFragment(name).toJSON()
}
export type StubCore = {
[K in keyof CoreClient]: Mock<CoreClient[K]>
}
// a fully wired core client whose every call resolves. A test that
// cares about one call overrides it and reads the others' call counts,
// which is what makes "nothing else was persisted" assertable.
export function stubCore(): StubCore {
return {
sendEmail: vi.fn().mockResolvedValue(undefined),
initializeOrganization: vi.fn().mockResolvedValue(undefined),
teardownOrganization: vi.fn().mockResolvedValue(undefined),
fetchBranches: vi.fn().mockResolvedValue([]),
fetchBranchContent: vi.fn().mockResolvedValue({
documentName: "Doc",
content: { type: "doc", content: [] },
icon: "lucide:file",
rawContent: null,
}),
storeBranchContent: vi.fn().mockResolvedValue(undefined),
mergeBranches: vi.fn().mockResolvedValue({
status: 200,
data: {
documentName: "Doc",
content: { type: "doc", content: [] },
icon: "lucide:file",
},
}),
}
}
// a complete, valid configuration. Tests override only the field under
// test so an unrelated env change cannot quietly alter what they assert.
export function testEnv(overrides: Partial<Env> = {}): Env {
return {
coreUrl: "http://core:8080",
databaseDSN: "postgresql://devuser:devpass@postgres/devdb",
valkeyUrl: "redis://valkey:6379",
publicAuthBaseUrl: "http://localhost:8080/auth-realtime",
authOrigin: "http://localhost:8080",
betterAuthSecret: "sup3rs3cr3t",
cookieDomain: "localhost",
frontendUrl: "http://localhost:8080",
organizationInvitationUrl:
"http://localhost:8080/accept-invite",
trustedOrigins: ["http://localhost:8080"],
mcpResource: "http://localhost:8080/core/api/mcp",
mcpTokenIssuer: "http://localhost:8080/api/auth",
socialProviders: {},
maxOrganizations: 100,
maxOrganizationMembers: 5,
rateLimitEnabled: true,
...overrides,
}
}

View File

@ -0,0 +1,21 @@
// yjs answers a read from a type that was never added to a document with a
// console warning rather than an error, so a fixture that forgets to attach
// its type still passes and the warning scrolls past — and vitest only
// prints intercepted stderr under the verbose reporter, so it is invisible
// in a piped or CI run. Turn it into a failure attributed to the test that
// caused it.
const PREMATURE_ACCESS = "Invalid access: Add Yjs type to a document"
const warn: typeof console.warn = console.warn.bind(console)
console.warn = (...args: unknown[]) => {
const [first] = args
if (typeof first === "string" && first.includes(PREMATURE_ACCESS)) {
throw new Error(
`${first} — the yjs type under test was never added to a document, so it cannot be read from. Attach it first.`,
)
}
warn(...args)
}

View File

@ -0,0 +1,312 @@
import { describe, it } from "vitest"
import * as Y from "yjs"
import { fragmentXml } from "./test-helpers.js"
import {
cloneXmlElement,
replaceYdocContent,
transformer,
type DocumentData,
} from "./ydocument.js"
function paragraph(uid: string, text: string) {
return {
type: "paragraph",
attrs: { uid },
content: [{ type: "text", text }],
}
}
function documentData(overrides: Partial<DocumentData> = {}): DocumentData {
return {
name: "Runbook",
content: {
type: "doc",
content: [paragraph("p1", "Restart the service")],
},
icon: "lucide:file",
...overrides,
}
}
// the serialized content fragment, which is where duplication shows up
function contentXml(ydoc: Y.Doc): string {
return fragmentXml(ydoc, "content")
}
// yjs refuses to read a type that is not integrated into a document, so
// both the elements under test and the clones taken from them are attached
// to a doc of their own before anything inspects them
function attached(element: Y.XmlElement): Y.XmlElement {
new Y.Doc().getXmlFragment("content").insert(0, [element])
return element
}
// Y.XmlElement.setAttribute is declared as accepting strings, but the
// runtime stores arbitrary values — which is exactly what the clone has to
// carry over
function setAnyAttribute(
element: Y.XmlElement,
key: string,
value: unknown,
): void {
;(
element as unknown as {
setAttribute(key: string, value: unknown): void
}
).setAttribute(key, value)
}
// an element carrying the kind of non-string attribute Y.XmlElement.clone()
// drops — the reason cloneXmlElement exists at all
function elementWithComplexAttributes(): Y.XmlElement {
const element = attached(new Y.XmlElement("metricBlock"))
setAnyAttribute(element, "uid", "m1")
setAnyAttribute(element, "queries", [{ expr: "up", legend: "up" }])
setAnyAttribute(element, "config", { unit: "short", refresh: 30 })
return element
}
describe("cloneXmlElement", () => {
it("copies the source's node name", ({ expect }) => {
const source = attached(new Y.XmlElement("calloutBlock"))
expect(attached(cloneXmlElement(source)).nodeName).toBe(
"calloutBlock",
)
})
it("copies string attributes", ({ expect }) => {
const source = attached(new Y.XmlElement("paragraph"))
source.setAttribute("uid", "p1")
const clone = attached(cloneXmlElement(source))
expect(clone.getAttribute("uid")).toBe("p1")
})
// Y.XmlElement.clone() copies only string attributes, silently
// dropping arrays and objects — which is what would strip a metric
// block's queries on every branch fork and merge
it("preserves array and object attributes", ({ expect }) => {
const clone = attached(
cloneXmlElement(elementWithComplexAttributes()),
)
expect(clone.getAttribute("queries")).toEqual([
{ expr: "up", legend: "up" },
])
expect(clone.getAttribute("config")).toEqual({
unit: "short",
refresh: 30,
})
})
it("deep-clones nested elements", ({ expect }) => {
const source = attached(new Y.XmlElement("calloutBlock"))
const child = new Y.XmlElement("paragraph")
source.insert(0, [child])
child.setAttribute("uid", "p1")
const clone = attached(cloneXmlElement(source))
const clonedChild = clone.get(0)
expect(clonedChild).toBeInstanceOf(Y.XmlElement)
expect((clonedChild as Y.XmlElement).getAttribute("uid")).toBe(
"p1",
)
})
it("clones text children with their formatting", ({ expect }) => {
const source = attached(new Y.XmlElement("paragraph"))
const text = new Y.XmlText()
source.insert(0, [text])
text.insert(0, "bold", { bold: true })
const clone = attached(cloneXmlElement(source))
const clonedText = clone.get(0) as Y.XmlText
expect(clonedText).toBeInstanceOf(Y.XmlText)
expect(clonedText.toDelta()).toEqual([
{ insert: "bold", attributes: { bold: true } },
])
})
it("returns an element with no children when the source has none", ({
expect,
}) => {
const source = attached(new Y.XmlElement("horizontalRule"))
expect(attached(cloneXmlElement(source)).length).toBe(0)
})
it("detaches the clone so mutating it leaves the source alone", ({
expect,
}) => {
const source = attached(new Y.XmlElement("paragraph"))
source.setAttribute("uid", "p1")
const clone = attached(cloneXmlElement(source))
clone.setAttribute("uid", "p2")
expect(source.getAttribute("uid")).toBe("p1")
})
})
describe("replaceYdocContent", () => {
it("writes the name into the name fragment", ({ expect }) => {
const ydoc = new Y.Doc()
replaceYdocContent(ydoc, documentData({ name: "Runbook" }))
expect(fragmentXml(ydoc, "name")).toContain("Runbook")
})
it("writes the content into the content fragment", ({ expect }) => {
const ydoc = new Y.Doc()
replaceYdocContent(ydoc, documentData())
expect(contentXml(ydoc)).toContain("Restart the service")
})
it("writes the icon into the icon text", ({ expect }) => {
const ydoc = new Y.Doc()
replaceYdocContent(ydoc, documentData({ icon: "lucide:siren" }))
expect(ydoc.getText("icon").toJSON()).toBe("lucide:siren")
})
it("clears the icon when the replacement has none", ({ expect }) => {
const ydoc = new Y.Doc()
replaceYdocContent(ydoc, documentData({ icon: "lucide:siren" }))
replaceYdocContent(ydoc, documentData({ icon: "" }))
expect(ydoc.getText("icon").toJSON()).toBe("")
})
// the whole reason this function exists instead of Y.applyUpdate:
// merging two states with different client origins keeps both copies
it("leaves one copy when the same content is applied twice", ({
expect,
}) => {
const ydoc = new Y.Doc()
replaceYdocContent(ydoc, documentData())
const afterFirst = contentXml(ydoc)
replaceYdocContent(ydoc, documentData())
expect(contentXml(ydoc)).toEqual(afterFirst)
})
it("removes the previous content rather than appending to it", ({
expect,
}) => {
const ydoc = new Y.Doc()
replaceYdocContent(ydoc, documentData())
replaceYdocContent(
ydoc,
documentData({
content: {
type: "doc",
content: [
paragraph(
"p2",
"Page the on-call",
),
],
},
}),
)
const text = contentXml(ydoc)
expect(text).toContain("Page the on-call")
expect(text).not.toContain("Restart the service")
})
it("replaces the name rather than appending to it", ({ expect }) => {
const ydoc = new Y.Doc()
replaceYdocContent(ydoc, documentData({ name: "Runbook" }))
replaceYdocContent(ydoc, documentData({ name: "Playbook" }))
const name = fragmentXml(ydoc, "name")
expect(name).toContain("Playbook")
expect(name).not.toContain("Runbook")
})
// content written by a doc with a different clientID is what a branch
// merge carries. Cloning it in must not CRDT-merge with what is
// already there.
it("takes over content from a document with a different client id", ({
expect,
}) => {
const source = new Y.Doc()
replaceYdocContent(
source,
documentData({
content: {
type: "doc",
content: [
paragraph(
"p9",
"Merged from a branch",
),
],
},
}),
)
const target = new Y.Doc()
replaceYdocContent(target, documentData())
expect(target.clientID).not.toBe(source.clientID)
replaceYdocContent(target, {
name: "Runbook",
content: transformer.fromYdoc(
source,
"content",
) as unknown,
icon: "lucide:file",
})
const text = contentXml(target)
expect(text).toContain("Merged from a branch")
expect(text).not.toContain("Restart the service")
})
// the persisted rawContent has to reload into an identical document,
// because that is what stops a restart from creating a second clientID
// whose state merges with the reconnecting clients'
it("produces a state that reloads into an identical document", ({
expect,
}) => {
const ydoc = new Y.Doc()
replaceYdocContent(ydoc, documentData())
const reloaded = new Y.Doc()
Y.applyUpdate(reloaded, Y.encodeStateAsUpdate(ydoc))
expect(contentXml(reloaded)).toEqual(contentXml(ydoc))
expect(reloaded.getText("icon").toJSON()).toBe(
ydoc.getText("icon").toJSON(),
)
})
it("emits a single update for the whole replacement", ({ expect }) => {
const ydoc = new Y.Doc()
let updates = 0
ydoc.on("update", () => {
updates++
})
replaceYdocContent(ydoc, documentData())
expect(updates).toBe(1)
})
})

View File

@ -33,6 +33,8 @@ export function cloneXmlElement(source: Y.XmlElement): Y.XmlElement {
const attrs = source.getAttributes()
for (const [key, value] of Object.entries(attrs)) {
if (value === undefined) {
// NOCOV: getAttributes never yields an undefined value for a
// key it reports, so this only guards a future yjs change.
continue
}
@ -47,8 +49,10 @@ export function cloneXmlElement(source: Y.XmlElement): Y.XmlElement {
return cloneXmlElement(child)
}
const newText = new Y.XmlText()
// Y.XmlText.toDelta is declared as returning
// `any`, while applyDelta wants a delta list.
newText.applyDelta(
(child as Y.XmlText).toDelta(),
(child as Y.XmlText).toDelta() as any[],
)
return newText
}),
@ -64,8 +68,11 @@ function cloneXmlFragment(source: Y.XmlFragment, target: Y.XmlFragment): void {
if (item instanceof Y.XmlElement) {
target.insert(i, [cloneXmlElement(item)])
} else if (item instanceof Y.XmlText) {
// NOCOV: both fragments this runs over come from the tiptap
// transformer, whose schema only ever puts block elements at
// the top level — bare text there is unreachable today.
const newText = new Y.XmlText()
newText.applyDelta(item.toDelta())
newText.applyDelta(item.toDelta() as any[])
target.insert(i, [newText])
}
}

View File

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"exclude": [
"node_modules",
"dist",
"src/**/*.test.ts",
"src/**/test-helpers.ts",
"src/test-setup.ts"
]
}

View File

@ -3,8 +3,6 @@
"target": "ESNext",
"module": "NodeNext",
"types": ["node"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"outDir": "./dist",
"paths": {
"@/*": ["./src/*"]
@ -19,7 +17,9 @@
"noImplicitAny": true,
"forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true
"noImplicitOverride": true,
"allowUnreachableCode": false
},
"exclude": ["node_modules"]
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,59 @@
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 service 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"],
// turns yjs's premature-access warning into a failure; see the
// file for why a warning is not enough
setupFiles: ["./src/test-setup.ts"],
coverage: {
provider: "v8",
// terminal summary only — no coverage/ artifact
// directory for git, prettier, and eslint to trip over.
// Pass --coverage.reporter=html ad hoc for the
// browsable report.
reporter: ["text"],
include: ["src/**/*.ts"],
exclude: [
// the composition root: it reads the environment,
// opens connections and listens, so evaluating it
// is starting the service. What it wires together
// is covered through the factories it calls.
"src/index.ts",
// loaded through node's --import before the app
// graph exists, so it can hold no imports of ours
"src/sentry.ts",
// the editor schema mirrors web's tiptap
// extension list; it is configuration, and the
// behaviour that depends on it is covered through
// the transformer in ydocument and operations
"src/schema/**",
"src/**/test-helpers.ts",
"src/test-setup.ts",
],
// set from the measured baseline and ratcheted up as
// suites grow — never lowered. 100% is not the target:
// genuinely untestable branches stay visible in the
// report, marked with NOCOV comments in place
thresholds: {
statements: 98.4,
branches: 96.3,
functions: 98.4,
lines: 98.4,
},
},
},
})