From 026cd568563b25e0c94d63fe978754aa3b2ea4df Mon Sep 17 00:00:00 2001 From: swithek <52840391+swithek@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:52:59 +0200 Subject: [PATCH] add lint and test scripts to server/auth-realtime --- .github/workflows/qa-auth-realtime.yml | 83 + CLAUDE.md | 2 +- Makefile | 11 +- README.md | 2 +- server/CLAUDE.md | 32 +- server/auth-realtime/CLAUDE.md | 338 ++++ server/auth-realtime/Dockerfile.dev | 5 +- server/auth-realtime/eslint.config.mjs | 99 +- server/auth-realtime/knip.ts | 17 + server/auth-realtime/package.json | 29 +- server/auth-realtime/pnpm-lock.yaml | 1620 ++++++++++++++++- server/auth-realtime/src/auth.test.ts | 760 ++++++++ server/auth-realtime/src/auth.ts | 1365 +++++++------- server/auth-realtime/src/core.test.ts | 257 +++ server/auth-realtime/src/core.ts | 179 ++ server/auth-realtime/src/database.ts | 38 - server/auth-realtime/src/db.test.ts | 201 ++ server/auth-realtime/src/db.ts | 116 ++ server/auth-realtime/src/env.test.ts | 301 +++ server/auth-realtime/src/env.ts | 240 +++ server/auth-realtime/src/headers.test.ts | 378 ++++ server/auth-realtime/src/headers.ts | 163 ++ server/auth-realtime/src/hocuspocus.test.ts | 691 +++++++ server/auth-realtime/src/hocuspocus.ts | 570 +++--- server/auth-realtime/src/index.ts | 50 +- server/auth-realtime/src/operations.test.ts | 1087 +++++++++++ server/auth-realtime/src/operations.ts | 53 +- server/auth-realtime/src/reporting.test.ts | 110 ++ server/auth-realtime/src/reporting.ts | 27 + server/auth-realtime/src/routes.test.ts | 965 ++++++++++ server/auth-realtime/src/routes.ts | 685 ++++--- server/auth-realtime/src/schema/callout.ts | 2 +- server/auth-realtime/src/schema/code-block.ts | 10 +- server/auth-realtime/src/schema/figma.ts | 14 +- server/auth-realtime/src/schema/index.ts | 6 +- server/auth-realtime/src/schema/marks.ts | 6 +- server/auth-realtime/src/schema/mermaid.ts | 2 +- .../src/schema/split-documentation.ts | 4 +- .../src/{instrument.ts => sentry.ts} | 0 server/auth-realtime/src/test-helpers.ts | 83 + server/auth-realtime/src/test-setup.ts | 21 + server/auth-realtime/src/ydocument.test.ts | 312 ++++ server/auth-realtime/src/ydocument.ts | 11 +- server/auth-realtime/tsconfig.build.json | 10 + server/auth-realtime/tsconfig.json | 8 +- server/auth-realtime/vitest.config.ts | 59 + 46 files changed, 9590 insertions(+), 1432 deletions(-) create mode 100644 .github/workflows/qa-auth-realtime.yml create mode 100644 server/auth-realtime/CLAUDE.md create mode 100644 server/auth-realtime/knip.ts create mode 100644 server/auth-realtime/src/auth.test.ts create mode 100644 server/auth-realtime/src/core.test.ts create mode 100644 server/auth-realtime/src/core.ts delete mode 100644 server/auth-realtime/src/database.ts create mode 100644 server/auth-realtime/src/db.test.ts create mode 100644 server/auth-realtime/src/db.ts create mode 100644 server/auth-realtime/src/env.test.ts create mode 100644 server/auth-realtime/src/env.ts create mode 100644 server/auth-realtime/src/headers.test.ts create mode 100644 server/auth-realtime/src/headers.ts create mode 100644 server/auth-realtime/src/hocuspocus.test.ts create mode 100644 server/auth-realtime/src/operations.test.ts create mode 100644 server/auth-realtime/src/reporting.test.ts create mode 100644 server/auth-realtime/src/reporting.ts create mode 100644 server/auth-realtime/src/routes.test.ts rename server/auth-realtime/src/{instrument.ts => sentry.ts} (100%) create mode 100644 server/auth-realtime/src/test-helpers.ts create mode 100644 server/auth-realtime/src/test-setup.ts create mode 100644 server/auth-realtime/src/ydocument.test.ts create mode 100644 server/auth-realtime/tsconfig.build.json create mode 100644 server/auth-realtime/vitest.config.ts diff --git a/.github/workflows/qa-auth-realtime.yml b/.github/workflows/qa-auth-realtime.yml new file mode 100644 index 00000000..58dea04b --- /dev/null +++ b/.github/workflows/qa-auth-realtime.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index d7bebde3..3985cc5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/Makefile b/Makefile index 10b84496..025a39f3 100644 --- a/Makefile +++ b/Makefile @@ -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' diff --git a/README.md b/README.md index 19ebfc13..2110e2b8 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 2abd34d4..1fe8bee0 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -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 `"-"`. `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. diff --git a/server/auth-realtime/CLAUDE.md b/server/auth-realtime/CLAUDE.md new file mode 100644 index 00000000..38fc35bc --- /dev/null +++ b/server/auth-realtime/CLAUDE.md @@ -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: .` 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). diff --git a/server/auth-realtime/Dockerfile.dev b/server/auth-realtime/Dockerfile.dev index 9a14a42e..cce4722d 100644 --- a/server/auth-realtime/Dockerfile.dev +++ b/server/auth-realtime/Dockerfile.dev @@ -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"] diff --git a/server/auth-realtime/eslint.config.mjs b/server/auth-realtime/eslint.config.mjs index 804e6fdc..6fdeb515 100644 --- a/server/auth-realtime/eslint.config.mjs +++ b/server/auth-realtime/eslint.config.mjs @@ -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 }, + ], }, }, ) diff --git a/server/auth-realtime/knip.ts b/server/auth-realtime/knip.ts new file mode 100644 index 00000000..82e5f28c --- /dev/null +++ b/server/auth-realtime/knip.ts @@ -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 diff --git a/server/auth-realtime/package.json b/server/auth-realtime/package.json index 64c1ba53..51439675 100644 --- a/server/auth-realtime/package.json +++ b/server/auth-realtime/package.json @@ -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" } } diff --git a/server/auth-realtime/pnpm-lock.yaml b/server/auth-realtime/pnpm-lock.yaml index 82c7e60d..0aefe237 100644 --- a/server/auth-realtime/pnpm-lock.yaml +++ b/server/auth-realtime/pnpm-lock.yaml @@ -10,10 +10,10 @@ importers: dependencies: '@better-auth/electron': specifier: ^1.7.1 - version: 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3))(better-call@1.4.0(zod@4.4.3)) + version: 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3)(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3)) '@better-auth/mcp': specifier: ^1.7.1 - version: 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3))(better-call@1.4.0(zod@4.4.3)) + version: 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3)(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3)) '@hocuspocus/extension-logger': specifier: ^3.2.3 version: 3.2.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) @@ -85,7 +85,7 @@ importers: version: 1.11.0 better-auth: specifier: ^1.7.1 - version: 1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3) + version: 1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3)(vitest@4.1.11) hono: specifier: ^4.9.2 version: 4.9.2 @@ -110,6 +110,9 @@ importers: yjs: specifier: ^13.6.27 version: 13.6.27 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@eslint/js': specifier: ^9.33.0 @@ -120,12 +123,21 @@ importers: '@types/pg': specifier: ^8.15.5 version: 8.15.6 + '@vitest/coverage-v8': + specifier: ^4.1.11 + version: 4.1.11(vitest@4.1.11) + '@vitest/eslint-plugin': + specifier: ^1.6.27 + version: 1.6.27(@typescript-eslint/eslint-plugin@8.40.0(@typescript-eslint/parser@8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2))(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2))(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2)(vitest@4.1.11) eslint: specifier: ^9.33.0 - version: 9.33.0 + version: 9.33.0(jiti@2.7.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.33.0) + version: 10.1.8(eslint@9.33.0(jiti@2.7.0)) + knip: + specifier: ^6.32.2 + version: 6.32.2 prettier: specifier: 3.6.2 version: 3.6.2 @@ -137,7 +149,10 @@ importers: version: 5.9.2 typescript-eslint: specifier: ^8.40.0 - version: 8.40.0(eslint@9.33.0)(typescript@5.9.2) + version: 8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2) + vitest: + specifier: ^4.1.11 + version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@20.19.11)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@20.19.11)(jiti@2.7.0)(tsx@4.20.4)(yaml@2.9.0)) packages: @@ -147,6 +162,27 @@ packages: '@apm-js-collab/tracing-hooks@0.3.1': resolution: {integrity: sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@better-auth/core@1.7.1': resolution: {integrity: sha512-eZ9lqcnVLMZ3QtUByRo4VZqkB1ESyRddd9NfWjBdDPgh+jcwLScoIUAqhtHLR8zaSUJZah8OLGlkzObyPdUH7A==} peerDependencies: @@ -262,6 +298,15 @@ packages: '@better-fetch/fetch@1.3.1': resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.25.9': resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} engines: {node: '>=18'} @@ -418,6 +463,12 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.7.0': resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -510,6 +561,23 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@noble/ciphers@2.2.0': resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} engines: {node: '>= 20.19.0'} @@ -730,6 +798,237 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 + '@oxc-parser/binding-android-arm-eabi@0.143.0': + resolution: {integrity: sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.143.0': + resolution: {integrity: sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.143.0': + resolution: {integrity: sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.143.0': + resolution: {integrity: sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.143.0': + resolution: {integrity: sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': + resolution: {integrity: sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': + resolution: {integrity: sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.143.0': + resolution: {integrity: sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.143.0': + resolution: {integrity: sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': + resolution: {integrity: sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': + resolution: {integrity: sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.143.0': + resolution: {integrity: sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.143.0': + resolution: {integrity: sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.143.0': + resolution: {integrity: sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.143.0': + resolution: {integrity: sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.143.0': + resolution: {integrity: sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.143.0': + resolution: {integrity: sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.143.0': + resolution: {integrity: sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.143.0': + resolution: {integrity: sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.143.0': + resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + + '@oxc-project/types@0.146.0': + resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + '@prisma/instrumentation@7.2.0': resolution: {integrity: sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g==} peerDependencies: @@ -766,6 +1065,105 @@ packages: '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} + '@rolldown/binding-android-arm-eabi@1.2.5': + resolution: {integrity: sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.5': + resolution: {integrity: sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.5': + resolution: {integrity: sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.5': + resolution: {integrity: sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.5': + resolution: {integrity: sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + resolution: {integrity: sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + resolution: {integrity: sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.5': + resolution: {integrity: sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + resolution: {integrity: sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + resolution: {integrity: sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.5': + resolution: {integrity: sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.5': + resolution: {integrity: sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.5': + resolution: {integrity: sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + resolution: {integrity: sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.5': + resolution: {integrity: sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sentry/core@10.39.0': resolution: {integrity: sha512-xCLip2mBwCdRrvXHtVEULX0NffUTYZZBhEUGht0WFL+GNdNQ7gmBOGOczhZlrf2hgFFtDO0fs1xiP9bqq5orEQ==} engines: {node: '>=18'} @@ -955,9 +1353,18 @@ packages: '@tiptap/starter-kit@3.2.0': resolution: {integrity: sha512-hcA12kqxVz1ypNqXhTUStQHWmDFmd2nm8NPmQmepK5T/T2FVXhVhhGSN8KsCR8sYFr3TwOk9utszxQLkT4AwOA==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1015,16 +1422,32 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.40.0': resolution: {integrity: sha512-y9ObStCcdCiZKzwqsE8CcpyuVMwRouJbbSrNuThDpv16dFAj429IkM6LNb1dZ2m7hK5fHyzNcErZf7CEeKXR4w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.40.0': resolution: {integrity: sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.40.0': resolution: {integrity: sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1036,12 +1459,22 @@ packages: resolution: {integrity: sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.40.0': resolution: {integrity: sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.40.0': resolution: {integrity: sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1049,10 +1482,75 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.40.0': resolution: {integrity: sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + peerDependencies: + '@vitest/browser': 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/eslint-plugin@1.6.27': + resolution: {integrity: sha512-X1RCAfwbatG4GFbJ/1PIHP9MSZMt0JJThqbYID+vS4L783k6QGlLPe421wQE8dHXuGCcenVLsydiM4qzsYWxhg==} + engines: {node: '>=18'} + peerDependencies: + '@typescript-eslint/eslint-plugin': '*' + eslint: '>=8.57.0' + typescript: '>=5.0.0' + vitest: '*' + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + typescript: + optional: true + vitest: + optional: true + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -1078,6 +1576,13 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + async-lock@1.4.1: resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} @@ -1090,6 +1595,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + better-auth@1.7.1: resolution: {integrity: sha512-g8WlTQijxXWJjPVZfFu1+EJg9cwwHrKDmIkcYMzx8CzYA+tDxl6NI7qQbKkbgw5UtHILsT5VH+RMzFzwnVJqAg==} peerDependencies: @@ -1166,6 +1675,10 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1178,6 +1691,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1203,6 +1720,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -1219,6 +1739,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1233,6 +1762,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -1252,6 +1785,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1287,6 +1823,10 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@9.33.0: resolution: {integrity: sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1314,10 +1854,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1334,6 +1881,18 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1366,6 +1925,11 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + engines: {node: '>=18.3.0'} + hasBin: true + forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} @@ -1388,6 +1952,9 @@ packages: get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + get-tsconfig@4.14.1: + resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1431,6 +1998,9 @@ packages: resolution: {integrity: sha512-UG2jXGS/gkLH42l/1uROnwXpkjvvxkl3kpopL3LBo27NuaDPI6xHNfuUSilIHcrBkPfl4y0z6y2ByI455TjNRw==} engines: {node: '>=16.9.0'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1468,9 +2038,28 @@ packages: isomorphic.js@0.2.5: resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jose@6.2.9: resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -1491,6 +2080,11 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + knip@6.32.2: + resolution: {integrity: sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + kysely@0.28.17: resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} engines: {node: '>=20.0.0'} @@ -1504,6 +2098,80 @@ packages: engines: {node: '>=16'} hasBin: true + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} @@ -1520,6 +2188,16 @@ packages: lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + markdown-it@14.1.0: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true @@ -1547,6 +2225,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1560,6 +2242,11 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanoid@5.1.6: resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} engines: {node: ^18 || >=20} @@ -1572,6 +2259,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1579,6 +2270,13 @@ packages: orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + oxc-parser@0.143.0: + resolution: {integrity: sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -1599,6 +2297,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pg-cloudflare@1.2.7: resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} @@ -1633,10 +2334,21 @@ packages: pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -1753,6 +2465,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.2.5: + resolution: {integrity: sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} @@ -1767,6 +2484,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + set-cookie-parser@3.1.2: resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} @@ -1778,18 +2500,54 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1800,6 +2558,15 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.20.4: resolution: {integrity: sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==} engines: {node: '>=18.0.0'} @@ -1824,6 +2591,10 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + unbash@4.0.10: + resolution: {integrity: sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg==} + engines: {node: '>=14'} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -1839,14 +2610,107 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -1883,6 +2747,11 @@ packages: peerDependencies: yjs: ^13.0.0 + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yjs@13.6.27: resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -1906,6 +2775,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + '@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.2 @@ -1925,12 +2809,12 @@ snapshots: '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 - '@better-auth/electron@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3))(better-call@1.4.0(zod@4.4.3))': + '@better-auth/electron@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3)(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3))': dependencies: '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3) + better-auth: 1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3)(vitest@4.1.11) better-call: 1.4.0(zod@4.4.3) zod: 4.4.3 @@ -1941,11 +2825,11 @@ snapshots: optionalDependencies: kysely: 0.28.17 - '@better-auth/mcp@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3))(better-call@1.4.0(zod@4.4.3))': + '@better-auth/mcp@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3)(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3))': dependencies: '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/oauth-provider': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3))(better-call@1.4.0(zod@4.4.3)) - better-auth: 1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3) + '@better-auth/oauth-provider': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3)(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3)) + better-auth: 1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3)(vitest@4.1.11) better-call: 1.4.0(zod@4.4.3) jose: 6.2.9 transitivePeerDependencies: @@ -1962,12 +2846,12 @@ snapshots: '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 - '@better-auth/oauth-provider@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3))(better-call@1.4.0(zod@4.4.3))': + '@better-auth/oauth-provider@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3)(vitest@4.1.11))(better-call@1.4.0(zod@4.4.3))': dependencies: '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3) + better-auth: 1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3)(vitest@4.1.11) better-call: 1.4.0(zod@4.4.3) jose: 6.2.9 zod: 4.4.3 @@ -1993,6 +2877,22 @@ snapshots: '@better-fetch/fetch@1.3.1': {} + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.25.9': optional: true @@ -2071,9 +2971,14 @@ snapshots: '@esbuild/win32-x64@0.25.9': optional: true - '@eslint-community/eslint-utils@4.7.0(eslint@9.33.0)': + '@eslint-community/eslint-utils@4.10.1(eslint@9.33.0(jiti@2.7.0))': dependencies: - eslint: 9.33.0 + eslint: 9.33.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.7.0(eslint@9.33.0(jiti@2.7.0))': + dependencies: + eslint: 9.33.0(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -2176,6 +3081,22 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@noble/ciphers@2.2.0': {} '@noble/hashes@2.3.0': {} @@ -2447,6 +3368,128 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@oxc-parser/binding-android-arm-eabi@0.143.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.143.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.143.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.143.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.143.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.143.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.143.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.143.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.143.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.143.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.143.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.143.0': + optional: true + + '@oxc-project/types@0.143.0': {} + + '@oxc-project/types@0.146.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + '@prisma/instrumentation@7.2.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -2476,6 +3519,53 @@ snapshots: '@remirror/core-constants@3.0.0': {} + '@rolldown/binding-android-arm-eabi@1.2.5': + optional: true + + '@rolldown/binding-android-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-x64@1.2.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.5': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@sentry/core@10.39.0': {} '@sentry/node-core@10.39.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.211.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.43.0)': @@ -2706,10 +3796,22 @@ snapshots: '@tiptap/extensions': 3.2.0(@tiptap/core@3.14.0(@tiptap/pm@3.14.0))(@tiptap/pm@3.14.0) '@tiptap/pm': 3.14.0 + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 20.19.11 + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/hast@3.0.4': @@ -2751,15 +3853,15 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript-eslint/eslint-plugin@8.40.0(@typescript-eslint/parser@8.40.0(eslint@9.33.0)(typescript@5.9.2))(eslint@9.33.0)(typescript@5.9.2)': + '@typescript-eslint/eslint-plugin@8.40.0(@typescript-eslint/parser@8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2))(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.40.0(eslint@9.33.0)(typescript@5.9.2) + '@typescript-eslint/parser': 8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2) '@typescript-eslint/scope-manager': 8.40.0 - '@typescript-eslint/type-utils': 8.40.0(eslint@9.33.0)(typescript@5.9.2) - '@typescript-eslint/utils': 8.40.0(eslint@9.33.0)(typescript@5.9.2) + '@typescript-eslint/type-utils': 8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2) + '@typescript-eslint/utils': 8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2) '@typescript-eslint/visitor-keys': 8.40.0 - eslint: 9.33.0 + eslint: 9.33.0(jiti@2.7.0) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -2768,14 +3870,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.40.0(eslint@9.33.0)(typescript@5.9.2)': + '@typescript-eslint/parser@8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2)': dependencies: '@typescript-eslint/scope-manager': 8.40.0 '@typescript-eslint/types': 8.40.0 '@typescript-eslint/typescript-estree': 8.40.0(typescript@5.9.2) '@typescript-eslint/visitor-keys': 8.40.0 debug: 4.4.1 - eslint: 9.33.0 + eslint: 9.33.0(jiti@2.7.0) typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -2789,22 +3891,40 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.67.0(typescript@5.9.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.2) + '@typescript-eslint/types': 8.67.0 + debug: 4.4.3 + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.40.0': dependencies: '@typescript-eslint/types': 8.40.0 '@typescript-eslint/visitor-keys': 8.40.0 + '@typescript-eslint/scope-manager@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + '@typescript-eslint/tsconfig-utils@8.40.0(typescript@5.9.2)': dependencies: typescript: 5.9.2 - '@typescript-eslint/type-utils@8.40.0(eslint@9.33.0)(typescript@5.9.2)': + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.2)': + dependencies: + typescript: 5.9.2 + + '@typescript-eslint/type-utils@8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2)': dependencies: '@typescript-eslint/types': 8.40.0 '@typescript-eslint/typescript-estree': 8.40.0(typescript@5.9.2) - '@typescript-eslint/utils': 8.40.0(eslint@9.33.0)(typescript@5.9.2) + '@typescript-eslint/utils': 8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2) debug: 4.4.1 - eslint: 9.33.0 + eslint: 9.33.0(jiti@2.7.0) ts-api-utils: 2.1.0(typescript@5.9.2) typescript: 5.9.2 transitivePeerDependencies: @@ -2812,6 +3932,8 @@ snapshots: '@typescript-eslint/types@8.40.0': {} + '@typescript-eslint/types@8.67.0': {} + '@typescript-eslint/typescript-estree@8.40.0(typescript@5.9.2)': dependencies: '@typescript-eslint/project-service': 8.40.0(typescript@5.9.2) @@ -2828,13 +3950,39 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.40.0(eslint@9.33.0)(typescript@5.9.2)': + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.2)': dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.33.0) + '@typescript-eslint/project-service': 8.67.0(typescript@5.9.2) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.2) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2)': + dependencies: + '@eslint-community/eslint-utils': 4.7.0(eslint@9.33.0(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.40.0 '@typescript-eslint/types': 8.40.0 '@typescript-eslint/typescript-estree': 8.40.0(typescript@5.9.2) - eslint: 9.33.0 + eslint: 9.33.0(jiti@2.7.0) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.67.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.33.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.2) + eslint: 9.33.0(jiti@2.7.0) typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -2844,6 +3992,78 @@ snapshots: '@typescript-eslint/types': 8.40.0 eslint-visitor-keys: 4.2.1 + '@typescript-eslint/visitor-keys@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + eslint-visitor-keys: 5.0.1 + + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@20.19.11)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@20.19.11)(jiti@2.7.0)(tsx@4.20.4)(yaml@2.9.0)) + + '@vitest/eslint-plugin@1.6.27(@typescript-eslint/eslint-plugin@8.40.0(@typescript-eslint/parser@8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2))(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2))(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2)(vitest@4.1.11)': + dependencies: + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/utils': 8.67.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2) + eslint: 9.33.0(jiti@2.7.0) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.40.0(@typescript-eslint/parser@8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2))(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2) + typescript: 5.9.2 + vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@20.19.11)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@20.19.11)(jiti@2.7.0)(tsx@4.20.4)(yaml@2.9.0)) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@20.19.11)(jiti@2.7.0)(tsx@4.20.4)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.2(@types/node@20.19.11)(jiti@2.7.0)(tsx@4.20.4)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -2867,6 +4087,14 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-lock@1.4.1: {} asynckit@0.4.0: {} @@ -2881,7 +4109,9 @@ snapshots: balanced-match@1.0.2: {} - better-auth@1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3): + balanced-match@4.0.4: {} + + better-auth@1.7.1(@opentelemetry/api@1.9.0)(pg@8.16.3)(vitest@4.1.11): dependencies: '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2) @@ -2902,6 +4132,7 @@ snapshots: zod: 4.4.3 optionalDependencies: pg: 8.16.3 + vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@20.19.11)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@20.19.11)(jiti@2.7.0)(tsx@4.20.4)(yaml@2.9.0)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -2924,6 +4155,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -2935,6 +4170,8 @@ snapshots: callsites@3.1.0: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2956,6 +4193,8 @@ snapshots: concat-map@0.0.1: {} + convert-source-map@2.0.0: {} + crelt@1.0.6: {} cross-spawn@7.0.6: @@ -2968,6 +4207,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + deep-is@0.1.4: {} defu@6.1.4: {} @@ -2976,6 +4219,8 @@ snapshots: dequal@2.0.3: {} + detect-libc@2.1.2: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -2992,6 +4237,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -3034,9 +4281,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.33.0): + eslint-config-prettier@10.1.8(eslint@9.33.0(jiti@2.7.0)): dependencies: - eslint: 9.33.0 + eslint: 9.33.0(jiti@2.7.0) eslint-scope@8.4.0: dependencies: @@ -3047,9 +4294,11 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.33.0: + eslint-visitor-keys@5.0.1: {} + + eslint@9.33.0(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.33.0) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.33.0(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.21.0 '@eslint/config-helpers': 0.3.1 @@ -3084,6 +4333,8 @@ snapshots: minimatch: 3.1.2 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -3103,8 +4354,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -3123,6 +4380,14 @@ snapshots: dependencies: reusify: 1.1.0 + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -3153,6 +4418,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formatly@0.3.0: + dependencies: + fd-package-json: 2.0.0 + forwarded-parse@2.1.2: {} fsevents@2.3.3: @@ -3182,6 +4451,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.14.1: + dependencies: + resolve-pkg-maps: 1.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -3212,6 +4485,8 @@ snapshots: hono@4.9.2: {} + html-escaper@2.0.2: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3242,8 +4517,25 @@ snapshots: isomorphic.js@0.2.5: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jiti@2.7.0: {} + jose@6.2.9: {} + js-tokens@10.0.0: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -3260,6 +4552,22 @@ snapshots: kleur@4.1.5: {} + knip@6.32.2: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + formatly: 0.3.0 + get-tsconfig: 4.14.1 + jiti: 2.7.0 + oxc-parser: 0.143.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.5 + smol-toml: 1.8.0 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 4.0.10 + yaml: 2.9.0 + zod: 4.4.3 + kysely@0.28.17: {} levn@0.4.1: @@ -3271,6 +4579,55 @@ snapshots: dependencies: isomorphic.js: 0.2.5 + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + linkify-it@5.0.0: dependencies: uc.micro: 2.1.0 @@ -3289,6 +4646,20 @@ snapshots: devlop: 1.1.0 highlight.js: 11.11.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + markdown-it@14.1.0: dependencies: argparse: 2.0.1 @@ -3315,6 +4686,10 @@ snapshots: dependencies: mime-db: 1.52.0 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -3327,12 +4702,16 @@ snapshots: ms@2.1.3: {} + nanoid@3.3.18: {} + nanoid@5.1.6: {} nanostores@1.3.0: {} natural-compare@1.4.0: {} + obug@2.1.4: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3344,6 +4723,52 @@ snapshots: orderedmap@2.1.1: {} + oxc-parser@0.143.0: + dependencies: + '@oxc-project/types': 0.143.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.143.0 + '@oxc-parser/binding-android-arm64': 0.143.0 + '@oxc-parser/binding-darwin-arm64': 0.143.0 + '@oxc-parser/binding-darwin-x64': 0.143.0 + '@oxc-parser/binding-freebsd-x64': 0.143.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.143.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.143.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.143.0 + '@oxc-parser/binding-linux-arm64-musl': 0.143.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.143.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.143.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.143.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.143.0 + '@oxc-parser/binding-linux-x64-gnu': 0.143.0 + '@oxc-parser/binding-linux-x64-musl': 0.143.0 + '@oxc-parser/binding-openharmony-arm64': 0.143.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.143.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.143.0 + '@oxc-parser/binding-win32-x64-msvc': 0.143.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -3360,6 +4785,8 @@ snapshots: path-key@3.1.1: {} + pathe@2.0.3: {} + pg-cloudflare@1.2.7: optional: true @@ -3395,8 +4822,18 @@ snapshots: dependencies: split2: 4.2.0 + picocolors@1.1.1: {} + picomatch@2.3.1: {} + picomatch@4.0.5: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postgres-array@2.0.0: {} postgres-bytea@1.0.0: {} @@ -3543,6 +4980,27 @@ snapshots: reusify@1.1.0: {} + rolldown@1.2.5: + dependencies: + '@oxc-project/types': 0.146.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.5 + '@rolldown/binding-android-arm64': 1.2.5 + '@rolldown/binding-darwin-arm64': 1.2.5 + '@rolldown/binding-darwin-x64': 1.2.5 + '@rolldown/binding-freebsd-x64': 1.2.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.5 + '@rolldown/binding-linux-arm64-gnu': 1.2.5 + '@rolldown/binding-linux-arm64-musl': 1.2.5 + '@rolldown/binding-linux-ppc64-gnu': 1.2.5 + '@rolldown/binding-linux-s390x-gnu': 1.2.5 + '@rolldown/binding-linux-x64-gnu': 1.2.5 + '@rolldown/binding-linux-x64-musl': 1.2.5 + '@rolldown/binding-openharmony-arm64': 1.2.5 + '@rolldown/binding-win32-arm64-msvc': 1.2.5 + '@rolldown/binding-win32-x64-msvc': 1.2.5 + rope-sequence@1.3.4: {} rou3@0.9.2: {} @@ -3553,6 +5011,8 @@ snapshots: semver@7.7.2: {} + semver@7.8.5: {} + set-cookie-parser@3.1.2: {} shebang-command@2.0.0: @@ -3561,14 +5021,37 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + + smol-toml@1.8.0: {} + + source-map-js@1.2.1: {} + split2@4.2.0: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -3577,6 +5060,13 @@ snapshots: dependencies: typescript: 5.9.2 + ts-api-utils@2.5.0(typescript@5.9.2): + dependencies: + typescript: 5.9.2 + + tslib@2.8.1: + optional: true + tsx@4.20.4: dependencies: esbuild: 0.25.9 @@ -3588,13 +5078,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.40.0(eslint@9.33.0)(typescript@5.9.2): + typescript-eslint@8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2): dependencies: - '@typescript-eslint/eslint-plugin': 8.40.0(@typescript-eslint/parser@8.40.0(eslint@9.33.0)(typescript@5.9.2))(eslint@9.33.0)(typescript@5.9.2) - '@typescript-eslint/parser': 8.40.0(eslint@9.33.0)(typescript@5.9.2) + '@typescript-eslint/eslint-plugin': 8.40.0(@typescript-eslint/parser@8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2))(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2) + '@typescript-eslint/parser': 8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2) '@typescript-eslint/typescript-estree': 8.40.0(typescript@5.9.2) - '@typescript-eslint/utils': 8.40.0(eslint@9.33.0)(typescript@5.9.2) - eslint: 9.33.0 + '@typescript-eslint/utils': 8.40.0(eslint@9.33.0(jiti@2.7.0))(typescript@5.9.2) + eslint: 9.33.0(jiti@2.7.0) typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -3603,6 +5093,8 @@ snapshots: uc.micro@2.1.0: {} + unbash@4.0.10: {} + undici-types@6.21.0: {} uri-js@4.4.1: @@ -3613,12 +5105,62 @@ snapshots: uuid@11.1.0: {} + vite@8.2.2(@types/node@20.19.11)(jiti@2.7.0)(tsx@4.20.4)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.11 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.20.4 + yaml: 2.9.0 + + vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@20.19.11)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@20.19.11)(jiti@2.7.0)(tsx@4.20.4)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@20.19.11)(jiti@2.7.0)(tsx@4.20.4)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.2(@types/node@20.19.11)(jiti@2.7.0)(tsx@4.20.4)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 20.19.11 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + transitivePeerDependencies: + - msw + w3c-keyname@2.2.8: {} + walk-up-path@4.0.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} ws@8.18.3: {} @@ -3639,6 +5181,8 @@ snapshots: lib0: 0.2.117 yjs: 13.6.27 + yaml@2.9.0: {} + yjs@13.6.27: dependencies: lib0: 0.2.117 diff --git a/server/auth-realtime/src/auth.test.ts b/server/auth-realtime/src/auth.test.ts new file mode 100644 index 00000000..413e8592 --- /dev/null +++ b/server/auth-realtime/src/auth.test.ts @@ -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[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) + }) + }) +}) diff --git a/server/auth-realtime/src/auth.ts b/server/auth-realtime/src/auth.ts index 8c72c13b..ecc7a6e1 100644 --- a/server/auth-realtime/src/auth.ts +++ b/server/auth-realtime/src/auth.ts @@ -1,654 +1,714 @@ -import * as Sentry from "@sentry/node" import { betterAuth } from "better-auth" import { createAuthMiddleware } from "better-auth/api" import { jwt, organization } from "better-auth/plugins" import { electron } from "@better-auth/electron" import { mcp } from "@better-auth/mcp" -import { createClient } from "redis" -import { db, dialect } from "./database.js" -import axios from "axios" +import type { PostgresDialect } from "kysely" +import type { Store } from "./db.js" +import type { CoreClient } from "./core.js" +import type { Env, SocialProviderName } from "./env.js" +import { reported } from "./reporting.js" -const MAX_ORGANIZATION_MEMBERS = parseInt( - process.env.OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS || "5", -) -export const MAX_ORGANIZATIONS = parseInt( - process.env.OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS || "100", -) -const backendUrl = process.env.OXYNOTE_AUTH_REALTIME_BACKEND_URL +export type AuthMethod = "email-password" | "google" | "github" | "slack" -const FRONTEND_URL = process.env.OXYNOTE_AUTH_REALTIME_FRONTEND_URL as string - -const PUBLIC_AUTH_BASE_URL = process.env - .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_BASE_URL as string - -const AUTH_ORIGIN = new URL(PUBLIC_AUTH_BASE_URL).origin - -// the canonical MCP protected-resource URL (RFC 8707/9728) — core's MCP -// endpoint as clients reach it through the front door. Issued access tokens -// are audience-bound to it. -export const MCP_RESOURCE = process.env - .OXYNOTE_AUTH_REALTIME_MCP_RESOURCE as string - -// MCP access tokens carry better-auth's resolved base URL (origin + basePath) -// as their issuer. The internal MCP session endpoint verifies against it. -export const MCP_TOKEN_ISSUER = `${AUTH_ORIGIN}/api/auth` - -// 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. -const RATE_LIMIT_DISABLED = - process.env.OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED === "true" - -const GOOGLE_CONFIGURED = Boolean( - process.env.OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_ID && - process.env - .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_SECRET, -) -const GITHUB_CONFIGURED = Boolean( - process.env.OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_ID && - process.env - .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_SECRET, -) -const SLACK_CONFIGURED = Boolean( - process.env.OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_ID && - process.env - .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_SECRET, -) - -// the capability list served by /api/auth-config. Derived from the same -// flags that register the social providers below, so the endpoint can never -// disagree with what better-auth actually accepts. Email-password needs no -// external credentials, so it is always available. -export const AUTH_METHODS = [ - "email-password" as const, - ...(GOOGLE_CONFIGURED ? ["google" as const] : []), - ...(GITHUB_CONFIGURED ? ["github" as const] : []), - ...(SLACK_CONFIGURED ? ["slack" as const] : []), -] - -// better-auth builds absolute links (email confirmation/deletion URLs) from -// its baseURL, which is the bare origin — see the baseURL comment below. -// Rewrite them onto the public prefix so emailed links survive the reverse -// proxy. -function toPublicAuthURL(url: string): string { - return url.replace(AUTH_ORIGIN, PUBLIC_AUTH_BASE_URL) +// the subset of the redis client better-auth's secondary storage uses. +// Declared structurally so a test can drive the adapter with a plain +// object. +export interface SecondaryStorageClient { + get(key: string): Promise + getDel(key: string): Promise + incr(key: string): Promise + expire(key: string, seconds: number, mode: "NX"): Promise + set( + key: string, + value: string, + options?: { EX: number }, + ): Promise + del(key: string): Promise } -const redisClient = createClient({ - url: process.env.OXYNOTE_AUTH_REALTIME_VALKEY_URL, -}) +export interface AuthDeps { + env: Env + store: Store + dialect: PostgresDialect + redis: SecondaryStorageClient + core: CoreClient +} -await redisClient.connect() +// the capability list served by /api/auth-config. Derived from the same +// credentials that register the social providers, so the endpoint can +// never disagree with what better-auth actually accepts. Email-password +// needs no external credentials, so it is always available. +export function authMethods(env: Env): AuthMethod[] { + return [ + "email-password", + ...(env.socialProviders.google ? (["google"] as const) : []), + ...(env.socialProviders.github ? (["github"] as const) : []), + ...(env.socialProviders.slack ? (["slack"] as const) : []), + ] +} -export const auth = betterAuth({ - // the public base URL carries the reverse proxy's /auth-realtime prefix, - // but a path inside baseURL becomes better-auth's basePath — and the - // proxy strips that prefix before requests reach this service. Match on - // the bare origin (basePath stays /api/auth) and use the full public URL - // only where absolute, browser-reachable URLs are built (the OAuth - // redirect URIs below). - baseURL: AUTH_ORIGIN, - basePath: "/api/auth", - secret: process.env.OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SECRET, - // the dialect instance alone leaves the CLI's schema generator unable - // to tell which database it is talking to (instanceof fails across - // the CLI's own kysely copy), and it silently falls back to sqlite - // typing; naming the type keeps runtime behaviour identical and the - // generated reference schema postgres-shaped. - database: { - dialect, - type: "postgres", - }, - // providers with missing credentials are left out entirely so sign-in - // attempts fail with PROVIDER_NOT_FOUND instead of a broken OAuth - // redirect. - socialProviders: { - ...(SLACK_CONFIGURED && { - slack: { - clientId: process.env - .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_ID as string, - clientSecret: process.env - .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_SECRET as string, - redirectURI: - PUBLIC_AUTH_BASE_URL + - "/api/auth/callback/slack", - }, - }), - ...(GOOGLE_CONFIGURED && { - google: { - clientId: process.env - .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_ID as string, - clientSecret: process.env - .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_SECRET as string, - redirectURI: - PUBLIC_AUTH_BASE_URL + - "/api/auth/callback/google", - }, - }), - ...(GITHUB_CONFIGURED && { - github: { - clientId: process.env - .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_ID as string, - clientSecret: process.env - .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_SECRET as string, - redirectURI: - PUBLIC_AUTH_BASE_URL + - "/api/auth/callback/github", - }, - }), - }, - emailAndPassword: { - enabled: true, - requireEmailVerification: true, - minPasswordLength: 16, - maxPasswordLength: 128, - // a reset always happens outside an authenticated session (the - // emailed link opens in a plain browser), so this drops every - // session of the account — nothing survives a password reset. - // The logged-in change-password flow revokes its other sessions - // via revokeOtherSessions on the client call instead. - revokeSessionsOnPasswordReset: true, - sendResetPassword: async ({ user, url }) => { - await sendEmail("password_reset", { - email: user.email, - link: toPublicAuthURL(url), - }) - }, - // duplicate signups get better-auth's synthetic success so the - // browser can't probe which emails have accounts. The real - // owner is told through their inbox instead. - onExistingUserSignUp: async ({ user }) => { - await sendEmail("account_exists", { - email: user.email, - link: `${FRONTEND_URL}/login`, - }) - }, - }, - emailVerification: { - sendOnSignUp: true, - // re-send the verification link when an unverified user tries to - // log in — the login page forwards them to the check-your-inbox - // page, which would otherwise lie about an email being sent. - sendOnSignIn: true, - // signup/sign-in verification gets its own account-activation - // template; the change-email flow below keeps the "new email - // address" one. - sendVerificationEmail: async ({ user, url }) => { - await sendEmail("signup_verification", { - email: user.email, - link: toPublicAuthURL(url), - }) - }, - }, - rateLimit: { - enabled: !RATE_LIMIT_DISABLED, - }, - advanced: { - cookiePrefix: "auth", - crossSubDomainCookies: { - enabled: true, - domain: process.env - .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_COOKIE_DOMAIN as string, - }, - }, - secondaryStorage: { - get: async (key) => { - try { - return await redisClient.get(key) - } catch (err) { - Sentry.captureException(err) - throw err - } - }, - getAndDelete: async (key) => { - try { - return await redisClient.getDel(key) - } catch (err) { - Sentry.captureException(err) - throw err - } - }, - increment: async (key, ttl) => { - try { - const count = await redisClient.incr(key) +// better-auth builds absolute links (email confirmation/deletion URLs) +// from its baseURL, which is the bare origin — see the baseURL comment in +// createAuth. Rewrite them onto the public prefix so emailed links survive +// the reverse proxy. +export function toPublicAuthUrl(env: Env, url: string): string { + return url.replace(env.authOrigin, env.publicAuthBaseUrl) +} - // NX applies the TTL only when the key has none yet, so - // the counter expires a fixed window after creation and - // later increments never extend it. - await redisClient.expire(key, ttl, "NX") +export interface SocialSignInContext { + path: string + query?: Record | null + body?: Record | null +} + +// the @better-auth/electron plugin proxies /electron/init-oauth-proxy to +// /sign-in/social with only `{ provider }` in the body, so generateState() +// falls back to `options.baseURL` for callbackURL and the post-callback 302 +// lands on the server root (404). Send it to the desktop-auth handoff page +// on the frontend instead — that page reads the auth-code cookie set on +// /callback/* and triggers the deep link back into the desktop app. +// Returns undefined for every other request, which leaves the context +// untouched. +export function electronCallbackOverride( + ctx: SocialSignInContext, + frontendUrl: string, +): { context: { body: Record } } | undefined { + if ( + ctx.path !== "/sign-in/social" || + ctx.query?.client_id !== "electron" || + ctx.body?.callbackURL + ) { + return undefined + } + + return { + context: { + body: { + ...ctx.body, + callbackURL: `${frontendUrl}/desktop-auth`, + }, + }, + } +} + +export interface InvitationData { + id: string + email: string + inviter: { user: { name: string } } + organization: { id: string; name: string } +} + +// the invite link the emailed button points at. Everything the +// accept-invite page needs to render before the user has a session travels +// in the query string. +export function invitationLink(env: Env, data: InvitationData): string { + return ( + env.organizationInvitationUrl + + `?id=${data.id}` + + `&email=${data.email}` + + `&inviter=${data.inviter.user.name}` + + `&orgName=${data.organization.name}` + + `&orgId=${data.organization.id}` + ) +} + +// bind an MCP token to the user's organization at issuance, the same way +// session creation resolves activeOrganizationId, so core can scope every +// call without a per-request lookup. +export async function organizationClaims( + store: Store, + user: { id: string } | undefined | null, +): Promise> { + if (!user) { + return {} + } + + return reported(async () => { + const orgId = await store.userOrganizationId(user.id) + + return orgId + ? { org_id: orgId } + : ({} as Record) + }) +} + +// the organization plugin's callbacks. Named here rather than inlined in +// the plugin's options because the plugin does not hand them back, and the +// teardown contract below is worth being able to test on its own. +export function createOrganizationHooks({ + env, + store, + core, +}: { + env: Env + store: Store + core: CoreClient +}) { + return { + canCreateOrganization: () => + reported(async () => { + const count = + await store.totalOrganizationCount() + + return count < env.maxOrganizations + }), + + afterCreateOrganization: async ({ + organization, + }: { + organization: { id: string } + }) => { + await reported(() => + core.initializeOrganization(organization.id), + ) + }, + + // core tears the organization's external resources down while + // every row still exists. The throw is load-bearing: + // better-auth aborts the deletion, so a failed teardown leaves + // the organization alive rather than orphaning what core owns. + beforeDeleteOrganization: async ({ + organization, + }: { + organization: { id: string } + }) => { + await reported(() => + core.teardownOrganization(organization.id), + ) + }, + + sendInvitationEmail: async (data: InvitationData) => { + await reported(() => + core.sendEmail("organization_invitation", { + email: data.email, + organization: data.organization.name, + link: invitationLink(env, data), + }), + ) + }, + } +} + +export function createSecondaryStorage(redis: SecondaryStorageClient) { + return { + get: (key: string) => reported(() => redis.get(key)), + getAndDelete: (key: string) => + reported(() => redis.getDel(key)), + increment: (key: string, ttl: number) => + reported(async () => { + const count = await redis.incr(key) + + // NX applies the TTL only when the key has none + // yet, so the counter expires a fixed window + // after creation and later increments never + // extend it. + await redis.expire(key, ttl, "NX") return count - } catch (err) { - Sentry.captureException(err) - throw err - } - }, - set: async (key, value, ttl) => { - try { + }), + set: (key: string, value: string, ttl?: number) => + reported(async () => { if (ttl) { - await redisClient.set(key, value, { - EX: ttl, - }) + await redis.set(key, value, { EX: ttl }) return } - await redisClient.set(key, value) - } catch (err) { - Sentry.captureException(err) - throw err - } + await redis.set(key, value) + }), + delete: (key: string) => + reported(async () => { + await redis.del(key) + }), + } +} + +export function createAuth({ env, store, dialect, redis, core }: AuthDeps) { + const organizationHooks = createOrganizationHooks({ env, store, core }) + + function socialProvider(name: SocialProviderName) { + const credentials = env.socialProviders[name] + if (!credentials) { + return undefined + } + + return { + ...credentials, + redirectURI: `${env.publicAuthBaseUrl}/api/auth/callback/${name}`, + } + } + + return betterAuth({ + // the public base URL carries the reverse proxy's + // /auth-realtime prefix, but a path inside baseURL becomes + // better-auth's basePath — and the proxy strips that prefix + // before requests reach this service. Match on the bare origin + // (basePath stays /api/auth) and use the full public URL only + // where absolute, browser-reachable URLs are built (the OAuth + // redirect URIs below). + baseURL: env.authOrigin, + basePath: "/api/auth", + secret: env.betterAuthSecret, + // the dialect instance alone leaves the CLI's schema generator + // unable to tell which database it is talking to (instanceof + // fails across the CLI's own kysely copy), and it silently + // falls back to sqlite typing; naming the type keeps runtime + // behaviour identical and the generated reference schema + // postgres-shaped. + database: { + dialect, + type: "postgres", }, - delete: async (key) => { - try { - await redisClient.del(key) - } catch (err) { - Sentry.captureException(err) - throw err - } + // providers with missing credentials are left out entirely so + // sign-in attempts fail with PROVIDER_NOT_FOUND instead of a + // broken OAuth redirect. + socialProviders: { + ...(env.socialProviders.slack && { + slack: socialProvider("slack"), + }), + ...(env.socialProviders.google && { + google: socialProvider("google"), + }), + ...(env.socialProviders.github && { + github: socialProvider("github"), + }), }, - }, - trustedOrigins: ( - (process.env.OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS || - "") as string - ).split(","), - user: { - modelName: "users", - fields: { - emailVerified: "email_verified", - createdAt: "created_at", - updatedAt: "updated_at", - }, - changeEmail: { + emailAndPassword: { enabled: true, - sendChangeEmailConfirmation: async ({ - newEmail, - url, - }) => { - await sendEmail("email_verification", { - email: newEmail, - link: toPublicAuthURL(url), - }) - }, - }, - deleteUser: { - enabled: true, - sendDeleteAccountVerification: async ({ - user, - url, - }) => { - await sendEmail("user_deletion", { - email: user.email, - link: toPublicAuthURL(url), - }) - }, - }, - }, - account: { - modelName: "user_accounts", - fields: { - accountId: "account_id", - providerId: "provider_id", - userId: "fk_user_id", - accessToken: "access_token", - refreshToken: "refresh_token", - idToken: "id_token", - accessTokenExpiresAt: "access_token_expires_at", - refreshTokenExpiresAt: "refresh_token_expires_at", - createdAt: "created_at", - updatedAt: "updated_at", - }, - }, - session: { - modelName: "user_sessions", - // the OAuth provider (mcp plugin) refuses to run on - // secondary-storage-only sessions, so sessions are persisted to - // the database as well; Valkey stays the hot path for lookups. - storeSessionInDatabase: true, - fields: { - expiresAt: "expires_at", - createdAt: "created_at", - updatedAt: "updated_at", - ipAddress: "ip_address", - userAgent: "user_agent", - userId: "fk_user_id", - }, - }, - verification: { - modelName: "user_verifications", - fields: { - expiresAt: "expires_at", - createdAt: "created_at", - updatedAt: "updated_at", - }, - }, - plugins: [ - electron(), - organization({ - allowUserToCreateOrganization: async () => { - try { - const count = - await totalOrganizationCount() - return count < MAX_ORGANIZATIONS - } catch (err) { - Sentry.captureException(err) - throw err - } - }, - disableOrganizationDeletion: true, // prevent clients from deleting the org - organizationLimit: 1, - membershipLimit: MAX_ORGANIZATION_MEMBERS, - schema: { - session: { - fields: { - activeOrganizationId: - "active_organization_id", - }, - }, - organization: { - modelName: "organizations", - fields: { - createdAt: "created_at", - }, - }, - member: { - modelName: "organization_members", - fields: { - organizationId: - "fk_organization_id", - userId: "fk_user_id", - createdAt: "created_at", - }, - }, - invitation: { - modelName: "organization_invitations", - fields: { - organizationId: - "fk_organization_id", - expiresAt: "expires_at", - createdAt: "created_at", - inviterId: "fk_inviter_id", - }, - }, - }, - organizationHooks: { - afterCreateOrganization: async ({ - organization, - }) => { - await initializeOrganization( - organization.id, - ) - }, - beforeDeleteOrganization: async ({ - organization, - }) => { - await teardownOrganization( - organization.id, - ) - }, - }, - sendInvitationEmail: async (data) => { - const inviteLink = - process.env - .OXYNOTE_AUTH_REALTIME_BETTER_AUTH_ORGANIZATION_INVITATION_URL + - `?id=${data.id}` + - `&email=${data.email}` + - `&inviter=${data.inviter.user.name}` + - `&orgName=${data.organization.name}` + - `&orgId=${data.organization.id}` - - await sendEmail("organization_invitation", { - email: data.email, - organization: data.organization.name, - link: inviteLink, - }) - }, - }), - // the mcp plugin requires jwt: MCP access tokens are JWTs signed - // with a key from the jwks table. - jwt({ - schema: { - jwks: { - fields: { - publicKey: "public_key", - privateKey: "private_key", - createdAt: "created_at", - expiresAt: "expires_at", - }, - }, - }, - }), - mcp({ - loginPage: `${FRONTEND_URL}/login`, - consentPage: `${FRONTEND_URL}/oauth-consent`, - resource: MCP_RESOURCE, - scopes: [ - "documents:read", - "documents:write", - "data-sources:read", - ], - // MCP clients self-register per RFC 7591 before the user has - // any session, so registration must be open. What a client - // can actually do is still gated by the user's consent and - // the token scopes. - allowDynamicClientRegistration: true, - allowUnauthenticatedClientRegistration: true, - // bind the token to the user's organization at issuance the - // same way session creation resolves activeOrganizationId, so - // core can scope every MCP call without a per-request lookup. - customAccessTokenClaims: async ({ user }) => { - if (!user) { - return {} - } - - try { - const orgId = await userOrganizationId( - user.id, - ) - - return orgId ? { org_id: orgId } : {} - } catch (err) { - Sentry.captureException(err) - throw err - } - }, - schema: { - oauthClient: { - modelName: "oauth_clients", - fields: { - clientId: "client_id", - clientSecret: "client_secret", - clientDiscoveryId: - "client_discovery_id", - skipConsent: "skip_consent", - enableEndSession: - "enable_end_session", - subjectType: "subject_type", - clientCredentialsScopes: - "client_credentials_scopes", - userId: "fk_user_id", - createdAt: "created_at", - updatedAt: "updated_at", - softwareId: "software_id", - softwareVersion: - "software_version", - softwareStatement: - "software_statement", - redirectUris: "redirect_uris", - postLogoutRedirectUris: - "post_logout_redirect_uris", - backchannelLogoutUri: - "backchannel_logout_uri", - backchannelLogoutSessionRequired: - "backchannel_logout_session_required", - tokenEndpointAuthMethod: - "token_endpoint_auth_method", - applicationType: - "application_type", - jwksUri: "jwks_uri", - grantTypes: "grant_types", - responseTypes: "response_types", - requirePKCE: "require_pkce", - dpopBoundAccessTokens: - "dpop_bound_access_tokens", - referenceId: "reference_id", - }, - }, - oauthResource: { - modelName: "oauth_resources", - fields: { - accessTokenTtl: - "access_token_ttl", - refreshTokenTtl: - "refresh_token_ttl", - signingAlgorithm: - "signing_algorithm", - signingKeyId: "signing_key_id", - allowedScopes: "allowed_scopes", - customClaims: "custom_claims", - dpopBoundAccessTokensRequired: - "dpop_bound_access_tokens_required", - createdAt: "created_at", - updatedAt: "updated_at", - policyVersion: "policy_version", - }, - }, - oauthClientResource: { - modelName: "oauth_client_resources", - fields: { - clientId: "client_id", - resourceId: "resource_id", - createdAt: "created_at", - }, - }, - oauthRefreshToken: { - modelName: "oauth_refresh_tokens", - fields: { - clientId: "client_id", - sessionId: "session_id", - userId: "fk_user_id", - referenceId: "reference_id", - authorizationCodeId: - "authorization_code_id", - requestedUserInfoClaims: - "requested_user_info_claims", - expiresAt: "expires_at", - createdAt: "created_at", - rotatedAt: "rotated_at", - rotationReplayResponse: - "rotation_replay_response", - rotationReplayExpiresAt: - "rotation_replay_expires_at", - authTime: "auth_time", - }, - }, - oauthAccessToken: { - modelName: "oauth_access_tokens", - fields: { - clientId: "client_id", - sessionId: "session_id", - userId: "fk_user_id", - referenceId: "reference_id", - authorizationCodeId: - "authorization_code_id", - requestedUserInfoClaims: - "requested_user_info_claims", - refreshId: "fk_refresh_token_id", - expiresAt: "expires_at", - createdAt: "created_at", - }, - }, - oauthConsent: { - modelName: "oauth_consents", - fields: { - clientId: "client_id", - userId: "fk_user_id", - referenceId: "reference_id", - requestedUserInfoClaims: - "requested_user_info_claims", - createdAt: "created_at", - updatedAt: "updated_at", - }, - }, - oauthClientAssertion: { - modelName: "oauth_client_assertions", - fields: { - expiresAt: "expires_at", - }, - }, - }, - }), - ], - hooks: { - before: createAuthMiddleware(async (ctx) => { - // The @better-auth/electron plugin proxies /electron/init-oauth-proxy - // to /sign-in/social with only `{ provider }` in the body, so - // generateState() falls back to `options.baseURL` for callbackURL and - // the post-callback 302 lands on the server root (404). Send it to - // the desktop-auth handoff page on the frontend instead — that page - // reads the auth-code cookie set on /callback/* and triggers the - // deep link back into the desktop app. - if ( - ctx.path === "/sign-in/social" && - ctx.query?.client_id === "electron" && - !ctx.body?.callbackURL - ) { - return { - context: { - body: { - ...ctx.body, - callbackURL: `${FRONTEND_URL}/desktop-auth`, - }, - }, - } - } - }), - }, - databaseHooks: { - user: { - create: { - after: async (user) => { - await sendEmail("user_creation", { + requireEmailVerification: true, + minPasswordLength: 16, + maxPasswordLength: 128, + // a reset always happens outside an authenticated session + // (the emailed link opens in a plain browser), so this + // drops every session of the account — nothing survives a + // password reset. The logged-in change-password flow + // revokes its other sessions via revokeOtherSessions on + // the client call instead. + revokeSessionsOnPasswordReset: true, + sendResetPassword: async ({ user, url }) => { + await reported(() => + core.sendEmail("password_reset", { email: user.email, - }) + link: toPublicAuthUrl(env, url), + }), + ) + }, + // duplicate signups get better-auth's synthetic success so + // the browser can't probe which emails have accounts. The + // real owner is told through their inbox instead. + onExistingUserSignUp: async ({ user }) => { + await reported(() => + core.sendEmail("account_exists", { + email: user.email, + link: `${env.frontendUrl}/login`, + }), + ) + }, + }, + emailVerification: { + sendOnSignUp: true, + // re-send the verification link when an unverified user + // tries to log in — the login page forwards them to the + // check-your-inbox page, which would otherwise lie about + // an email being sent. + sendOnSignIn: true, + // signup/sign-in verification gets its own + // account-activation template; the change-email flow + // below keeps the "new email address" one. + sendVerificationEmail: async ({ user, url }) => { + await reported(() => + core.sendEmail("signup_verification", { + email: user.email, + link: toPublicAuthUrl(env, url), + }), + ) + }, + }, + rateLimit: { + enabled: env.rateLimitEnabled, + }, + advanced: { + cookiePrefix: "auth", + crossSubDomainCookies: { + enabled: true, + domain: env.cookieDomain, + }, + }, + secondaryStorage: createSecondaryStorage(redis), + trustedOrigins: env.trustedOrigins, + user: { + modelName: "users", + fields: { + emailVerified: "email_verified", + createdAt: "created_at", + updatedAt: "updated_at", + }, + changeEmail: { + enabled: true, + sendChangeEmailConfirmation: async ({ + newEmail, + url, + }) => { + await reported(() => + core.sendEmail( + "email_verification", + { + email: newEmail, + link: toPublicAuthUrl( + env, + url, + ), + }, + ), + ) }, }, + deleteUser: { + enabled: true, + sendDeleteAccountVerification: async ({ + user, + url, + }) => { + await reported(() => + core.sendEmail( + "user_deletion", + { + email: user.email, + link: toPublicAuthUrl( + env, + url, + ), + }, + ), + ) + }, + }, + }, + account: { + modelName: "user_accounts", + fields: { + accountId: "account_id", + providerId: "provider_id", + userId: "fk_user_id", + accessToken: "access_token", + refreshToken: "refresh_token", + idToken: "id_token", + accessTokenExpiresAt: "access_token_expires_at", + refreshTokenExpiresAt: + "refresh_token_expires_at", + createdAt: "created_at", + updatedAt: "updated_at", + }, }, session: { - create: { - before: async (session) => { - try { - const orgId = - await userOrganizationId( - session.userId, - ) - return { - data: { - ...session, - activeOrganizationId: - orgId, - }, - } - } catch (err) { - Sentry.captureException(err) - throw err - } + modelName: "user_sessions", + // the OAuth provider (mcp plugin) refuses to run on + // secondary-storage-only sessions, so sessions are + // persisted to the database as well; Valkey stays the hot + // path for lookups. + storeSessionInDatabase: true, + fields: { + expiresAt: "expires_at", + createdAt: "created_at", + updatedAt: "updated_at", + ipAddress: "ip_address", + userAgent: "user_agent", + userId: "fk_user_id", + }, + }, + verification: { + modelName: "user_verifications", + fields: { + expiresAt: "expires_at", + createdAt: "created_at", + updatedAt: "updated_at", + }, + }, + plugins: [ + electron(), + organization({ + allowUserToCreateOrganization: + organizationHooks.canCreateOrganization, + disableOrganizationDeletion: true, // prevent clients from deleting the org + organizationLimit: 1, + membershipLimit: env.maxOrganizationMembers, + schema: { + session: { + fields: { + activeOrganizationId: + "active_organization_id", + }, + }, + organization: { + modelName: "organizations", + fields: { + createdAt: "created_at", + }, + }, + member: { + modelName: "organization_members", + fields: { + organizationId: + "fk_organization_id", + userId: "fk_user_id", + createdAt: "created_at", + }, + }, + invitation: { + modelName: "organization_invitations", + fields: { + organizationId: + "fk_organization_id", + expiresAt: "expires_at", + createdAt: "created_at", + inviterId: "fk_inviter_id", + }, + }, + }, + organizationHooks: { + afterCreateOrganization: + organizationHooks.afterCreateOrganization, + beforeDeleteOrganization: + organizationHooks.beforeDeleteOrganization, + }, + sendInvitationEmail: + organizationHooks.sendInvitationEmail, + }), + // the mcp plugin requires jwt: MCP access tokens are + // JWTs signed with a key from the jwks table. + jwt({ + schema: { + jwks: { + fields: { + publicKey: "public_key", + privateKey: "private_key", + createdAt: "created_at", + expiresAt: "expires_at", + }, + }, + }, + }), + mcp({ + loginPage: `${env.frontendUrl}/login`, + consentPage: `${env.frontendUrl}/oauth-consent`, + resource: env.mcpResource, + scopes: [ + "documents:read", + "documents:write", + "data-sources:read", + ], + // MCP clients self-register per RFC 7591 before + // the user has any session, so registration must + // be open. What a client can actually do is + // still gated by the user's consent and the + // token scopes. + allowDynamicClientRegistration: true, + allowUnauthenticatedClientRegistration: true, + customAccessTokenClaims: ({ user }) => + organizationClaims(store, user), + schema: { + oauthClient: { + modelName: "oauth_clients", + fields: { + clientId: "client_id", + clientSecret: + "client_secret", + clientDiscoveryId: + "client_discovery_id", + skipConsent: + "skip_consent", + enableEndSession: + "enable_end_session", + subjectType: + "subject_type", + clientCredentialsScopes: + "client_credentials_scopes", + userId: "fk_user_id", + createdAt: "created_at", + updatedAt: "updated_at", + softwareId: "software_id", + softwareVersion: + "software_version", + softwareStatement: + "software_statement", + redirectUris: + "redirect_uris", + postLogoutRedirectUris: + "post_logout_redirect_uris", + backchannelLogoutUri: + "backchannel_logout_uri", + backchannelLogoutSessionRequired: + "backchannel_logout_session_required", + tokenEndpointAuthMethod: + "token_endpoint_auth_method", + applicationType: + "application_type", + jwksUri: "jwks_uri", + grantTypes: "grant_types", + responseTypes: + "response_types", + requirePKCE: + "require_pkce", + dpopBoundAccessTokens: + "dpop_bound_access_tokens", + referenceId: + "reference_id", + }, + }, + oauthResource: { + modelName: "oauth_resources", + fields: { + accessTokenTtl: + "access_token_ttl", + refreshTokenTtl: + "refresh_token_ttl", + signingAlgorithm: + "signing_algorithm", + signingKeyId: + "signing_key_id", + allowedScopes: + "allowed_scopes", + customClaims: + "custom_claims", + dpopBoundAccessTokensRequired: + "dpop_bound_access_tokens_required", + createdAt: "created_at", + updatedAt: "updated_at", + policyVersion: + "policy_version", + }, + }, + oauthClientResource: { + modelName: "oauth_client_resources", + fields: { + clientId: "client_id", + resourceId: "resource_id", + createdAt: "created_at", + }, + }, + oauthRefreshToken: { + modelName: "oauth_refresh_tokens", + fields: { + clientId: "client_id", + sessionId: "session_id", + userId: "fk_user_id", + referenceId: + "reference_id", + authorizationCodeId: + "authorization_code_id", + requestedUserInfoClaims: + "requested_user_info_claims", + expiresAt: "expires_at", + createdAt: "created_at", + rotatedAt: "rotated_at", + rotationReplayResponse: + "rotation_replay_response", + rotationReplayExpiresAt: + "rotation_replay_expires_at", + authTime: "auth_time", + }, + }, + oauthAccessToken: { + modelName: "oauth_access_tokens", + fields: { + clientId: "client_id", + sessionId: "session_id", + userId: "fk_user_id", + referenceId: + "reference_id", + authorizationCodeId: + "authorization_code_id", + requestedUserInfoClaims: + "requested_user_info_claims", + refreshId: "fk_refresh_token_id", + expiresAt: "expires_at", + createdAt: "created_at", + }, + }, + oauthConsent: { + modelName: "oauth_consents", + fields: { + clientId: "client_id", + userId: "fk_user_id", + referenceId: + "reference_id", + requestedUserInfoClaims: + "requested_user_info_claims", + createdAt: "created_at", + updatedAt: "updated_at", + }, + }, + oauthClientAssertion: { + modelName: "oauth_client_assertions", + fields: { + expiresAt: "expires_at", + }, + }, + }, + }), + ], + hooks: { + before: createAuthMiddleware((ctx) => + Promise.resolve( + electronCallbackOverride( + ctx, + env.frontendUrl, + ), + ), + ), + }, + databaseHooks: { + user: { + create: { + after: async (user) => { + await reported(() => + core.sendEmail( + "user_creation", + { + email: user.email, + }, + ), + ) + }, }, }, - update: { - before: async (data, ctx) => { - try { - if (ctx?.context.session) { + session: { + create: { + before: async (session) => + reported(async () => { const orgId = - await userOrganizationId( + await store.userOrganizationId( + session.userId, + ) + + return { + data: { + ...session, + activeOrganizationId: + orgId, + }, + } + }), + }, + update: { + before: async (data, ctx) => + reported(async () => { + if ( + !ctx?.context + .session + ) { + return { data } + } + + const orgId = + await store.userOrganizationId( ctx .context .session .user .id, ) + return { data: { ...data, @@ -656,78 +716,9 @@ export const auth = betterAuth({ orgId, }, } - } - - return { data } - } catch (err) { - Sentry.captureException(err) - throw err - } + }), }, }, }, - }, -}) - -async function userOrganizationId(userId: string): Promise { - const res = await db - .selectFrom("organization_members") - .where("fk_user_id", "=", userId) - .select("fk_organization_id") - .executeTakeFirst() - - return res ? res.fk_organization_id : null -} - -export async function totalOrganizationCount(): Promise { - const res = await db - .selectFrom("organizations") - .select(db.fn.countAll().as("count")) - .executeTakeFirstOrThrow() - - return res.count -} - -async function initializeOrganization(organizationId: string): Promise { - try { - await axios.post( - `${backendUrl}/api/x/organizations/${organizationId}/initialize`, - ) - } catch (err) { - Sentry.captureException(err) - throw err - } -} - -async function teardownOrganization(organizationId: string): Promise { - try { - await axios.post( - `${backendUrl}/api/x/organizations/${organizationId}/teardown`, - ) - } catch (err) { - Sentry.captureException(err) - throw err - } -} - -async function sendEmail( - template: - | "email_verification" - | "password_reset" - | "account_exists" - | "signup_verification" - | "organization_invitation" - | "user_deletion" - | "user_creation", - data: Record, -): Promise { - try { - await axios.post(`${backendUrl}/api/x/email`, { - template, - data, - }) - } catch (err) { - Sentry.captureException(err) - throw err - } + }) } diff --git a/server/auth-realtime/src/core.test.ts b/server/auth-realtime/src/core.test.ts new file mode 100644 index 00000000..7a71bf21 --- /dev/null +++ b/server/auth-realtime/src/core.test.ts @@ -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) + }) + }) +}) diff --git a/server/auth-realtime/src/core.ts b/server/auth-realtime/src/core.ts new file mode 100644 index 00000000..b7a2051e --- /dev/null +++ b/server/auth-realtime/src/core.ts @@ -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 + post( + url: string, + data?: unknown, + config?: RequestOptions, + ): Promise + put( + url: string, + data?: unknown, + config?: RequestOptions, + ): Promise +} + +// 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, + ): Promise + initializeOrganization(organizationId: string): Promise + teardownOrganization(organizationId: string): Promise + fetchBranches( + documentId: string, + options?: RequestOptions, + ): Promise + fetchBranchContent( + documentId: string, + branchId: string, + ): Promise + storeBranchContent( + documentId: string, + branchId: string, + update: BranchUpdate, + ): Promise + 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, + } + }, + } +} diff --git a/server/auth-realtime/src/database.ts b/server/auth-realtime/src/database.ts deleted file mode 100644 index 8395f2a8..00000000 --- a/server/auth-realtime/src/database.ts +++ /dev/null @@ -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({ - 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 -} diff --git a/server/auth-realtime/src/db.test.ts b/server/auth-realtime/src/db.test.ts new file mode 100644 index 00000000..78b8d33a --- /dev/null +++ b/server/auth-realtime/src/db.test.ts @@ -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 + + 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) + }) + }) +}) diff --git a/server/auth-realtime/src/db.ts b/server/auth-realtime/src/db.ts new file mode 100644 index 00000000..120b7cc4 --- /dev/null +++ b/server/auth-realtime/src/db.ts @@ -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 + userOrganizationId(userId: string): Promise + hasOAuthConsent(clientId: string, userId: string): Promise + isOrganizationMember( + userId: string, + organizationId: string, + ): Promise +} + +// 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): Store { + return { + async totalOrganizationCount() { + const res = await db + .selectFrom("organizations") + .select(db.fn.countAll().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({ dialect })), + dialect, + } +} diff --git a/server/auth-realtime/src/env.test.ts b/server/auth-realtime/src/env.test.ts new file mode 100644 index 00000000..05954886 --- /dev/null +++ b/server/auth-realtime/src/env.test.ts @@ -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 = {}, +): Record { + 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) + }, + ) + }) +}) diff --git a/server/auth-realtime/src/env.ts b/server/auth-realtime/src/env.ts new file mode 100644 index 00000000..4fa731f4 --- /dev/null +++ b/server/auth-realtime/src/env.ts @@ -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 + > + 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 + +function socialProviders( + values: ParsedEnv, +): Partial> { + const providers: Partial< + Record + > = {} + + 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): 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, + } +} diff --git a/server/auth-realtime/src/headers.test.ts b/server/auth-realtime/src/headers.test.ts new file mode 100644 index 00000000..0cb9134b --- /dev/null +++ b/server/auth-realtime/src/headers.test.ts @@ -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([ + ["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([["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( + {}, + ) + }, + ) + }) +}) diff --git a/server/auth-realtime/src/headers.ts b/server/auth-realtime/src/headers.ts new file mode 100644 index 00000000..6ec7676a --- /dev/null +++ b/server/auth-realtime/src/headers.ts @@ -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).forEach === "function" +} + +function isEntryIterable(value: object): value is Iterable<[string, unknown]> { + return ( + typeof (value as Partial>)[ + 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 + 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)[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 +} diff --git a/server/auth-realtime/src/hocuspocus.test.ts b/server/auth-realtime/src/hocuspocus.test.ts new file mode 100644 index 00000000..6cd7b742 --- /dev/null +++ b/server/auth-realtime/src/hocuspocus.test.ts @@ -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", + ) + }) +}) diff --git a/server/auth-realtime/src/hocuspocus.ts b/server/auth-realtime/src/hocuspocus.ts index d975c00c..60f343c4 100644 --- a/server/auth-realtime/src/hocuspocus.ts +++ b/server/auth-realtime/src/hocuspocus.ts @@ -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>() +// 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 +} -// 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 { @@ -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>() + + 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 { + 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), + }) } diff --git a/server/auth-realtime/src/index.ts b/server/auth-realtime/src/index.ts index cfb6d9a5..8e53ae60 100644 --- a/server/auth-realtime/src/index.ts +++ b/server/auth-realtime/src/index.ts @@ -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", () => { diff --git a/server/auth-realtime/src/operations.test.ts b/server/auth-realtime/src/operations.test.ts new file mode 100644 index 00000000..0e89190d --- /dev/null +++ b/server/auth-realtime/src/operations.test.ts @@ -0,0 +1,1087 @@ +import { describe, it } from "vitest" +import * as Y from "yjs" +import { replaceYdocContent } from "./ydocument.js" +import { + applyOperations, + findByUid, + pmBlockToY, + type InsertOp, + type Operation, + type PMInline, + type PMNode, +} from "./operations.js" + +const SAMPLE_NAME = "Runbook" +const SAMPLE_ICON = "lucide:file" + +function textParagraph(uid: string, text: string): PMNode { + return { + type: "paragraph", + attrs: { uid }, + content: [{ type: "text", text }], + } +} + +function emptyParagraph(uid: string): PMNode { + return { type: "paragraph", attrs: { uid } } +} + +// a realistic editor document: an intro paragraph, a callout wrapping a +// nested paragraph, and a heading last. Tests that care about TipTap's +// trailing empty paragraph append one themselves. +function sampleBlocks(): PMNode[] { + return [ + textParagraph("intro", "Intro line"), + { + type: "calloutBlock", + attrs: { uid: "callout", icon: "lucide:zap" }, + content: [textParagraph("nested", "Nested line")], + }, + { + type: "heading", + attrs: { uid: "head", level: 2 }, + content: [{ type: "text", text: "Section" }], + }, + ] +} + +// builds a live document through the same path the Hocuspocus server +// uses, so every block carries the uid the uid-addressed operations +// look up. +function docWith(blocks: PMNode[]): Y.Doc { + const doc = new Y.Doc() + replaceYdocContent(doc, { + name: SAMPLE_NAME, + content: { type: "doc", content: blocks }, + icon: SAMPLE_ICON, + }) + + return doc +} + +// a paragraph whose only child is a block element. The transformer +// cannot produce one — paragraphs hold inline content — so it is built +// by hand to exercise the element branch of the trailing-node check. +function paragraphWrappingElement(uid: string): Y.XmlElement { + const para = new Y.XmlElement("paragraph") + para.setAttribute("uid", uid) + + const inner = new Y.XmlElement("paragraph") + inner.setAttribute("uid", `${uid}-inner`) + para.insert(0, [inner]) + + return para +} + +// a paragraph holding a zero-length Y.XmlText, which is still the +// trailing-node affordance as far as opAppend is concerned. +function paragraphWithBlankText(uid: string): Y.XmlElement { + const para = new Y.XmlElement("paragraph") + para.setAttribute("uid", uid) + para.insert(0, [new Y.XmlText()]) + + return para +} + +// the uids of the content fragment's direct children, which is what +// most position assertions come down to. +function topUids(doc: Y.Doc): (string | undefined)[] { + return doc + .getXmlFragment("content") + .toArray() + .map((node) => + node instanceof Y.XmlElement + ? node.getAttribute("uid") + : undefined, + ) +} + +// resolves a block for assertions, throwing rather than returning null +// so callers keep a non-nullable element. +function blockByUid(doc: Y.Doc, uid: string): Y.XmlElement { + const found = findByUid(doc.getXmlFragment("content"), uid) + if (!found) { + throw new Error(`test fixture has no block: ${uid}`) + } + + return found.element +} + +// getAttributes is declared as a string map, but the runtime keeps +// whatever value it was given — numbers and arrays included — so +// assertions widen it once here. +function attrsOf(el: Y.XmlElement): Record { + return el.getAttributes() as Record +} + +// Y.XmlText.toDelta is declared as returning any; narrowing it in one +// place keeps every call site free of unsafe values. +function inlineDelta(el: Y.XmlElement): unknown[] { + const child = el.get(0) + if (!(child instanceof Y.XmlText)) { + throw new Error("block carries no inline text") + } + + return child.toDelta() as unknown[] +} + +// neither Y.XmlElement nor Y.Text declares a string-returning toString +// in its type definitions, though both serialize properly at runtime. +// Narrowing it once here keeps the assertions readable. +function serialize(node: Y.XmlElement | Y.Text): string { + return (node as unknown as { toString(): string }).toString() +} + +// a detached Y.XmlElement keeps its attributes and children in prelim +// state, invisible to the public getters, so assertions read it after +// attaching it to a throwaway host — which is what the operations do +// with it anyway. +function attached(el: Y.XmlElement): Y.XmlElement { + new Y.Doc().getXmlFragment("content").insert(0, [el]) + + return el +} + +describe("applyOperations", () => { + it("keeps applying later operations after one of them fails", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "prepend", + block: textParagraph("first", "First"), + }, + { kind: "delete", block_uid: "ghost" }, + { + kind: "append", + block: textParagraph("last", "Last"), + }, + ]) + + expect(result.applied).toBe(2) + expect(result.errors).toEqual([ + { index: 1, message: "block_uid not found: ghost" }, + ]) + expect(topUids(doc)).toEqual([ + "first", + "intro", + "callout", + "head", + "last", + ]) + }) + + it("emits one consolidated update for the whole batch", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + let updates = 0 + doc.on("update", () => { + updates++ + }) + + applyOperations(doc, [ + { kind: "set_name", name: "Renamed" }, + { kind: "set_icon", icon: "lucide:zap" }, + { + kind: "prepend", + block: textParagraph("first", "First"), + }, + ]) + + expect(updates).toBe(1) + }) + + it("reports nothing applied for an empty batch", ({ expect }) => { + const doc = docWith(sampleBlocks()) + + expect(applyOperations(doc, [])).toEqual({ + applied: 0, + errors: [], + }) + expect(topUids(doc)).toEqual(["intro", "callout", "head"]) + }) + + // an operation kind this service does not implement means the two + // sides have drifted apart; counting the no-op as applied would tell + // the caller its edit landed + it("reports an error for an operation kind it does not implement", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { kind: "transmogrify" } as unknown as Operation, + ]) + + expect(result.applied).toBe(0) + expect(result.errors).toEqual([ + { + index: 0, + message: "unknown operation kind: transmogrify", + }, + ]) + expect(topUids(doc)).toEqual(["intro", "callout", "head"]) + }) + + describe("insert", () => { + const positionCases: { + name: string + input: InsertOp["position"] + expected: string[] + }[] = [ + { + name: "before", + input: "before", + expected: ["intro", "added", "callout", "head"], + }, + { + name: "after", + input: "after", + expected: ["intro", "callout", "added", "head"], + }, + ] + + it.for(positionCases)( + "places the block $name the reference block", + ({ input, expected }, { expect }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "insert", + position: input, + reference_uid: "callout", + block: textParagraph( + "added", + "Added", + ), + }, + ]) + + expect(result.applied).toBe(1) + expect(topUids(doc)).toEqual(expected) + }, + ) + + it("inserts next to a nested block inside that block's own parent", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + applyOperations(doc, [ + { + kind: "insert", + position: "after", + reference_uid: "nested", + block: textParagraph( + "sibling", + "Sibling", + ), + }, + ]) + + const callout = blockByUid(doc, "callout") + expect( + callout + .toArray() + .map((node) => + node instanceof Y.XmlElement + ? node.getAttribute( + "uid", + ) + : undefined, + ), + ).toEqual(["nested", "sibling"]) + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + ]) + }) + + // the position arrives as unvalidated JSON, and taking anything + // that is not "before" as "after" would put the block on the + // wrong side of the reference without saying so + it("reports an error for a position that is neither before nor after", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "insert", + position: "beforeX", + reference_uid: "callout", + block: textParagraph("added", "Added"), + } as unknown as Operation, + ]) + + expect(result.applied).toBe(0) + expect(result.errors[0]?.message).toBe( + 'insert position must be "before" or "after", got: beforeX', + ) + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + ]) + }) + + it("reports an error when the reference block is absent", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "insert", + position: "after", + reference_uid: "ghost", + block: textParagraph("added", "Added"), + }, + ]) + + expect(result).toEqual({ + applied: 0, + errors: [ + { + index: 0, + message: "reference_uid not found: ghost", + }, + ], + }) + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + ]) + }) + }) + + describe("append", () => { + it("adds the block after a last block that is not a paragraph", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + applyOperations(doc, [ + { + kind: "append", + block: textParagraph("added", "Added"), + }, + ]) + + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + "added", + ]) + }) + + it("adds the block before TipTap's trailing empty paragraph", ({ + expect, + }) => { + const doc = docWith([ + ...sampleBlocks(), + emptyParagraph("trailing"), + ]) + + applyOperations(doc, [ + { + kind: "append", + block: textParagraph("added", "Added"), + }, + ]) + + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + "added", + "trailing", + ]) + }) + + it("adds the block after a last paragraph that still carries text", ({ + expect, + }) => { + const doc = docWith([ + ...sampleBlocks(), + textParagraph("outro", "Outro line"), + ]) + + applyOperations(doc, [ + { + kind: "append", + block: textParagraph("added", "Added"), + }, + ]) + + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + "outro", + "added", + ]) + }) + + it("adds the block after a last paragraph that wraps an element", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + const frag = doc.getXmlFragment("content") + frag.insert(frag.length, [ + paragraphWrappingElement("wrapper"), + ]) + + applyOperations(doc, [ + { + kind: "append", + block: textParagraph("added", "Added"), + }, + ]) + + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + "wrapper", + "added", + ]) + }) + + it("adds the block before a last paragraph holding only blank text", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + const frag = doc.getXmlFragment("content") + frag.insert(frag.length, [ + paragraphWithBlankText("trailing"), + ]) + + applyOperations(doc, [ + { + kind: "append", + block: textParagraph("added", "Added"), + }, + ]) + + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + "added", + "trailing", + ]) + }) + + it("adds the block after a trailing node that is not an element", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + const frag = doc.getXmlFragment("content") + const stray = new Y.XmlText() + frag.insert(frag.length, [stray]) + stray.insert(0, "stray") + + applyOperations(doc, [ + { + kind: "append", + block: textParagraph("added", "Added"), + }, + ]) + + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + undefined, + "added", + ]) + }) + + it("adds the block as the only child of an empty fragment", ({ + expect, + }) => { + const doc = new Y.Doc() + + const result = applyOperations(doc, [ + { + kind: "append", + block: textParagraph("added", "Added"), + }, + ]) + + expect(result.applied).toBe(1) + expect(topUids(doc)).toEqual(["added"]) + expect(serialize(blockByUid(doc, "added"))).toBe( + 'Added', + ) + }) + }) + + describe("prepend", () => { + it("adds the block at index 0", ({ expect }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "prepend", + block: textParagraph("added", "Added"), + }, + ]) + + expect(result.applied).toBe(1) + expect(topUids(doc)).toEqual([ + "added", + "intro", + "callout", + "head", + ]) + }) + }) + + describe("replace", () => { + it("swaps the block in place, keeping its index", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "replace", + block_uid: "callout", + block: textParagraph("fresh", "Fresh"), + }, + ]) + + expect(result.applied).toBe(1) + expect(topUids(doc)).toEqual(["intro", "fresh", "head"]) + expect( + findByUid( + doc.getXmlFragment("content"), + "nested", + ), + ).toBeNull() + }) + + it("swaps a nested block inside that block's own parent", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + applyOperations(doc, [ + { + kind: "replace", + block_uid: "nested", + block: textParagraph("fresh", "Fresh"), + }, + ]) + + const found = findByUid( + doc.getXmlFragment("content"), + "fresh", + ) + expect(found?.index).toBe(0) + expect(found?.parent).toBe(blockByUid(doc, "callout")) + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + ]) + }) + + it("reports an error when the block is absent", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "replace", + block_uid: "ghost", + block: textParagraph("fresh", "Fresh"), + }, + ]) + + expect(result).toEqual({ + applied: 0, + errors: [ + { + index: 0, + message: "block_uid not found: ghost", + }, + ], + }) + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + ]) + }) + }) + + describe("update_text", () => { + it("swaps the inline content while keeping the block's type, attrs and uid", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "update_text", + block_uid: "head", + content: [ + { + type: "text", + text: "Rewritten", + }, + ], + }, + ]) + + const head = blockByUid(doc, "head") + expect(result.applied).toBe(1) + expect(head.nodeName).toBe("heading") + expect(attrsOf(head)).toEqual({ uid: "head", level: 2 }) + expect(head.length).toBe(1) + expect(inlineDelta(head)).toEqual([ + { insert: "Rewritten" }, + ]) + }) + + it("turns marks into format attributes on the inline text", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + applyOperations(doc, [ + { + kind: "update_text", + block_uid: "intro", + content: [ + { + type: "text", + text: "plain ", + }, + { + type: "text", + text: "bold", + marks: [ + { + type: "bold", + }, + ], + }, + { + type: "text", + text: "link", + marks: [ + { + type: "link", + attrs: { + href: "https://oxynote.test", + }, + }, + ], + }, + ], + }, + ]) + + expect(inlineDelta(blockByUid(doc, "intro"))).toEqual([ + { insert: "plain " }, + { insert: "bold", attributes: { bold: true } }, + { + insert: "link", + attributes: { + link: { + href: "https://oxynote.test", + }, + }, + }, + ]) + }) + + it("leaves the block without children when the content is empty", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + applyOperations(doc, [ + { + kind: "update_text", + block_uid: "intro", + content: [], + }, + ]) + + const intro = blockByUid(doc, "intro") + expect(intro.length).toBe(0) + expect(serialize(intro)).toBe( + '', + ) + }) + + it("leaves the block without children when no inline node carries text", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + applyOperations(doc, [ + { + kind: "update_text", + block_uid: "intro", + content: [ + { + type: "hardBreak", + } as unknown as PMInline, + { type: "text", text: "" }, + ], + }, + ]) + + expect(blockByUid(doc, "intro").length).toBe(0) + }) + + it("reports an error when the block is absent", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "update_text", + block_uid: "ghost", + content: [ + { + type: "text", + text: "Rewritten", + }, + ], + }, + ]) + + expect(result).toEqual({ + applied: 0, + errors: [ + { + index: 0, + message: "block_uid not found: ghost", + }, + ], + }) + }) + }) + + describe("update_attrs", () => { + it("sets the named attributes and keeps the untouched ones", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "update_attrs", + block_uid: "callout", + attrs: { + icon: "lucide:bug", + queries: [{ query: "up" }], + }, + }, + ]) + + expect(result.applied).toBe(1) + expect(attrsOf(blockByUid(doc, "callout"))).toEqual({ + uid: "callout", + icon: "lucide:bug", + queries: [{ query: "up" }], + }) + }) + + it("refuses to change the uid while applying the other attributes", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + applyOperations(doc, [ + { + kind: "update_attrs", + block_uid: "head", + attrs: { uid: "stolen", level: 3 }, + }, + ]) + + expect(attrsOf(blockByUid(doc, "head"))).toEqual({ + uid: "head", + level: 3, + }) + expect( + findByUid( + doc.getXmlFragment("content"), + "stolen", + ), + ).toBeNull() + }) + + it("reports an error when the block is absent", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { + kind: "update_attrs", + block_uid: "ghost", + attrs: { level: 3 }, + }, + ]) + + expect(result).toEqual({ + applied: 0, + errors: [ + { + index: 0, + message: "block_uid not found: ghost", + }, + ], + }) + }) + }) + + describe("delete", () => { + it("removes the block and everything nested inside it", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { kind: "delete", block_uid: "callout" }, + ]) + + expect(result.applied).toBe(1) + expect(topUids(doc)).toEqual(["intro", "head"]) + expect( + findByUid( + doc.getXmlFragment("content"), + "nested", + ), + ).toBeNull() + }) + + it("reports an error when the block is absent", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { kind: "delete", block_uid: "ghost" }, + ]) + + expect(result).toEqual({ + applied: 0, + errors: [ + { + index: 0, + message: "block_uid not found: ghost", + }, + ], + }) + expect(topUids(doc)).toEqual([ + "intro", + "callout", + "head", + ]) + }) + }) + + describe("set_name", () => { + it("replaces the name fragment with one paragraph carrying the text and a generated uid", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + const result = applyOperations(doc, [ + { kind: "set_name", name: "Renamed" }, + ]) + + const frag = doc.getXmlFragment("name") + const para = frag.get(0) + expect(result.applied).toBe(1) + expect(frag.length).toBe(1) + expect(para).toBeInstanceOf(Y.XmlElement) + + if (!(para instanceof Y.XmlElement)) { + return + } + + expect(para.nodeName).toBe("paragraph") + expect(serialize(para)).toContain(">Renamed<") + + const uid = para.getAttribute("uid") + expect(typeof uid).toBe("string") + expect(uid).not.toBe("") + }) + + it("leaves the paragraph without a text child for an empty name", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + + applyOperations(doc, [{ kind: "set_name", name: "" }]) + + const frag = doc.getXmlFragment("name") + const para = frag.get(0) + expect(frag.length).toBe(1) + expect(para).toBeInstanceOf(Y.XmlElement) + + if (!(para instanceof Y.XmlElement)) { + return + } + + expect(para.length).toBe(0) + expect(para.getAttribute("uid")).not.toBe("") + }) + }) + + describe("set_icon", () => { + const iconCases: { + name: string + input: string + expected: string + }[] = [ + { + name: "a new identifier", + input: "lucide:bug", + expected: "lucide:bug", + }, + { name: "an empty string", input: "", expected: "" }, + ] + + it.for(iconCases)( + "replaces the icon text with $name", + ({ input, expected }, { expect }) => { + const doc = docWith(sampleBlocks()) + expect(serialize(doc.getText("icon"))).toBe( + SAMPLE_ICON, + ) + + const result = applyOperations(doc, [ + { kind: "set_icon", icon: input }, + ]) + + expect(result.applied).toBe(1) + expect(serialize(doc.getText("icon"))).toBe( + expected, + ) + }, + ) + }) +}) + +describe("findByUid", () => { + it("returns a top-level block with the content fragment as its parent", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + const frag = doc.getXmlFragment("content") + + const found = findByUid(frag, "head") + + expect(found?.parent).toBe(frag) + expect(found?.index).toBe(2) + expect(found?.element.nodeName).toBe("heading") + }) + + it("returns a nested block with its enclosing element as parent", ({ + expect, + }) => { + const doc = docWith(sampleBlocks()) + const callout = blockByUid(doc, "callout") + + const found = findByUid(doc.getXmlFragment("content"), "nested") + + expect(found?.parent).toBe(callout) + expect(found?.index).toBe(0) + expect(found?.element).toBe(callout.get(0)) + }) + + it("returns null when no block carries the uid", ({ expect }) => { + const doc = docWith(sampleBlocks()) + + expect( + findByUid(doc.getXmlFragment("content"), "ghost"), + ).toBeNull() + }) + + it("skips children that are not elements", ({ expect }) => { + const doc = new Y.Doc() + const frag = doc.getXmlFragment("content") + const stray = new Y.XmlText() + frag.insert(0, [stray]) + stray.insert(0, "stray") + frag.insert(1, [paragraphWrappingElement("wrapper")]) + + const found = findByUid(frag, "wrapper-inner") + + expect(found?.index).toBe(0) + expect(found?.parent).toBe(frag.get(1)) + }) +}) + +describe("pmBlockToY", () => { + it("returns a detached element carrying the block's node name, attributes and children", ({ + expect, + }) => { + const el = pmBlockToY({ + type: "calloutBlock", + attrs: { uid: "callout", icon: "lucide:zap" }, + content: [textParagraph("nested", "Nested line")], + }) + + expect(el.nodeName).toBe("calloutBlock") + expect(el.doc).toBeNull() + expect(el.parent).toBeNull() + + const live = attached(el) + expect(attrsOf(live)).toEqual({ + uid: "callout", + icon: "lucide:zap", + }) + expect(serialize(live)).toBe( + '' + + 'Nested line' + + "", + ) + }) + + it("preserves attribute values that are not strings", ({ expect }) => { + const el = pmBlockToY({ + type: "metricBlock", + attrs: { + uid: "metric", + queries: [{ query: "up", legend: "uptime" }], + }, + }) + + expect(attrsOf(attached(el))).toEqual({ + uid: "metric", + queries: [{ query: "up", legend: "uptime" }], + }) + }) + + it("throws when the transformer yields no element for the block", ({ + expect, + }) => { + // a bare inline text node is not a block, so the transformer + // puts a Y.XmlText at the top of the fragment + expect(() => + pmBlockToY({ type: "text", text: "loose" }), + ).toThrow("pmBlockToY: transformer produced no XmlElement") + }) +}) diff --git a/server/auth-realtime/src/operations.ts b/server/auth-realtime/src/operations.ts index 1781cc21..40ebfa07 100644 --- a/server/auth-realtime/src/operations.ts +++ b/server/auth-realtime/src/operations.ts @@ -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 } diff --git a/server/auth-realtime/src/reporting.test.ts b/server/auth-realtime/src/reporting.test.ts new file mode 100644 index 00000000..9295cabd --- /dev/null +++ b/server/auth-realtime/src/reporting.test.ts @@ -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) + }) +}) diff --git a/server/auth-realtime/src/reporting.ts b/server/auth-realtime/src/reporting.ts new file mode 100644 index 00000000..8b78628b --- /dev/null +++ b/server/auth-realtime/src/reporting.ts @@ -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(run: () => Promise): Promise { + 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 { + try { + await run() + } catch (err) { + Sentry.captureException(err) + } +} diff --git a/server/auth-realtime/src/routes.test.ts b/server/auth-realtime/src/routes.test.ts new file mode 100644 index 00000000..d29f6678 --- /dev/null +++ b/server/auth-realtime/src/routes.test.ts @@ -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 + transact: (fn: (doc: Y.Doc) => void) => Promise + open: () => Promise + }> = {}, +) { + const documents = overrides.documents ?? new Map() + 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 + 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") + }) + }) +}) diff --git a/server/auth-realtime/src/routes.ts b/server/auth-realtime/src/routes.ts index 91a64430..515149ac 100644 --- a/server/auth-realtime/src/routes.ts +++ b/server/auth-realtime/src/routes.ts @@ -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 + api: { + getJwks(): Promise[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 | null = null - -async function mcpTokenKeySet(): Promise> { - 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 + disconnect(): void | Promise +} + +// 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 +} + +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)[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 { + 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 | null = null + + async function mcpTokenKeySet(): Promise< + ReturnType + > { + 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 +} diff --git a/server/auth-realtime/src/schema/callout.ts b/server/auth-realtime/src/schema/callout.ts index 3373de3c..a0d54e6f 100644 --- a/server/auth-realtime/src/schema/callout.ts +++ b/server/auth-realtime/src/schema/callout.ts @@ -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, }), }, } diff --git a/server/auth-realtime/src/schema/code-block.ts b/server/auth-realtime/src/schema/code-block.ts index 89427a5a..e1568996 100644 --- a/server/auth-realtime/src/schema/code-block.ts +++ b/server/auth-realtime/src/schema/code-block.ts @@ -52,9 +52,11 @@ export const CodeBlock = CodeBlockLowlight.extend({ node, HTMLAttributes, }: { - node: any + node: { attrs: Record } HTMLAttributes: Record }) { + const language = node.attrs.language as string | null + return [ "pre", mergeAttributes(HTMLAttributes, { @@ -63,10 +65,8 @@ export const CodeBlock = CodeBlockLowlight.extend({ [ "code", { - class: node.attrs.language - ? this.options - .languageClassPrefix + - node.attrs.language + class: language + ? `${this.options.languageClassPrefix ?? ""}${language}` : null, }, 0, diff --git a/server/auth-realtime/src/schema/figma.ts b/server/auth-realtime/src/schema/figma.ts index f6d899ea..a65ded31 100644 --- a/server/auth-realtime/src/schema/figma.ts +++ b/server/auth-realtime/src/schema/figma.ts @@ -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, + } }, }, } diff --git a/server/auth-realtime/src/schema/index.ts b/server/auth-realtime/src/schema/index.ts index 3f982650..c0672293 100644 --- a/server/auth-realtime/src/schema/index.ts +++ b/server/auth-realtime/src/schema/index.ts @@ -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(), }), ] } diff --git a/server/auth-realtime/src/schema/marks.ts b/server/auth-realtime/src/schema/marks.ts index 9c58060e..3545bf5c 100644 --- a/server/auth-realtime/src/schema/marks.ts +++ b/server/auth-realtime/src/schema/marks.ts @@ -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, } }, }, diff --git a/server/auth-realtime/src/schema/mermaid.ts b/server/auth-realtime/src/schema/mermaid.ts index a3082d53..8e131086 100644 --- a/server/auth-realtime/src/schema/mermaid.ts +++ b/server/auth-realtime/src/schema/mermaid.ts @@ -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, diff --git a/server/auth-realtime/src/schema/split-documentation.ts b/server/auth-realtime/src/schema/split-documentation.ts index 936ff086..edde540d 100644 --- a/server/auth-realtime/src/schema/split-documentation.ts +++ b/server/auth-realtime/src/schema/split-documentation.ts @@ -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", diff --git a/server/auth-realtime/src/instrument.ts b/server/auth-realtime/src/sentry.ts similarity index 100% rename from server/auth-realtime/src/instrument.ts rename to server/auth-realtime/src/sentry.ts diff --git a/server/auth-realtime/src/test-helpers.ts b/server/auth-realtime/src/test-helpers.ts new file mode 100644 index 00000000..68e5367b --- /dev/null +++ b/server/auth-realtime/src/test-helpers.ts @@ -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 +} + +// 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 +} + +// 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 { + 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, + } +} diff --git a/server/auth-realtime/src/test-setup.ts b/server/auth-realtime/src/test-setup.ts new file mode 100644 index 00000000..a73b69eb --- /dev/null +++ b/server/auth-realtime/src/test-setup.ts @@ -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) +} diff --git a/server/auth-realtime/src/ydocument.test.ts b/server/auth-realtime/src/ydocument.test.ts new file mode 100644 index 00000000..a8761a20 --- /dev/null +++ b/server/auth-realtime/src/ydocument.test.ts @@ -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 { + 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) + }) +}) diff --git a/server/auth-realtime/src/ydocument.ts b/server/auth-realtime/src/ydocument.ts index a6933b02..0ca09b74 100644 --- a/server/auth-realtime/src/ydocument.ts +++ b/server/auth-realtime/src/ydocument.ts @@ -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]) } } diff --git a/server/auth-realtime/tsconfig.build.json b/server/auth-realtime/tsconfig.build.json new file mode 100644 index 00000000..f5e52fb4 --- /dev/null +++ b/server/auth-realtime/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts", + "src/**/test-helpers.ts", + "src/test-setup.ts" + ] +} diff --git a/server/auth-realtime/tsconfig.json b/server/auth-realtime/tsconfig.json index 2c77be04..1d356755 100644 --- a/server/auth-realtime/tsconfig.json +++ b/server/auth-realtime/tsconfig.json @@ -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"] } diff --git a/server/auth-realtime/vitest.config.ts b/server/auth-realtime/vitest.config.ts new file mode 100644 index 00000000..d3cd81ba --- /dev/null +++ b/server/auth-realtime/vitest.config.ts @@ -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, + }, + }, + }, +})