mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
add lint and test scripts to server/auth-realtime
This commit is contained in:
parent
b1f86cdd0c
commit
026cd56856
83
.github/workflows/qa-auth-realtime.yml
vendored
Normal file
83
.github/workflows/qa-auth-realtime.yml
vendored
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
name: QA
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- server/auth-realtime/**
|
||||
- .github/workflows/qa-auth-realtime.yml
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- server/auth-realtime/**
|
||||
- .github/workflows/qa-auth-realtime.yml
|
||||
|
||||
# a newer push supersedes the still-running workflow of the stale commit
|
||||
# and cancels it. The group carries a hardcoded per-file prefix because
|
||||
# every QA workflow shares the display name "QA", so github.workflow
|
||||
# would put them all in one group and let a run of one workflow cancel
|
||||
# an in-flight run of another.
|
||||
concurrency:
|
||||
group: qa-auth-realtime-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Linting auth-realtime
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
package_json_file: server/auth-realtime/package.json
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: server/auth-realtime/pnpm-lock.yaml
|
||||
|
||||
- name: Installing dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: server/auth-realtime
|
||||
|
||||
- name: Run check-lint
|
||||
run: pnpm run check-lint
|
||||
working-directory: server/auth-realtime
|
||||
|
||||
test:
|
||||
name: Testing auth-realtime
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
package_json_file: server/auth-realtime/package.json
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: server/auth-realtime/pnpm-lock.yaml
|
||||
|
||||
- name: Installing dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: server/auth-realtime
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm run test
|
||||
working-directory: server/auth-realtime
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
11
Makefile
11
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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -34,11 +34,13 @@ cd server/core && make build # goreleaser release --snapshot --clean -> bin
|
|||
# datagen
|
||||
cd datagen && make build
|
||||
|
||||
# auth-realtime
|
||||
cd server/auth-realtime && pnpm run dev # local watch mode (tsx + instrument.ts)
|
||||
cd server/auth-realtime && pnpm run build # tsc only
|
||||
cd server/auth-realtime && pnpm run qa # check-types + check-lint + check-fmt
|
||||
cd server/auth-realtime && pnpm run qa-fix # check-types + lint --fix + prettier --write
|
||||
# auth-realtime (full details in auth-realtime/CLAUDE.md)
|
||||
cd server/auth-realtime && pnpm run dev # local watch mode (tsx + sentry.ts preloaded)
|
||||
cd server/auth-realtime && pnpm run build # tsc -p tsconfig.build.json
|
||||
cd server/auth-realtime && pnpm run check-lint # types + eslint + prettier + knip
|
||||
cd server/auth-realtime && pnpm run lint # the fixing variant
|
||||
cd server/auth-realtime && pnpm run test # vitest run --coverage
|
||||
cd server/auth-realtime && pnpm run qa # check-lint + test; qa-fix = lint + test
|
||||
```
|
||||
|
||||
The Go test/lint workflow is documented in [core/CLAUDE.md](core/CLAUDE.md)
|
||||
|
|
@ -76,7 +78,7 @@ This is the model that's least obvious from a fresh read:
|
|||
- Documents are organized into **branches** (`document_branches` table). Every document has at least one branch; the oldest = the default/main branch.
|
||||
- The Hocuspocus `documentName` is encoded as `"<documentId>-<branchIdentifier>"`. `branchIdentifier` may be the literal string `"default"`, in which case `resolveBranchId` in `server/auth-realtime/src/hocuspocus.ts` looks up the default branch ID. Splitting on the **first** `-` (`indexOf("-")`) is intentional; XIDs do not contain dashes but the suffix path can.
|
||||
- Branch content is stored two ways in `document_branches`: structured ProseMirror JSON in `content` (JSONB) and the canonical Yjs binary state in `raw_content` (base64-encoded over the wire). `raw_content` is authoritative for CRDT continuity.
|
||||
- `onLoadDocument` / `onStoreDocument` round-trip through core's internal `/api/x/documents/{id}/branch/{branchId}` endpoints. **Do not** use `Y.applyUpdate` to seed a freshly-created doc with content from another doc — the differing `clientID`s CRDT-merge and silently duplicate content. The codebase's pattern is `replaceYdocContent` in `server/auth-realtime/src/ydocument.ts`, which deep-clones `Y.XmlElement`s (preserving non-string attrs that `XmlElement.clone()` drops). Read the comments at `server/auth-realtime/src/hocuspocus.ts:154` and `server/auth-realtime/src/routes.ts:73` before touching this area.
|
||||
- `onLoadDocument` / `onStoreDocument` round-trip through core's internal `/api/x/documents/{id}/branch/{branchId}` endpoints. **Do not** use `Y.applyUpdate` to seed a freshly-created doc with content from another doc — the differing `clientID`s CRDT-merge and silently duplicate content. The codebase's pattern is `replaceYdocContent` in `server/auth-realtime/src/ydocument.ts`, which deep-clones `Y.XmlElement`s (preserving non-string attrs that `XmlElement.clone()` drops). Read the comments on `onLoadDocument` in `server/auth-realtime/src/hocuspocus.ts` and on `applyMergeToOpenDocument` in `server/auth-realtime/src/routes.ts` before touching this area.
|
||||
- **The main branch is identified by its `"default"` flag, not by its name.** The tree join and `FetchMainBranchContent` key on the flag; `UpdateDocumentBranch` refuses to rename the default branch so the two never disagree. Anything that creates a document's first branch — `NewDocument`, `Duplicate` — must set `Default: true`, and forks must not.
|
||||
- **Maintainers accumulate; nothing removes them.** The `maintainers` field on a branch update is *who edited in that persist*, not the document's maintainer set — auth-realtime's store hook sends the current session's editors and system writes send none. `UpsertDocumentMaintainers` only ever adds, so a diff against the stored set would drop everyone not editing at that moment. Removing a maintainer needs a path of its own; there is none today.
|
||||
- Branch merging: `PUT /documents/:documentId/merge` is handled by auth-realtime, which proxies to core, then **directly mutates the in-memory target-branch Y.Doc** via `replaceYdocContent` (not `applyUpdate`) and immediately persists `rawContent` back through `/api/x/.../branch/:branchId` so a server restart can't reset the clientID and duplicate content on reconnect.
|
||||
|
|
@ -253,11 +255,19 @@ change to a schema or a tool description has to be deliberate enough to update
|
|||
that file — a silently altered description degrades the assistant in ways no
|
||||
other test catches.
|
||||
|
||||
## Node formatting & TS
|
||||
## auth-realtime
|
||||
|
||||
Prettier uses **tabs (width 8)**, no semicolons, trailing commas, double-quoted strings — see [auth-realtime/prettier.config.js](auth-realtime/prettier.config.js). ESLint extends `@eslint/js` recommended + `typescript-eslint` **strict** + **stylistic** + `eslint-config-prettier`; `@typescript-eslint/no-explicit-any` is **off**. See [auth-realtime/eslint.config.mjs](auth-realtime/eslint.config.mjs).
|
||||
The service has its own [CLAUDE.md](auth-realtime/CLAUDE.md) covering its
|
||||
composition-root structure, zod-parsed environment, type-aware lint setup, and
|
||||
testing conventions. Two things worth knowing from here:
|
||||
|
||||
TypeScript is `module: NodeNext`, `target: ESNext`, fully strict — `strict`, `noImplicitAny`, `noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexedAccess`, `noImplicitOverride`, `verbatimModuleSyntax` all on. See [auth-realtime/tsconfig.json](auth-realtime/tsconfig.json). Path alias `@/*` points to `server/auth-realtime/src/*`.
|
||||
|
||||
**ES module imports must include the explicit `.js` extension** (NodeNext + ESM requirement), even when importing TypeScript files — e.g. `import { foo } from "./bar.js"` for a `./bar.ts` source file.
|
||||
- **`src/index.ts` is the only module with side effects.** Everything else is
|
||||
a factory taking its dependencies as an argument — which is what lets the
|
||||
service be tested without a database, a redis, or a port. A change that
|
||||
reads `process.env` or opens a connection outside `index.ts` breaks that.
|
||||
- Prettier uses **tabs at width 8** (matching gofmt's convention on this side
|
||||
of the repo), no semicolons, trailing commas, double quotes. ESLint runs
|
||||
type-aware, and **ES module imports must include the explicit `.js`
|
||||
extension** (NodeNext + ESM), even when importing TypeScript files — e.g.
|
||||
`import { foo } from "./bar.js"` for a `./bar.ts` source file.
|
||||
|
||||
|
|
|
|||
338
server/auth-realtime/CLAUDE.md
Normal file
338
server/auth-realtime/CLAUDE.md
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
# CLAUDE.md
|
||||
|
||||
Guidance for `server/auth-realtime`. Shared working principles and the TS/JS
|
||||
comment/whitespace rules live in the root [CLAUDE.md](../../CLAUDE.md);
|
||||
backend architecture, the Caddy/port layout, and the document-storage model
|
||||
live in [server/CLAUDE.md](../CLAUDE.md).
|
||||
|
||||
## Project
|
||||
|
||||
`@oxynote/auth-realtime` — a TypeScript ES-module service running Better Auth
|
||||
and a Hocuspocus (Yjs) server in a single Hono process on port `8081`. It is a
|
||||
thin layer: it owns sessions, the realtime document connection, and the
|
||||
service-to-service endpoints core calls back into. Everything about documents
|
||||
themselves belongs to core.
|
||||
|
||||
## Common commands
|
||||
|
||||
Package manager is **pnpm** (own workspace).
|
||||
|
||||
```bash
|
||||
pnpm install # install dependencies
|
||||
pnpm dev # tsx watch, sentry preloaded via --import
|
||||
pnpm build # tsc -p tsconfig.build.json -> dist/
|
||||
pnpm start # run the built service
|
||||
|
||||
pnpm check-lint # check-types + check-eslint + check-fmt +
|
||||
# check-knip (read-only)
|
||||
pnpm lint # fixing variant: check-types + knip --fix
|
||||
# (removes dead exports/files!) + eslint --fix +
|
||||
# prettier --write
|
||||
pnpm test # vitest run --coverage
|
||||
pnpm test-watch # vitest in watch mode
|
||||
pnpm qa # check-lint + test; qa-fix = lint + test
|
||||
```
|
||||
|
||||
## Composition root
|
||||
|
||||
**`src/index.ts` is the only module with side effects.** It is the one place
|
||||
that reads the environment, opens a database pool, connects to Valkey, or
|
||||
listens on a port. Everything else is a factory taking what it needs:
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts composition root: env -> wiring -> listen
|
||||
sentry.ts the sentry bootstrap, loaded before the app graph exists
|
||||
env.ts zod-parsed, typed configuration
|
||||
core.ts the client for every call this service makes into core
|
||||
db.ts the Store: every query this service runs, behind one
|
||||
interface
|
||||
reporting.ts reported() / bestEffort(), the two ways a failure reaches
|
||||
sentry
|
||||
headers.ts normalizing whatever shape a transport hands over
|
||||
auth.ts createAuth(deps) + the callbacks better-auth invokes
|
||||
hocuspocus.ts createHocuspocus(deps) / createDocumentHooks(deps)
|
||||
routes.ts createRoutes(deps) -> a Hono app
|
||||
operations.ts pure: applying the assistant's edit ops to a Y.Doc
|
||||
ydocument.ts pure: replacing a Y.Doc's content without CRDT merging
|
||||
schema/ the ProseMirror schema, mirroring web's tiptap extensions
|
||||
```
|
||||
|
||||
Rules that follow, and that a change must not erode:
|
||||
|
||||
- **A module never reaches for `process.env`.** It takes an `Env`. `loadEnv`
|
||||
is called once, in `index.ts`.
|
||||
- **A factory takes its dependencies as an argument**, narrowed to what it
|
||||
actually uses. `createDocumentHooks` asks for a `SessionResolver` with one
|
||||
method rather than the whole better-auth instance, and `createRoutes` asks
|
||||
for a `DocumentRegistry` rather than the whole Hocuspocus server. That
|
||||
narrowing is what lets a test drive them with a two-line stub.
|
||||
- **A callback a framework will invoke is a named function, not an inline
|
||||
arrow**, whenever the framework does not hand it back. The organization
|
||||
plugin keeps its hooks to itself, so they live in
|
||||
`createOrganizationHooks`; better-auth exposes `auth.options`, so the
|
||||
email callbacks stay inline and the tests reach them there.
|
||||
- Module-level mutable state belongs in the factory's closure (the maintainer
|
||||
map in `createDocumentHooks`, the cached JWKS in `createRoutes`), never at
|
||||
module scope, so nothing carries over between servers — or between tests.
|
||||
- **No query builder leaves [db.ts](src/db.ts).** Every query the service
|
||||
runs is a method on `Store`, named for what it answers rather than the SQL
|
||||
it builds (`hasOAuthConsent`, `userOrganizationId`). A caller takes the
|
||||
`Store`, so a table or column rename touches one file and a test stubs four
|
||||
methods instead of a chain of builder calls.
|
||||
- **A failure reaches sentry through
|
||||
[reporting.ts](src/reporting.ts), not an inline `catch`.** `reported(fn)`
|
||||
reports and rethrows, for work whose failure the caller must still see —
|
||||
the frameworks this service hands callbacks to swallow the cause, so
|
||||
without it a failed hook leaves only a status code. `bestEffort(fn)` reports
|
||||
and swallows, for an effect whose failure must not fail work that has
|
||||
already succeeded (persisting rawContent after a document is already
|
||||
seeded, closing a direct connection). A `catch` that also builds a response
|
||||
stays written out, because neither helper can express that.
|
||||
|
||||
`src/sentry.ts` is the exception: node loads it through `--import` before the
|
||||
app graph exists, so it reads `process.env` directly and must not import
|
||||
anything of ours. Sentry's own docs call this file `instrument.js` — the name
|
||||
comes from "instrumentation", the APM term for the monkey-patching the SDK
|
||||
does at load — but nothing outside `package.json` and the Dockerfile's `CMD`
|
||||
depends on the filename, and `sentry.ts` matches how the rest of the
|
||||
repository names its Sentry setup (`web/sentry.client.config.ts`,
|
||||
`web/sentry.server.config.ts`).
|
||||
|
||||
## Environment
|
||||
|
||||
Every variable is declared in [src/env.ts](src/env.ts) and parsed once at boot
|
||||
with zod. **A missing, malformed, or half-configured value is a boot error** —
|
||||
the same policy core applies to its own env — so nothing surfaces later as an
|
||||
`undefined` deep inside better-auth. Specifically:
|
||||
|
||||
- URLs are scheme-pinned to http/https: bare `z.url()` accepts `core:8080` as
|
||||
a valid URL with a `core:` scheme, which would boot and then fail on use.
|
||||
- Counters must be positive integers; a typo is an error rather than a `NaN`
|
||||
limit that compares false against every count.
|
||||
- Boolean flags accept only `"true"` and `"false"`, so a flag spelled `1`
|
||||
cannot read as false while the operator believes it is set.
|
||||
- A social provider needs both halves of its credentials or neither. One half
|
||||
is a boot error rather than a broken OAuth redirect a user discovers.
|
||||
- Docker's env files list every variable including the blank ones, so an
|
||||
unset value arrives as `""`. `loadEnv` drops those before parsing, which is
|
||||
what makes a required variable report as missing rather than empty.
|
||||
|
||||
Adding a variable means adding it to the schema, to the `Env` interface, and
|
||||
to `docker/env/auth-realtime.example.env`.
|
||||
|
||||
## Formatting & TS
|
||||
|
||||
Prettier uses **tabs at width 8** (matching gofmt's convention on the backend
|
||||
side), no semicolons, trailing commas, double quotes — see
|
||||
[prettier.config.js](prettier.config.js).
|
||||
|
||||
ESLint ([eslint.config.mjs](eslint.config.mjs)) runs **type-aware**:
|
||||
`eslint:recommended` plus typescript-eslint **strictTypeChecked** and
|
||||
**stylisticTypeChecked**, resolved through the tsconfig by `projectService`.
|
||||
Consequences worth knowing:
|
||||
|
||||
- Linting builds a full TS program, so a cold run takes a few seconds. Runs
|
||||
are cached (`node_modules/.cache/eslint`); after cross-file type changes a
|
||||
cached file can report stale results locally, and `rm -rf
|
||||
node_modules/.cache/eslint` forces a full run. CI always runs cold.
|
||||
- Root config files (`eslint.config.mjs`, `knip.ts`, `prettier.config.js`,
|
||||
`vitest.config.ts`) sit outside the tsconfig project and opt out of typed
|
||||
linting; the two type-aware rule overrides live in the `src/**` block for
|
||||
the same reason, since applying them to an untyped file crashes the run.
|
||||
- Every `eslint-disable` **must** state a reason after `--`. It is for false
|
||||
positives only, never to avoid a fix. Stale disables are errors
|
||||
(`reportUnusedDisableDirectives`), and eslint runs with `--max-warnings 0`.
|
||||
- `@typescript-eslint/no-explicit-any` is off. The `no-unsafe-*` family is
|
||||
**on**, and the codebase answers it by typing the boundary rather than
|
||||
relaxing the rule: untrusted JSON is read as `unknown` and narrowed
|
||||
(`readString`, `upstreamResponse`, `seedContent`), and tiptap's
|
||||
`any`-typed extension API gets an explicit cast at each use, the same way
|
||||
[web](../../web/app/components/editor/blocks/) does it.
|
||||
|
||||
TypeScript is `module: NodeNext`, `target: ESNext`, fully strict, with
|
||||
`noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexedAccess`,
|
||||
`noImplicitOverride`, `verbatimModuleSyntax`, and `allowUnreachableCode:
|
||||
false`. Two configs: [tsconfig.json](tsconfig.json) covers all of `src/`
|
||||
(tests included) for `check-types` and eslint;
|
||||
[tsconfig.build.json](tsconfig.build.json) excludes the test files so `pnpm
|
||||
build` cannot emit them into `dist/`.
|
||||
|
||||
**ES module imports must include the explicit `.js` extension** (NodeNext +
|
||||
ESM requirement), even when importing TypeScript files — e.g. `import { foo }
|
||||
from "./bar.js"` for a `./bar.ts` source file.
|
||||
|
||||
**knip** ([knip.ts](knip.ts)) guards dead exports, unused files, and unused
|
||||
dependencies. `ignoreExportsUsedInFile` is on for interfaces and types only: a
|
||||
type exported so another export in the same file can name it — a union
|
||||
member, a table in the `Database` map, a factory's dependency bag — is part
|
||||
of that file's contract, not a dead export. Values are deliberately left out,
|
||||
because an exported const nobody imports is worth hearing about. Knip runs
|
||||
with `--cache`, and the cache is keyed by knip version, **not** config —
|
||||
after editing `knip.ts`, run `rm -rf node_modules/.cache/knip` or results are
|
||||
stale.
|
||||
|
||||
## Testing
|
||||
|
||||
### Layout
|
||||
|
||||
- **Tests are co-located**, paired 1:1 with the file they test:
|
||||
`ydocument.ts` → `ydocument.test.ts`. Never a parallel `tests/` mirror tree.
|
||||
- **One environment, one suffix.** The service is plain node — no DOM, no
|
||||
framework runtime — so every test is `.test.ts` running in vitest's node
|
||||
environment. There is no suffix taxonomy to learn.
|
||||
- When a test exercises behaviour spanning two source files, it lives with
|
||||
the file that initiates the behaviour.
|
||||
- **The test file mirrors the source file's order**: top-level `describe`
|
||||
blocks appear in the same order as the functions they test appear in the
|
||||
source, so the two files read side by side.
|
||||
- Helpers used by more than one suite live in
|
||||
[src/test-helpers.ts](src/test-helpers.ts) (`stubCore`, `stubStore`,
|
||||
`testEnv`, `fragmentXml`). It is excluded from coverage and from the build.
|
||||
Fixtures used by one suite stay file-local.
|
||||
- [db.test.ts](src/db.test.ts) is the only suite that knows kysely builds a
|
||||
query by chaining. It stubs the builder to pin the table and column names
|
||||
each `Store` method touches; every other suite takes a `stubStore()`.
|
||||
- **A yjs type must be added to a document before a test reads from it.**
|
||||
A detached `Y.XmlElement` or `Y.XmlText` keeps its attributes and children
|
||||
in prelim state, invisible to the public getters, and yjs answers the read
|
||||
with a console warning rather than an error — so the fixture is silently
|
||||
wrong and the test still passes. Build fixtures through an `attached()`
|
||||
helper (see `ydocument.test.ts` and `operations.test.ts`).
|
||||
[src/test-setup.ts](src/test-setup.ts) turns that warning into a failure so
|
||||
it cannot be missed: vitest prints intercepted stderr only under the
|
||||
verbose reporter, so in a piped or CI run the warning is invisible.
|
||||
- `src/schema/` is out of scope for the suite: it is a declarative mirror of
|
||||
web's tiptap extension list, and the behaviour that depends on it is
|
||||
covered through the transformer in `ydocument` and `operations`. It is
|
||||
excluded from coverage.
|
||||
|
||||
### Naming
|
||||
|
||||
**The rule: `describe` names the subject, `it` completes the sentence
|
||||
"it …" with observable behaviour.**
|
||||
|
||||
```ts
|
||||
describe("resolveBranchId", () => {
|
||||
it("returns a concrete branch identifier without asking core")
|
||||
it("resolves 'default' to the branch core flags as default")
|
||||
it("throws when no branch is flagged as default")
|
||||
})
|
||||
```
|
||||
|
||||
Read it aloud: "resolveBranchId throws when no branch is flagged as default."
|
||||
If the concatenation isn't a grammatical sentence, the name is off — reporters
|
||||
print exactly that concatenation on failure.
|
||||
|
||||
- Every test file wraps its tests in a root `describe` naming the unit by its
|
||||
real, greppable identifier. Never bare top-level `it` calls.
|
||||
- Present tense, third person, **no "should"**.
|
||||
- State behaviour, not implementation: `it("propagates a failed teardown so
|
||||
the organization survives")`, not `it("calls core.teardownOrganization")`.
|
||||
The name should survive a behaviour-preserving refactor.
|
||||
- Be specific enough that a failure is informative. `it("works")` and
|
||||
`it("handles errors")` are useless.
|
||||
- One level of `describe` nesting is usually right, two is the ceiling.
|
||||
|
||||
### Parameterized tests
|
||||
|
||||
**Use `it.for` wherever cases differ only in inputs and expected outputs.**
|
||||
The moment cases differ in setup, mock wiring, or assertions, they are
|
||||
separate `it`s — divergent cases in a shared callback breed conditionals, and
|
||||
neither a breakpoint nor a reporter line can target a single row.
|
||||
|
||||
- Case objects use the same field vocabulary everywhere: `name`, `input`,
|
||||
`expected`, and the title interpolates them (`"rejects $name"`).
|
||||
- Always `it.for`, never `it.each`: `.for` passes the test context as the
|
||||
second callback argument, which the concurrency rule below requires.
|
||||
- Hoist bulky case data into named builders above the table.
|
||||
|
||||
### Mocks & path coverage
|
||||
|
||||
- **Every exported function is covered** — a module's exports are its
|
||||
testable contract.
|
||||
- **Every path gets a test**: the success path and each failure path,
|
||||
including one per collaborator that can fail. Name them after the
|
||||
observable behaviour (`it("propagates a failed content fetch")`).
|
||||
- **Dependencies are injected, not module-mocked.** The factory pattern above
|
||||
exists so a test can pass a stub; module mocks are file-level singletons
|
||||
that cannot vary per test and force suites out of concurrent execution.
|
||||
[reporting.test.ts](src/reporting.test.ts) is the one exception, and shows
|
||||
what one costs: sentry is a global reporter rather than an injected
|
||||
dependency, so asserting that a failure reaches it needs
|
||||
`vi.mock("@sentry/node")`, which in turn needs `{ concurrent: false }` on
|
||||
every describe that counts its calls and a `beforeEach` clearing it —
|
||||
vitest's `restoreMocks` does not reset a hand-made `vi.fn()`. Everywhere
|
||||
else, `reported` and `bestEffort` are asserted through what they do to the
|
||||
caller (rethrow or swallow), not through sentry.
|
||||
- **No real IO.** No network, no database, no redis, no port. `createAuth` is
|
||||
exercised against a lazily-constructed pg pool that is never dialled, and
|
||||
everything else takes a stub.
|
||||
- **One act per test**: arrange once, invoke the unit once, then assert as
|
||||
many facets of that single outcome as needed. Needing a second invocation
|
||||
is the real "and" smell — split the test.
|
||||
- **Account for every injected dependency**: assert the calls that must have
|
||||
happened (count and arguments) *and* the zero counts of the ones that must
|
||||
not. `expect(core.fetchBranches).toHaveBeenCalledTimes(0)` after a
|
||||
rejected connection is as load-bearing as any positive assertion.
|
||||
- Test through the module's public exports. Never export something solely for
|
||||
tests — an internal complex enough to need direct tests is extracted into
|
||||
its own module (which is how `createOrganizationHooks` came to exist).
|
||||
- Plain `expect` for guards and preconditions; `expect.soft` is permitted in
|
||||
a final outcome-accounting block, where it reports the whole broken
|
||||
accounting at once.
|
||||
- A branch that genuinely cannot be reproduced carries a `// NOCOV: <lowercase
|
||||
reason>.` comment as the first line inside the branch body. NOCOV is a
|
||||
reviewer covenant, not a tool directive — it is deliberately not wired to
|
||||
the coverage provider (whose ignore hints need the banned `/* */` comment
|
||||
form), so coverage reports stay truthful and the marker explains the
|
||||
residual gap in place. It never excuses a testable path.
|
||||
|
||||
### Independence & concurrency
|
||||
|
||||
- **Every test is completely independent**: it creates its own fresh state (a
|
||||
new `Y.Doc`, a new stub, a new app) and never depends on execution order.
|
||||
- **All tests run concurrently** (`sequence.concurrent: true`). Under
|
||||
concurrency, assertions must use the `expect` from the local test context —
|
||||
`it("…", ({ expect }) => …)` — because the global `expect` cannot reliably
|
||||
attribute a failure to the right test when tests interleave. This applies
|
||||
to every test in the suite, `it.for` rows included.
|
||||
- Mock and global state cannot leak: the config sets `restoreMocks`,
|
||||
`unstubGlobals`, and `unstubEnvs`, so spies, stubbed globals, and env stubs
|
||||
are restored after every test without per-file cleanup.
|
||||
|
||||
### Determinism
|
||||
|
||||
- No `setTimeout`-based waiting. Await concrete signals — a promise the unit
|
||||
returns, an emitted event.
|
||||
- Time is driven, not waited on: `vi.useFakeTimers()` +
|
||||
`advanceTimersByTime()`, `vi.setSystemTime()` for deterministic timestamps.
|
||||
Assert exact values, never wall-clock deltas.
|
||||
|
||||
### Coverage
|
||||
|
||||
`pnpm test` runs with coverage and **fails under the thresholds** in
|
||||
[vitest.config.ts](vitest.config.ts). They are set from the measured baseline
|
||||
and ratcheted up as suites grow — **never lowered**. 100% is not the target:
|
||||
genuinely untestable branches stay visible in the report, marked with NOCOV
|
||||
in place. `src/index.ts` is excluded because evaluating it *is* starting the
|
||||
service; what it wires together is covered through the factories it calls.
|
||||
|
||||
## Yjs
|
||||
|
||||
Read the document-storage section of [server/CLAUDE.md](../CLAUDE.md) before
|
||||
touching branch content. The rule that matters most here: **never seed or
|
||||
merge one Y.Doc from another with `Y.applyUpdate`** — the differing
|
||||
`clientID`s CRDT-merge and silently duplicate every block. Use
|
||||
`replaceYdocContent` ([src/ydocument.ts](src/ydocument.ts)), and persist
|
||||
`rawContent` immediately after replacing it, so a restart cannot rebuild the
|
||||
document under a new clientID. Both invariants are pinned by tests; if you
|
||||
change that code, the tests in `ydocument.test.ts`, `hocuspocus.test.ts` and
|
||||
`routes.test.ts` are the specification.
|
||||
|
||||
## Better Auth schema
|
||||
|
||||
The core migrations own all tables, including Better Auth's. The generated
|
||||
`sql/better_auth_schema.sql` is **reference output only — never apply it
|
||||
directly**; regenerate it to diff what Better Auth expects after changing
|
||||
`src/auth.ts`. See the "Database" section of [server/CLAUDE.md](../CLAUDE.md).
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
17
server/auth-realtime/knip.ts
Normal file
17
server/auth-realtime/knip.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { KnipConfig } from "knip"
|
||||
|
||||
const config: KnipConfig = {
|
||||
project: ["src/**/*.ts"],
|
||||
|
||||
// a type exported only so another export in the same file can name it
|
||||
// — a union member, a table in the Database map, a dependency bag on a
|
||||
// factory — is part of that file's contract, not a dead export. Values
|
||||
// are deliberately left out: an exported const nobody imports is worth
|
||||
// hearing about.
|
||||
ignoreExportsUsedInFile: {
|
||||
interface: true,
|
||||
type: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
|
|
@ -3,16 +3,22 @@
|
|||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.3",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --import ./src/instrument.ts src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node --import ./dist/instrument.js dist/index.js",
|
||||
"dev": "tsx watch --import ./src/sentry.ts src/index.ts",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"start": "node --import ./dist/sentry.js dist/index.js",
|
||||
"check-types": "tsc --noEmit",
|
||||
"lint": "eslint --fix .",
|
||||
"check-lint": "eslint .",
|
||||
"eslint": "eslint --fix --max-warnings 0 --cache --cache-location node_modules/.cache/eslint --cache-strategy content .",
|
||||
"check-eslint": "eslint --max-warnings 0 --cache --cache-location node_modules/.cache/eslint --cache-strategy content .",
|
||||
"fmt": "prettier --write .",
|
||||
"check-fmt": "prettier --check .",
|
||||
"qa": "pnpm run check-types && pnpm run check-lint && pnpm run check-fmt",
|
||||
"qa-fix": "pnpm run check-types && pnpm run lint && pnpm run fmt"
|
||||
"knip": "knip --fix --allow-remove-files --cache",
|
||||
"check-knip": "knip --cache",
|
||||
"test": "vitest run --coverage",
|
||||
"test-watch": "vitest",
|
||||
"lint": "pnpm run check-types && pnpm run knip && pnpm run eslint && pnpm run fmt",
|
||||
"check-lint": "pnpm run check-types && pnpm run check-eslint && pnpm run check-fmt && pnpm run check-knip",
|
||||
"qa": "pnpm run check-lint && pnpm run test",
|
||||
"qa-fix": "pnpm run lint && pnpm run test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/electron": "^1.7.1",
|
||||
|
|
@ -48,17 +54,22 @@
|
|||
"nanoid": "^5.1.6",
|
||||
"pg": "^8.16.3",
|
||||
"redis": "^5.8.3",
|
||||
"yjs": "^13.6.27"
|
||||
"yjs": "^13.6.27",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/node": "^20.11.17",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/eslint-plugin": "^1.6.27",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"knip": "^6.32.2",
|
||||
"prettier": "3.6.2",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.40.0"
|
||||
"typescript-eslint": "^8.40.0",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
760
server/auth-realtime/src/auth.test.ts
Normal file
760
server/auth-realtime/src/auth.test.ts
Normal file
|
|
@ -0,0 +1,760 @@
|
|||
import { describe, it, vi } from "vitest"
|
||||
import {
|
||||
authMethods,
|
||||
createAuth,
|
||||
createOrganizationHooks,
|
||||
createSecondaryStorage,
|
||||
electronCallbackOverride,
|
||||
invitationLink,
|
||||
organizationClaims,
|
||||
toPublicAuthUrl,
|
||||
type SecondaryStorageClient,
|
||||
} from "./auth.js"
|
||||
import { createDatabase, type Store } from "./db.js"
|
||||
import { stubCore, stubStore, testEnv, type StubCore } from "./test-helpers.js"
|
||||
|
||||
function stubRedis() {
|
||||
return {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
getDel: vi.fn().mockResolvedValue(null),
|
||||
incr: vi.fn().mockResolvedValue(1),
|
||||
expire: vi.fn().mockResolvedValue(1),
|
||||
set: vi.fn().mockResolvedValue("OK"),
|
||||
del: vi.fn().mockResolvedValue(1),
|
||||
}
|
||||
}
|
||||
|
||||
const GOOGLE = {
|
||||
clientId: "google-id",
|
||||
clientSecret: "google-secret",
|
||||
}
|
||||
|
||||
// a real auth instance, built against a lazy pg pool that is never dialled.
|
||||
// Its `options` are what better-auth would invoke at runtime, so the
|
||||
// callbacks are asserted exactly as wired.
|
||||
function buildAuth(
|
||||
overrides: {
|
||||
env?: Parameters<typeof createAuth>[0]["env"]
|
||||
core?: StubCore
|
||||
redis?: SecondaryStorageClient
|
||||
store?: Store
|
||||
} = {},
|
||||
) {
|
||||
const real = createDatabase("postgresql://u:p@localhost/d")
|
||||
const core = overrides.core ?? stubCore()
|
||||
const auth = createAuth({
|
||||
env: overrides.env ?? testEnv(),
|
||||
store: overrides.store ?? stubStore(),
|
||||
dialect: real.dialect,
|
||||
redis: overrides.redis ?? stubRedis(),
|
||||
core,
|
||||
})
|
||||
|
||||
return { auth, core }
|
||||
}
|
||||
|
||||
describe("authMethods", () => {
|
||||
it("always offers email and password", ({ expect }) => {
|
||||
expect(authMethods(testEnv())).toEqual(["email-password"])
|
||||
})
|
||||
|
||||
it("offers each provider that has credentials", ({ expect }) => {
|
||||
const methods = authMethods(
|
||||
testEnv({
|
||||
socialProviders: {
|
||||
google: GOOGLE,
|
||||
slack: {
|
||||
clientId: "slack-id",
|
||||
clientSecret: "slack-secret",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(methods).toEqual(["email-password", "google", "slack"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("toPublicAuthUrl", () => {
|
||||
// better-auth builds links from the bare origin, but the browser can
|
||||
// only reach this service behind the proxy's /auth-realtime prefix
|
||||
it("rewrites a link built on the bare origin onto the public prefix", ({
|
||||
expect,
|
||||
}) => {
|
||||
const url = toPublicAuthUrl(
|
||||
testEnv(),
|
||||
"http://localhost:8080/api/auth/reset-password/tok",
|
||||
)
|
||||
|
||||
expect(url).toBe(
|
||||
"http://localhost:8080/auth-realtime/api/auth/reset-password/tok",
|
||||
)
|
||||
})
|
||||
|
||||
it("leaves a link on another origin alone", ({ expect }) => {
|
||||
const url = toPublicAuthUrl(testEnv(), "http://elsewhere/reset")
|
||||
|
||||
expect(url).toBe("http://elsewhere/reset")
|
||||
})
|
||||
})
|
||||
|
||||
describe("electronCallbackOverride", () => {
|
||||
const electronContext = {
|
||||
path: "/sign-in/social",
|
||||
query: { client_id: "electron" },
|
||||
body: { provider: "google" },
|
||||
}
|
||||
|
||||
// the electron plugin sends no callbackURL, so better-auth would
|
||||
// redirect to the server root after the OAuth callback
|
||||
it("redirects the desktop OAuth proxy to the handoff page", ({
|
||||
expect,
|
||||
}) => {
|
||||
const override = electronCallbackOverride(
|
||||
electronContext,
|
||||
"http://localhost:8080",
|
||||
)
|
||||
|
||||
expect(override).toEqual({
|
||||
context: {
|
||||
body: {
|
||||
provider: "google",
|
||||
callbackURL:
|
||||
"http://localhost:8080/desktop-auth",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
name: "another path",
|
||||
input: { ...electronContext, path: "/sign-in/email" },
|
||||
},
|
||||
{
|
||||
name: "a non-electron client",
|
||||
input: {
|
||||
...electronContext,
|
||||
query: { client_id: "web" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no query at all",
|
||||
input: { ...electronContext, query: null },
|
||||
},
|
||||
{
|
||||
name: "a callback the caller already chose",
|
||||
input: {
|
||||
...electronContext,
|
||||
body: {
|
||||
provider: "google",
|
||||
callbackURL:
|
||||
"http://localhost:8080/back",
|
||||
},
|
||||
},
|
||||
},
|
||||
])("leaves $name untouched", ({ input }, { expect }) => {
|
||||
expect(
|
||||
electronCallbackOverride(
|
||||
input,
|
||||
"http://localhost:8080",
|
||||
),
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("invitationLink", () => {
|
||||
it("carries everything the accept-invite page renders before sign-in", ({
|
||||
expect,
|
||||
}) => {
|
||||
const link = invitationLink(testEnv(), {
|
||||
id: "inv-1",
|
||||
email: "a@b.c",
|
||||
inviter: { user: { name: "Ada" } },
|
||||
organization: { id: "org-1", name: "Acme" },
|
||||
})
|
||||
|
||||
expect(link).toBe(
|
||||
"http://localhost:8080/accept-invite?id=inv-1&email=a@b.c&inviter=Ada&orgName=Acme&orgId=org-1",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("organizationClaims", () => {
|
||||
it("binds the token to the user's organization", async ({ expect }) => {
|
||||
const store = stubStore()
|
||||
store.userOrganizationId.mockResolvedValue("org-1")
|
||||
|
||||
expect(
|
||||
await organizationClaims(store, { id: "user-1" }),
|
||||
).toEqual({ org_id: "org-1" })
|
||||
})
|
||||
|
||||
it("claims nothing for a user in no organization", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const store = stubStore()
|
||||
store.userOrganizationId.mockResolvedValue(null)
|
||||
|
||||
expect(
|
||||
await organizationClaims(store, { id: "user-1" }),
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it("claims nothing when the token has no user", async ({ expect }) => {
|
||||
const store = stubStore()
|
||||
|
||||
expect(await organizationClaims(store, null)).toEqual({})
|
||||
expect(store.userOrganizationId).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createOrganizationHooks", () => {
|
||||
describe("canCreateOrganization", () => {
|
||||
it.for([
|
||||
{ name: "below the limit", input: 99, expected: true },
|
||||
{ name: "at the limit", input: 100, expected: false },
|
||||
{ name: "past the limit", input: 101, expected: false },
|
||||
])(
|
||||
"answers $expected with the count $name",
|
||||
async ({ input, expected }, { expect }) => {
|
||||
const store = stubStore()
|
||||
store.totalOrganizationCount.mockResolvedValue(
|
||||
input,
|
||||
)
|
||||
const hooks = createOrganizationHooks({
|
||||
env: testEnv({ maxOrganizations: 100 }),
|
||||
store,
|
||||
core: stubCore(),
|
||||
})
|
||||
|
||||
expect(
|
||||
await hooks.canCreateOrganization(),
|
||||
).toBe(expected)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe("afterCreateOrganization", () => {
|
||||
it("asks core to initialize the new organization", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = createOrganizationHooks({
|
||||
env: testEnv(),
|
||||
store: stubStore(),
|
||||
core,
|
||||
})
|
||||
|
||||
await hooks.afterCreateOrganization({
|
||||
organization: { id: "org-1" },
|
||||
})
|
||||
|
||||
expect(
|
||||
core.initializeOrganization,
|
||||
).toHaveBeenCalledWith("org-1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("beforeDeleteOrganization", () => {
|
||||
it("tears the organization down in core first", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = createOrganizationHooks({
|
||||
env: testEnv(),
|
||||
store: stubStore(),
|
||||
core,
|
||||
})
|
||||
|
||||
await hooks.beforeDeleteOrganization({
|
||||
organization: { id: "org-1" },
|
||||
})
|
||||
|
||||
expect(core.teardownOrganization).toHaveBeenCalledWith(
|
||||
"org-1",
|
||||
)
|
||||
})
|
||||
|
||||
// better-auth aborts the deletion on a throw, which is what
|
||||
// keeps a half-torn-down organization from being deleted and
|
||||
// orphaning everything core owns for it
|
||||
it("propagates a failed teardown so the organization survives", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const failure = new Error("core unreachable")
|
||||
const core = stubCore()
|
||||
core.teardownOrganization.mockRejectedValue(failure)
|
||||
const hooks = createOrganizationHooks({
|
||||
env: testEnv(),
|
||||
store: stubStore(),
|
||||
core,
|
||||
})
|
||||
|
||||
await expect(
|
||||
hooks.beforeDeleteOrganization({
|
||||
organization: { id: "org-1" },
|
||||
}),
|
||||
).rejects.toBe(failure)
|
||||
})
|
||||
})
|
||||
|
||||
describe("sendInvitationEmail", () => {
|
||||
it("emails the invitee a link to the organization's invite page", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = createOrganizationHooks({
|
||||
env: testEnv(),
|
||||
store: stubStore(),
|
||||
core,
|
||||
})
|
||||
|
||||
await hooks.sendInvitationEmail({
|
||||
id: "inv-1",
|
||||
email: "a@b.c",
|
||||
inviter: { user: { name: "Ada" } },
|
||||
organization: { id: "org-1", name: "Acme" },
|
||||
})
|
||||
|
||||
expect(core.sendEmail).toHaveBeenCalledWith(
|
||||
"organization_invitation",
|
||||
{
|
||||
email: "a@b.c",
|
||||
organization: "Acme",
|
||||
link: "http://localhost:8080/accept-invite?id=inv-1&email=a@b.c&inviter=Ada&orgName=Acme&orgId=org-1",
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createSecondaryStorage", () => {
|
||||
it("reads through to the redis client", async ({ expect }) => {
|
||||
const redis = stubRedis()
|
||||
redis.get.mockResolvedValue("value")
|
||||
|
||||
expect(await createSecondaryStorage(redis).get("key")).toBe(
|
||||
"value",
|
||||
)
|
||||
expect(redis.get).toHaveBeenCalledWith("key")
|
||||
})
|
||||
|
||||
it("deletes on read for getAndDelete", async ({ expect }) => {
|
||||
const redis = stubRedis()
|
||||
redis.getDel.mockResolvedValue("value")
|
||||
|
||||
expect(
|
||||
await createSecondaryStorage(redis).getAndDelete("key"),
|
||||
).toBe("value")
|
||||
expect(redis.getDel).toHaveBeenCalledWith("key")
|
||||
expect(redis.get).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
// NX applies the TTL only to a key that has none, so the window is
|
||||
// fixed from creation and later increments cannot extend it
|
||||
it("expires a counter a fixed window after its first increment", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const redis = stubRedis()
|
||||
redis.incr.mockResolvedValue(3)
|
||||
|
||||
expect(
|
||||
await createSecondaryStorage(redis).increment(
|
||||
"key",
|
||||
60,
|
||||
),
|
||||
).toBe(3)
|
||||
expect(redis.expire).toHaveBeenCalledWith("key", 60, "NX")
|
||||
})
|
||||
|
||||
it("writes with an expiry when given a ttl", async ({ expect }) => {
|
||||
const redis = stubRedis()
|
||||
|
||||
await createSecondaryStorage(redis).set("key", "value", 60)
|
||||
|
||||
expect(redis.set).toHaveBeenCalledWith("key", "value", {
|
||||
EX: 60,
|
||||
})
|
||||
})
|
||||
|
||||
it("writes without an expiry when given no ttl", async ({ expect }) => {
|
||||
const redis = stubRedis()
|
||||
|
||||
await createSecondaryStorage(redis).set("key", "value")
|
||||
|
||||
expect(redis.set).toHaveBeenCalledWith("key", "value")
|
||||
})
|
||||
|
||||
it("deletes the key", async ({ expect }) => {
|
||||
const redis = stubRedis()
|
||||
|
||||
await createSecondaryStorage(redis).delete("key")
|
||||
|
||||
expect(redis.del).toHaveBeenCalledWith("key")
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ name: "get", input: "get" as const },
|
||||
{ name: "getAndDelete", input: "getDel" as const },
|
||||
{ name: "increment", input: "incr" as const },
|
||||
{ name: "set", input: "set" as const },
|
||||
{ name: "delete", input: "del" as const },
|
||||
])(
|
||||
"propagates a redis failure from $name",
|
||||
async ({ input }, { expect }) => {
|
||||
const failure = new Error("redis unreachable")
|
||||
const redis = stubRedis()
|
||||
redis[input].mockRejectedValue(failure)
|
||||
const storage = createSecondaryStorage(redis)
|
||||
|
||||
const calls = {
|
||||
get: () => storage.get("key"),
|
||||
getDel: () => storage.getAndDelete("key"),
|
||||
incr: () => storage.increment("key", 60),
|
||||
set: () => storage.set("key", "value"),
|
||||
del: () => storage.delete("key"),
|
||||
}
|
||||
|
||||
await expect(calls[input]()).rejects.toBe(failure)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe("createAuth", () => {
|
||||
it("matches on the bare origin so the proxy's stripped prefix lines up", ({
|
||||
expect,
|
||||
}) => {
|
||||
const { auth } = buildAuth()
|
||||
|
||||
expect(auth.options.baseURL).toBe("http://localhost:8080")
|
||||
expect(auth.options.basePath).toBe("/api/auth")
|
||||
})
|
||||
|
||||
it("registers no social provider without credentials", ({ expect }) => {
|
||||
const { auth } = buildAuth()
|
||||
|
||||
expect(Object.keys(auth.options.socialProviders)).toEqual([])
|
||||
})
|
||||
|
||||
// a provider registered with half its credentials would send users
|
||||
// into a broken OAuth redirect instead of failing at sign-in
|
||||
it("registers a configured provider with a browser-reachable redirect", ({
|
||||
expect,
|
||||
}) => {
|
||||
const { auth } = buildAuth({
|
||||
env: testEnv({ socialProviders: { google: GOOGLE } }),
|
||||
})
|
||||
|
||||
expect(auth.options.socialProviders.google).toEqual({
|
||||
clientId: "google-id",
|
||||
clientSecret: "google-secret",
|
||||
redirectURI:
|
||||
"http://localhost:8080/auth-realtime/api/auth/callback/google",
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ name: "enabled", input: true, expected: true },
|
||||
{ name: "disabled", input: false, expected: false },
|
||||
])(
|
||||
"leaves rate limiting $name as configured",
|
||||
({ input, expected }, { expect }) => {
|
||||
const { auth } = buildAuth({
|
||||
env: testEnv({ rateLimitEnabled: input }),
|
||||
})
|
||||
|
||||
expect(auth.options.rateLimit.enabled).toBe(expected)
|
||||
},
|
||||
)
|
||||
|
||||
it("trusts the configured origins", ({ expect }) => {
|
||||
const { auth } = buildAuth({
|
||||
env: testEnv({
|
||||
trustedOrigins: [
|
||||
"http://localhost:8080",
|
||||
"oxynote://",
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(auth.options.trustedOrigins).toEqual([
|
||||
"http://localhost:8080",
|
||||
"oxynote://",
|
||||
])
|
||||
})
|
||||
|
||||
describe("email callbacks", () => {
|
||||
it("sends a password reset on the public link", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { auth, core } = buildAuth()
|
||||
|
||||
await auth.options.emailAndPassword.sendResetPassword({
|
||||
user: { email: "a@b.c" },
|
||||
url: "http://localhost:8080/api/auth/reset/tok",
|
||||
token: "tok",
|
||||
} as never)
|
||||
|
||||
expect(core.sendEmail).toHaveBeenCalledWith(
|
||||
"password_reset",
|
||||
{
|
||||
email: "a@b.c",
|
||||
link: "http://localhost:8080/auth-realtime/api/auth/reset/tok",
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// a duplicate signup gets better-auth's synthetic success so a
|
||||
// browser cannot probe which addresses have accounts; the real
|
||||
// owner is told through their inbox
|
||||
it("tells the existing owner rather than the browser about a duplicate signup", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { auth, core } = buildAuth()
|
||||
|
||||
await auth.options.emailAndPassword.onExistingUserSignUp(
|
||||
{ user: { email: "a@b.c" } } as never,
|
||||
)
|
||||
|
||||
expect(core.sendEmail).toHaveBeenCalledWith(
|
||||
"account_exists",
|
||||
{
|
||||
email: "a@b.c",
|
||||
link: "http://localhost:8080/login",
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("sends the signup verification on the public link", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { auth, core } = buildAuth()
|
||||
|
||||
await auth.options.emailVerification.sendVerificationEmail(
|
||||
{
|
||||
user: { email: "a@b.c" },
|
||||
url: "http://localhost:8080/api/auth/verify/tok",
|
||||
token: "tok",
|
||||
} as never,
|
||||
)
|
||||
|
||||
expect(core.sendEmail).toHaveBeenCalledWith(
|
||||
"signup_verification",
|
||||
{
|
||||
email: "a@b.c",
|
||||
link: "http://localhost:8080/auth-realtime/api/auth/verify/tok",
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("confirms a change of address at the new address", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { auth, core } = buildAuth()
|
||||
|
||||
await auth.options.user.changeEmail.sendChangeEmailConfirmation(
|
||||
{
|
||||
user: { email: "old@b.c" },
|
||||
newEmail: "new@b.c",
|
||||
url: "http://localhost:8080/api/auth/change/tok",
|
||||
token: "tok",
|
||||
} as never,
|
||||
)
|
||||
|
||||
expect(core.sendEmail).toHaveBeenCalledWith(
|
||||
"email_verification",
|
||||
{
|
||||
email: "new@b.c",
|
||||
link: "http://localhost:8080/auth-realtime/api/auth/change/tok",
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("confirms an account deletion by email", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { auth, core } = buildAuth()
|
||||
|
||||
await auth.options.user.deleteUser.sendDeleteAccountVerification(
|
||||
{
|
||||
user: { email: "a@b.c" },
|
||||
url: "http://localhost:8080/api/auth/delete/tok",
|
||||
token: "tok",
|
||||
} as never,
|
||||
)
|
||||
|
||||
expect(core.sendEmail).toHaveBeenCalledWith(
|
||||
"user_deletion",
|
||||
{
|
||||
email: "a@b.c",
|
||||
link: "http://localhost:8080/auth-realtime/api/auth/delete/tok",
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("welcomes a newly created user", async ({ expect }) => {
|
||||
const { auth, core } = buildAuth()
|
||||
|
||||
await auth.options.databaseHooks.user.create.after({
|
||||
email: "a@b.c",
|
||||
} as never)
|
||||
|
||||
expect(core.sendEmail).toHaveBeenCalledWith(
|
||||
"user_creation",
|
||||
{ email: "a@b.c" },
|
||||
)
|
||||
})
|
||||
|
||||
it("propagates a failed send so better-auth fails the request", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const failure = new Error("core unreachable")
|
||||
const core = stubCore()
|
||||
core.sendEmail.mockRejectedValue(failure)
|
||||
const { auth } = buildAuth({ core })
|
||||
|
||||
await expect(
|
||||
auth.options.emailAndPassword.sendResetPassword(
|
||||
{
|
||||
user: { email: "a@b.c" },
|
||||
url: "http://localhost:8080/api/auth/reset/tok",
|
||||
token: "tok",
|
||||
} as never,
|
||||
),
|
||||
).rejects.toBe(failure)
|
||||
})
|
||||
})
|
||||
|
||||
// core scopes every request by the session's organization, so a
|
||||
// session that reaches the database without one is unusable
|
||||
describe("session organization binding", () => {
|
||||
it("stamps the user's organization onto a new session", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const store = stubStore()
|
||||
store.userOrganizationId.mockResolvedValue("org-1")
|
||||
const { auth } = buildAuth({ store })
|
||||
|
||||
const result =
|
||||
await auth.options.databaseHooks.session.create.before(
|
||||
{
|
||||
userId: "user-1",
|
||||
token: "t",
|
||||
} as never,
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
data: {
|
||||
userId: "user-1",
|
||||
activeOrganizationId: "org-1",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("stamps null when the user belongs to no organization", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const store = stubStore()
|
||||
store.userOrganizationId.mockResolvedValue(null)
|
||||
const { auth } = buildAuth({ store })
|
||||
|
||||
const result =
|
||||
await auth.options.databaseHooks.session.create.before(
|
||||
{
|
||||
userId: "user-1",
|
||||
token: "t",
|
||||
} as never,
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
data: { activeOrganizationId: null },
|
||||
})
|
||||
})
|
||||
|
||||
it("re-resolves the organization when a session is updated", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const store = stubStore()
|
||||
store.userOrganizationId.mockResolvedValue("org-2")
|
||||
const { auth } = buildAuth({ store })
|
||||
|
||||
const result =
|
||||
await auth.options.databaseHooks.session.update.before(
|
||||
{ token: "t" } as never,
|
||||
{
|
||||
context: {
|
||||
session: {
|
||||
user: {
|
||||
id: "user-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
data: { activeOrganizationId: "org-2" },
|
||||
})
|
||||
})
|
||||
|
||||
it("leaves an update with no session context untouched", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const store = stubStore()
|
||||
const { auth } = buildAuth({ store })
|
||||
|
||||
const result =
|
||||
await auth.options.databaseHooks.session.update.before(
|
||||
{ token: "t" } as never,
|
||||
undefined as never,
|
||||
)
|
||||
|
||||
expect(result).toEqual({ data: { token: "t" } })
|
||||
expect(store.userOrganizationId).toHaveBeenCalledTimes(
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
it("propagates a failed organization lookup", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const store = stubStore()
|
||||
store.userOrganizationId.mockRejectedValue(
|
||||
new Error("connection terminated"),
|
||||
)
|
||||
const { auth } = buildAuth({ store })
|
||||
|
||||
await expect(
|
||||
auth.options.databaseHooks.session.create.before(
|
||||
{
|
||||
userId: "user-1",
|
||||
token: "t",
|
||||
} as never,
|
||||
),
|
||||
).rejects.toThrow("connection terminated")
|
||||
})
|
||||
})
|
||||
|
||||
describe("password policy", () => {
|
||||
it("requires a verified address and a long passphrase", ({
|
||||
expect,
|
||||
}) => {
|
||||
const { auth } = buildAuth()
|
||||
|
||||
expect(
|
||||
auth.options.emailAndPassword
|
||||
.requireEmailVerification,
|
||||
).toBe(true)
|
||||
expect(
|
||||
auth.options.emailAndPassword.minPasswordLength,
|
||||
).toBe(16)
|
||||
// a reset happens outside any session, so every session
|
||||
// of the account goes with it
|
||||
expect(
|
||||
auth.options.emailAndPassword
|
||||
.revokeSessionsOnPasswordReset,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
257
server/auth-realtime/src/core.test.ts
Normal file
257
server/auth-realtime/src/core.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { describe, it, vi } from "vitest"
|
||||
import { AxiosHeaders } from "axios"
|
||||
import { createCoreClient, type HttpResponse } from "./core.js"
|
||||
|
||||
const BASE_URL = "http://core:8080"
|
||||
|
||||
function stubHttp(response: HttpResponse = { status: 200, data: null }) {
|
||||
return {
|
||||
get: vi.fn().mockResolvedValue(response),
|
||||
post: vi.fn().mockResolvedValue(response),
|
||||
put: vi.fn().mockResolvedValue(response),
|
||||
}
|
||||
}
|
||||
|
||||
describe("createCoreClient", () => {
|
||||
describe("sendEmail", () => {
|
||||
it("posts the template and its data to core's internal email endpoint", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const http = stubHttp()
|
||||
|
||||
await createCoreClient(BASE_URL, http).sendEmail(
|
||||
"password_reset",
|
||||
{ email: "a@b.c", link: "http://link" },
|
||||
)
|
||||
|
||||
expect(http.post).toHaveBeenCalledTimes(1)
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
"http://core:8080/api/x/email",
|
||||
{
|
||||
template: "password_reset",
|
||||
data: {
|
||||
email: "a@b.c",
|
||||
link: "http://link",
|
||||
},
|
||||
},
|
||||
)
|
||||
expect.soft(http.get).toHaveBeenCalledTimes(0)
|
||||
expect.soft(http.put).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("propagates the transport's rejection untouched", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const failure = new Error("connect ECONNREFUSED")
|
||||
const http = stubHttp()
|
||||
http.post.mockRejectedValue(failure)
|
||||
|
||||
await expect(
|
||||
createCoreClient(BASE_URL, http).sendEmail(
|
||||
"user_creation",
|
||||
{ email: "a@b.c" },
|
||||
),
|
||||
).rejects.toBe(failure)
|
||||
})
|
||||
})
|
||||
|
||||
describe("initializeOrganization", () => {
|
||||
it("posts to the organization's initialize endpoint", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const http = stubHttp()
|
||||
|
||||
await createCoreClient(
|
||||
BASE_URL,
|
||||
http,
|
||||
).initializeOrganization("org-1")
|
||||
|
||||
expect(http.post).toHaveBeenCalledTimes(1)
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
"http://core:8080/api/x/organizations/org-1/initialize",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("teardownOrganization", () => {
|
||||
it("posts to the organization's teardown endpoint", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const http = stubHttp()
|
||||
|
||||
await createCoreClient(
|
||||
BASE_URL,
|
||||
http,
|
||||
).teardownOrganization("org-1")
|
||||
|
||||
expect(http.post).toHaveBeenCalledTimes(1)
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
"http://core:8080/api/x/organizations/org-1/teardown",
|
||||
)
|
||||
})
|
||||
|
||||
// the organization survives a failed teardown rather than
|
||||
// leaving core's resources orphaned, which only works if the
|
||||
// rejection reaches better-auth's hook
|
||||
it("propagates a failed teardown", async ({ expect }) => {
|
||||
const failure = new Error("teardown failed")
|
||||
const http = stubHttp()
|
||||
http.post.mockRejectedValue(failure)
|
||||
|
||||
await expect(
|
||||
createCoreClient(
|
||||
BASE_URL,
|
||||
http,
|
||||
).teardownOrganization("org-1"),
|
||||
).rejects.toBe(failure)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchBranches", () => {
|
||||
it("returns the branch list core responded with", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const branches = [
|
||||
{ branchId: "branch-1", default: true },
|
||||
{ branchId: "branch-2", default: false },
|
||||
]
|
||||
const http = stubHttp({ status: 200, data: branches })
|
||||
|
||||
const result = await createCoreClient(
|
||||
BASE_URL,
|
||||
http,
|
||||
).fetchBranches("doc-1")
|
||||
|
||||
expect(result).toEqual(branches)
|
||||
expect(http.get).toHaveBeenCalledTimes(1)
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
"http://core:8080/api/x/documents/doc-1/branches",
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it("forwards the caller's headers when given them", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const http = stubHttp({ status: 200, data: [] })
|
||||
const headers = new AxiosHeaders()
|
||||
headers.set("cookie", "auth.session=abc")
|
||||
|
||||
await createCoreClient(BASE_URL, http).fetchBranches(
|
||||
"doc-1",
|
||||
{ headers },
|
||||
)
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
"http://core:8080/api/x/documents/doc-1/branches",
|
||||
{ headers },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchBranchContent", () => {
|
||||
it("returns the branch core responded with", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const branch = {
|
||||
documentName: "Runbook",
|
||||
content: { type: "doc", content: [] },
|
||||
icon: "lucide:file",
|
||||
rawContent: "AAEC",
|
||||
}
|
||||
const http = stubHttp({ status: 200, data: branch })
|
||||
|
||||
const result = await createCoreClient(
|
||||
BASE_URL,
|
||||
http,
|
||||
).fetchBranchContent("doc-1", "branch-1")
|
||||
|
||||
expect(result).toEqual(branch)
|
||||
expect(http.get).toHaveBeenCalledTimes(1)
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
"http://core:8080/api/x/documents/doc-1/branch/branch-1",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeBranchContent", () => {
|
||||
it("puts the update to the branch's internal endpoint", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const http = stubHttp()
|
||||
const update = {
|
||||
name: "Runbook",
|
||||
icon: "lucide:file",
|
||||
content: { type: "doc", content: [] },
|
||||
maintainers: ["user-1"],
|
||||
rawContent: "AAEC",
|
||||
system: false,
|
||||
}
|
||||
|
||||
await createCoreClient(
|
||||
BASE_URL,
|
||||
http,
|
||||
).storeBranchContent("doc-1", "branch-1", update)
|
||||
|
||||
expect(http.put).toHaveBeenCalledTimes(1)
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
"http://core:8080/api/x/documents/doc-1/branch/branch-1",
|
||||
update,
|
||||
)
|
||||
expect.soft(http.get).toHaveBeenCalledTimes(0)
|
||||
expect.soft(http.post).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("mergeBranches", () => {
|
||||
// the merge is the one call that goes to core's session-authed
|
||||
// surface rather than /api/x, because core decides from the
|
||||
// forwarded headers whether the caller may merge
|
||||
it("puts to core's public merge route with the caller's headers", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const http = stubHttp({
|
||||
status: 200,
|
||||
data: {
|
||||
documentName: "Runbook",
|
||||
content: { type: "doc", content: [] },
|
||||
icon: "lucide:file",
|
||||
},
|
||||
})
|
||||
const headers = new AxiosHeaders()
|
||||
headers.set("cookie", "auth.session=abc")
|
||||
|
||||
const result = await createCoreClient(
|
||||
BASE_URL,
|
||||
http,
|
||||
).mergeBranches("doc-1", "branch-2", "branch-1", {
|
||||
headers,
|
||||
})
|
||||
|
||||
expect(http.put).toHaveBeenCalledTimes(1)
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
"http://core:8080/api/documents/doc-1/merge",
|
||||
{
|
||||
fromBranchId: "branch-2",
|
||||
toBranchId: "branch-1",
|
||||
},
|
||||
{ headers },
|
||||
)
|
||||
expect(result.status).toBe(200)
|
||||
expect(result.data.documentName).toBe("Runbook")
|
||||
})
|
||||
|
||||
it("reports the status core answered with", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const http = stubHttp({ status: 409, data: {} })
|
||||
|
||||
const result = await createCoreClient(
|
||||
BASE_URL,
|
||||
http,
|
||||
).mergeBranches("doc-1", "branch-2", "branch-1", {})
|
||||
|
||||
expect(result.status).toBe(409)
|
||||
})
|
||||
})
|
||||
})
|
||||
179
server/auth-realtime/src/core.ts
Normal file
179
server/auth-realtime/src/core.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import axios from "axios"
|
||||
import type { AxiosHeaders } from "axios"
|
||||
|
||||
// the four emails core renders on this service's behalf. Naming them here
|
||||
// keeps a typo in a template name a compile error rather than a silently
|
||||
// dropped email.
|
||||
export type EmailTemplate =
|
||||
| "email_verification"
|
||||
| "password_reset"
|
||||
| "account_exists"
|
||||
| "signup_verification"
|
||||
| "organization_invitation"
|
||||
| "user_deletion"
|
||||
| "user_creation"
|
||||
|
||||
export interface BranchSummary {
|
||||
branchId: string
|
||||
default: boolean
|
||||
}
|
||||
|
||||
export interface BranchContent {
|
||||
documentName: string
|
||||
// prosemirror JSON. Left unknown because nothing on this side
|
||||
// validates it — the editor schema is the only thing that can.
|
||||
content: unknown
|
||||
icon: string
|
||||
// the canonical Yjs state, base64-encoded. Absent on a branch that
|
||||
// has never been opened by an editor.
|
||||
rawContent?: string | null
|
||||
}
|
||||
|
||||
export interface BranchUpdate {
|
||||
name: string
|
||||
icon: string
|
||||
content: unknown
|
||||
maintainers: string[]
|
||||
rawContent: string
|
||||
system: boolean
|
||||
}
|
||||
|
||||
export interface MergedDocument {
|
||||
documentName: string
|
||||
content: unknown
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
headers?: AxiosHeaders
|
||||
}
|
||||
|
||||
export interface HttpResponse {
|
||||
status: number
|
||||
data: unknown
|
||||
}
|
||||
|
||||
// the minimum of axios this service uses. Declaring it structurally lets a
|
||||
// test hand createCoreClient a plain object instead of intercepting a
|
||||
// real axios instance. Response bodies arrive as unknown: this client is
|
||||
// the one place that states what core returns, and it does so by naming
|
||||
// the shape at each call rather than trusting a generic parameter.
|
||||
export interface HttpClient {
|
||||
get(url: string, config?: RequestOptions): Promise<HttpResponse>
|
||||
post(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: RequestOptions,
|
||||
): Promise<HttpResponse>
|
||||
put(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: RequestOptions,
|
||||
): Promise<HttpResponse>
|
||||
}
|
||||
|
||||
// every call this service makes into core. Transport only: a failed
|
||||
// request rejects with the underlying error untouched, because the callers
|
||||
// differ in what they do about it — the merge route forwards core's status
|
||||
// to its own caller, while the hocuspocus store hook swallows it and warns
|
||||
// the connected editors instead.
|
||||
export interface CoreClient {
|
||||
sendEmail(
|
||||
template: EmailTemplate,
|
||||
data: Record<string, string>,
|
||||
): Promise<void>
|
||||
initializeOrganization(organizationId: string): Promise<void>
|
||||
teardownOrganization(organizationId: string): Promise<void>
|
||||
fetchBranches(
|
||||
documentId: string,
|
||||
options?: RequestOptions,
|
||||
): Promise<BranchSummary[]>
|
||||
fetchBranchContent(
|
||||
documentId: string,
|
||||
branchId: string,
|
||||
): Promise<BranchContent>
|
||||
storeBranchContent(
|
||||
documentId: string,
|
||||
branchId: string,
|
||||
update: BranchUpdate,
|
||||
): Promise<void>
|
||||
mergeBranches(
|
||||
documentId: string,
|
||||
fromBranchId: string,
|
||||
toBranchId: string,
|
||||
options: RequestOptions,
|
||||
): Promise<{ status: number; data: MergedDocument }>
|
||||
}
|
||||
|
||||
export function createCoreClient(
|
||||
baseUrl: string,
|
||||
http: HttpClient = axios,
|
||||
): CoreClient {
|
||||
// /api/x is core's internal, sessionless surface — the reverse proxy
|
||||
// blocks it at the front door, so these calls only work from inside
|
||||
// the container network.
|
||||
const internal = `${baseUrl}/api/x`
|
||||
|
||||
return {
|
||||
async sendEmail(template, data) {
|
||||
await http.post(`${internal}/email`, { template, data })
|
||||
},
|
||||
|
||||
async initializeOrganization(organizationId) {
|
||||
await http.post(
|
||||
`${internal}/organizations/${organizationId}/initialize`,
|
||||
)
|
||||
},
|
||||
|
||||
async teardownOrganization(organizationId) {
|
||||
await http.post(
|
||||
`${internal}/organizations/${organizationId}/teardown`,
|
||||
)
|
||||
},
|
||||
|
||||
async fetchBranches(documentId, options) {
|
||||
const response = await http.get(
|
||||
`${internal}/documents/${documentId}/branches`,
|
||||
options,
|
||||
)
|
||||
|
||||
return response.data as BranchSummary[]
|
||||
},
|
||||
|
||||
async fetchBranchContent(documentId, branchId) {
|
||||
const response = await http.get(
|
||||
`${internal}/documents/${documentId}/branch/${branchId}`,
|
||||
)
|
||||
|
||||
return response.data as BranchContent
|
||||
},
|
||||
|
||||
async storeBranchContent(documentId, branchId, update) {
|
||||
await http.put(
|
||||
`${internal}/documents/${documentId}/branch/${branchId}`,
|
||||
update,
|
||||
)
|
||||
},
|
||||
|
||||
// the merge itself goes through core's session-authed surface,
|
||||
// not /api/x: the caller's own headers are forwarded so core
|
||||
// decides whether they may merge.
|
||||
async mergeBranches(
|
||||
documentId,
|
||||
fromBranchId,
|
||||
toBranchId,
|
||||
options,
|
||||
) {
|
||||
const response = await http.put(
|
||||
`${baseUrl}/api/documents/${documentId}/merge`,
|
||||
{ fromBranchId, toBranchId },
|
||||
options,
|
||||
)
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
data: response.data as MergedDocument,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { Pool } from "pg"
|
||||
import { Kysely, PostgresDialect } from "kysely"
|
||||
|
||||
export const dialect = new PostgresDialect({
|
||||
pool: new Pool({
|
||||
connectionString: process.env.OXYNOTE_AUTH_REALTIME_DB_DSN,
|
||||
}),
|
||||
})
|
||||
|
||||
export const db = new Kysely<Database>({
|
||||
dialect,
|
||||
})
|
||||
|
||||
export interface Database {
|
||||
users: UsersTable
|
||||
organizations: OrganizationsTable
|
||||
organization_members: OrganizationMembersTable
|
||||
oauth_consents: OAuthConsentsTable
|
||||
}
|
||||
|
||||
export interface UsersTable {
|
||||
id: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface OrganizationsTable {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface OrganizationMembersTable {
|
||||
fk_user_id: string
|
||||
fk_organization_id: string
|
||||
}
|
||||
|
||||
export interface OAuthConsentsTable {
|
||||
client_id: string
|
||||
fk_user_id: string
|
||||
}
|
||||
201
server/auth-realtime/src/db.test.ts
Normal file
201
server/auth-realtime/src/db.test.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { describe, it, vi } from "vitest"
|
||||
import type { Kysely } from "kysely"
|
||||
import { createStore, type Database } from "./db.js"
|
||||
|
||||
// kysely builds a query by chaining, so the stub returns itself from every
|
||||
// builder call and answers at the terminal one. This is the only file that
|
||||
// needs to know that — everywhere else takes a Store.
|
||||
function stubDb(row: unknown) {
|
||||
const chain = {
|
||||
select: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
executeTakeFirst: vi.fn().mockResolvedValue(row),
|
||||
executeTakeFirstOrThrow: vi.fn().mockResolvedValue(row),
|
||||
}
|
||||
|
||||
const selectFrom = vi.fn(() => chain)
|
||||
const db = {
|
||||
selectFrom,
|
||||
fn: { countAll: () => ({ as: (alias: string) => alias }) },
|
||||
} as unknown as Kysely<Database>
|
||||
|
||||
return { store: createStore(db), chain, selectFrom }
|
||||
}
|
||||
|
||||
describe("createStore", () => {
|
||||
describe("totalOrganizationCount", () => {
|
||||
it("returns the count the query answered with", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { store, selectFrom } = stubDb({ count: 7 })
|
||||
|
||||
expect(await store.totalOrganizationCount()).toBe(7)
|
||||
expect(selectFrom).toHaveBeenCalledWith("organizations")
|
||||
})
|
||||
|
||||
// the count decides whether another organization may be
|
||||
// created, so an unanswered query must not read as zero
|
||||
it("propagates a failed query", async ({ expect }) => {
|
||||
const failure = new Error("connection terminated")
|
||||
const { store, chain } = stubDb(null)
|
||||
chain.executeTakeFirstOrThrow.mockRejectedValue(failure)
|
||||
|
||||
await expect(
|
||||
store.totalOrganizationCount(),
|
||||
).rejects.toBe(failure)
|
||||
})
|
||||
})
|
||||
|
||||
describe("userOrganizationId", () => {
|
||||
it("returns the organization the user belongs to", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { store, chain, selectFrom } = stubDb({
|
||||
fk_organization_id: "org-1",
|
||||
})
|
||||
|
||||
expect(await store.userOrganizationId("user-1")).toBe(
|
||||
"org-1",
|
||||
)
|
||||
expect(selectFrom).toHaveBeenCalledWith(
|
||||
"organization_members",
|
||||
)
|
||||
expect(chain.where).toHaveBeenCalledWith(
|
||||
"fk_user_id",
|
||||
"=",
|
||||
"user-1",
|
||||
)
|
||||
expect(chain.select).toHaveBeenCalledWith(
|
||||
"fk_organization_id",
|
||||
)
|
||||
})
|
||||
|
||||
it("returns null when the user belongs to no organization", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { store } = stubDb(undefined)
|
||||
|
||||
expect(
|
||||
await store.userOrganizationId("user-1"),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("propagates a failed query", async ({ expect }) => {
|
||||
const failure = new Error("connection terminated")
|
||||
const { store, chain } = stubDb(undefined)
|
||||
chain.executeTakeFirst.mockRejectedValue(failure)
|
||||
|
||||
await expect(
|
||||
store.userOrganizationId("user-1"),
|
||||
).rejects.toBe(failure)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasOAuthConsent", () => {
|
||||
it("matches the consent on both the client and the user", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { store, chain, selectFrom } = stubDb({
|
||||
client_id: "client-1",
|
||||
})
|
||||
|
||||
expect(
|
||||
await store.hasOAuthConsent(
|
||||
"client-1",
|
||||
"user-1",
|
||||
),
|
||||
).toBe(true)
|
||||
expect(selectFrom).toHaveBeenCalledWith(
|
||||
"oauth_consents",
|
||||
)
|
||||
expect(chain.where).toHaveBeenCalledWith(
|
||||
"client_id",
|
||||
"=",
|
||||
"client-1",
|
||||
)
|
||||
expect(chain.where).toHaveBeenCalledWith(
|
||||
"fk_user_id",
|
||||
"=",
|
||||
"user-1",
|
||||
)
|
||||
})
|
||||
|
||||
// revoking a client deletes the row, which is what cuts off the
|
||||
// access tokens it already holds
|
||||
it("reports no consent when the row is gone", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { store } = stubDb(undefined)
|
||||
|
||||
expect(
|
||||
await store.hasOAuthConsent(
|
||||
"client-1",
|
||||
"user-1",
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("propagates a failed query", async ({ expect }) => {
|
||||
const failure = new Error("connection terminated")
|
||||
const { store, chain } = stubDb(undefined)
|
||||
chain.executeTakeFirst.mockRejectedValue(failure)
|
||||
|
||||
await expect(
|
||||
store.hasOAuthConsent("client-1", "user-1"),
|
||||
).rejects.toBe(failure)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isOrganizationMember", () => {
|
||||
it("matches the membership on both the user and the organization", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { store, chain, selectFrom } = stubDb({
|
||||
fk_user_id: "user-1",
|
||||
})
|
||||
|
||||
expect(
|
||||
await store.isOrganizationMember(
|
||||
"user-1",
|
||||
"org-1",
|
||||
),
|
||||
).toBe(true)
|
||||
expect(selectFrom).toHaveBeenCalledWith(
|
||||
"organization_members",
|
||||
)
|
||||
expect(chain.where).toHaveBeenCalledWith(
|
||||
"fk_user_id",
|
||||
"=",
|
||||
"user-1",
|
||||
)
|
||||
expect(chain.where).toHaveBeenCalledWith(
|
||||
"fk_organization_id",
|
||||
"=",
|
||||
"org-1",
|
||||
)
|
||||
})
|
||||
|
||||
it("reports no membership when the user has left", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { store } = stubDb(undefined)
|
||||
|
||||
expect(
|
||||
await store.isOrganizationMember(
|
||||
"user-1",
|
||||
"org-1",
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("propagates a failed query", async ({ expect }) => {
|
||||
const failure = new Error("connection terminated")
|
||||
const { store, chain } = stubDb(undefined)
|
||||
chain.executeTakeFirst.mockRejectedValue(failure)
|
||||
|
||||
await expect(
|
||||
store.isOrganizationMember("user-1", "org-1"),
|
||||
).rejects.toBe(failure)
|
||||
})
|
||||
})
|
||||
})
|
||||
116
server/auth-realtime/src/db.ts
Normal file
116
server/auth-realtime/src/db.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { Pool } from "pg"
|
||||
import { Kysely, PostgresDialect } from "kysely"
|
||||
|
||||
// the columns this service reads. Better Auth owns writing these tables and
|
||||
// core's migrations own creating them; what is declared here is only what a
|
||||
// query below touches.
|
||||
export interface Database {
|
||||
users: UsersTable
|
||||
organizations: OrganizationsTable
|
||||
organization_members: OrganizationMembersTable
|
||||
oauth_consents: OAuthConsentsTable
|
||||
}
|
||||
|
||||
export interface UsersTable {
|
||||
id: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface OrganizationsTable {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface OrganizationMembersTable {
|
||||
fk_user_id: string
|
||||
fk_organization_id: string
|
||||
}
|
||||
|
||||
export interface OAuthConsentsTable {
|
||||
client_id: string
|
||||
fk_user_id: string
|
||||
}
|
||||
|
||||
// every query this service runs, named by what it answers rather than by
|
||||
// the SQL it builds. Nothing outside this module holds a query builder, so
|
||||
// a table or column rename is a change to one file, and a caller can be
|
||||
// tested against four stubbed methods instead of a mocked chain.
|
||||
export interface Store {
|
||||
totalOrganizationCount(): Promise<number>
|
||||
userOrganizationId(userId: string): Promise<string | null>
|
||||
hasOAuthConsent(clientId: string, userId: string): Promise<boolean>
|
||||
isOrganizationMember(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
): Promise<boolean>
|
||||
}
|
||||
|
||||
// better-auth needs the dialect rather than the query builder: handing it
|
||||
// the Kysely instance alone leaves the CLI's schema generator unable to
|
||||
// tell which database it is talking to.
|
||||
export interface DatabaseHandle {
|
||||
store: Store
|
||||
dialect: PostgresDialect
|
||||
}
|
||||
|
||||
export function createStore(db: Kysely<Database>): Store {
|
||||
return {
|
||||
async totalOrganizationCount() {
|
||||
const res = await db
|
||||
.selectFrom("organizations")
|
||||
.select(db.fn.countAll<number>().as("count"))
|
||||
.executeTakeFirstOrThrow()
|
||||
|
||||
return res.count
|
||||
},
|
||||
|
||||
// a user belongs to at most one organization, which is what
|
||||
// makes "the user's organization" a single value everywhere
|
||||
// else in the service.
|
||||
async userOrganizationId(userId) {
|
||||
const res = await db
|
||||
.selectFrom("organization_members")
|
||||
.where("fk_user_id", "=", userId)
|
||||
.select("fk_organization_id")
|
||||
.executeTakeFirst()
|
||||
|
||||
return res ? res.fk_organization_id : null
|
||||
},
|
||||
|
||||
async hasOAuthConsent(clientId, userId) {
|
||||
const res = await db
|
||||
.selectFrom("oauth_consents")
|
||||
.where("client_id", "=", clientId)
|
||||
.where("fk_user_id", "=", userId)
|
||||
.select("client_id")
|
||||
.executeTakeFirst()
|
||||
|
||||
return res !== undefined
|
||||
},
|
||||
|
||||
async isOrganizationMember(userId, organizationId) {
|
||||
const res = await db
|
||||
.selectFrom("organization_members")
|
||||
.where("fk_user_id", "=", userId)
|
||||
.where(
|
||||
"fk_organization_id",
|
||||
"=",
|
||||
organizationId,
|
||||
)
|
||||
.select("fk_user_id")
|
||||
.executeTakeFirst()
|
||||
|
||||
return res !== undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createDatabase(dsn: string): DatabaseHandle {
|
||||
const dialect = new PostgresDialect({
|
||||
pool: new Pool({ connectionString: dsn }),
|
||||
})
|
||||
|
||||
return {
|
||||
store: createStore(new Kysely<Database>({ dialect })),
|
||||
dialect,
|
||||
}
|
||||
}
|
||||
301
server/auth-realtime/src/env.test.ts
Normal file
301
server/auth-realtime/src/env.test.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import { describe, it } from "vitest"
|
||||
import { loadEnv } from "./env.js"
|
||||
|
||||
// a complete, valid environment. Each test overrides only the variables it
|
||||
// is about, so the rest can never be the reason a case passes or fails.
|
||||
function completeEnv(
|
||||
overrides: Record<string, string | undefined> = {},
|
||||
): Record<string, string | undefined> {
|
||||
return {
|
||||
OXYNOTE_AUTH_REALTIME_BACKEND_URL: "http://core:8080",
|
||||
OXYNOTE_AUTH_REALTIME_DB_DSN:
|
||||
"postgresql://devuser:devpass@postgres/devdb",
|
||||
OXYNOTE_AUTH_REALTIME_VALKEY_URL: "redis://valkey:6379",
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_BASE_URL:
|
||||
"http://localhost:8080/auth-realtime",
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SECRET: "sup3rs3cr3t",
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_COOKIE_DOMAIN: "localhost",
|
||||
OXYNOTE_AUTH_REALTIME_FRONTEND_URL: "http://localhost:8080",
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_ORGANIZATION_INVITATION_URL:
|
||||
"http://localhost:8080/accept-invite",
|
||||
OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS:
|
||||
"http://localhost:8080,http://localhost:3000",
|
||||
OXYNOTE_AUTH_REALTIME_MCP_RESOURCE:
|
||||
"http://localhost:8080/core/api/mcp",
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("loadEnv", () => {
|
||||
it("maps every required variable onto the typed configuration", ({
|
||||
expect,
|
||||
}) => {
|
||||
const env = loadEnv(completeEnv())
|
||||
|
||||
expect(env.coreUrl).toBe("http://core:8080")
|
||||
expect(env.databaseDSN).toBe(
|
||||
"postgresql://devuser:devpass@postgres/devdb",
|
||||
)
|
||||
expect(env.valkeyUrl).toBe("redis://valkey:6379")
|
||||
expect(env.betterAuthSecret).toBe("sup3rs3cr3t")
|
||||
expect(env.cookieDomain).toBe("localhost")
|
||||
expect(env.frontendUrl).toBe("http://localhost:8080")
|
||||
expect(env.organizationInvitationUrl).toBe(
|
||||
"http://localhost:8080/accept-invite",
|
||||
)
|
||||
expect(env.mcpResource).toBe(
|
||||
"http://localhost:8080/core/api/mcp",
|
||||
)
|
||||
})
|
||||
|
||||
it("reduces the public auth base URL to its origin", ({ expect }) => {
|
||||
const env = loadEnv(completeEnv())
|
||||
|
||||
expect(env.publicAuthBaseUrl).toBe(
|
||||
"http://localhost:8080/auth-realtime",
|
||||
)
|
||||
expect(env.authOrigin).toBe("http://localhost:8080")
|
||||
})
|
||||
|
||||
it("derives the MCP token issuer from the auth origin", ({
|
||||
expect,
|
||||
}) => {
|
||||
const env = loadEnv(completeEnv())
|
||||
|
||||
expect(env.mcpTokenIssuer).toBe(
|
||||
"http://localhost:8080/api/auth",
|
||||
)
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
name: "a missing required variable",
|
||||
input: { OXYNOTE_AUTH_REALTIME_DB_DSN: undefined },
|
||||
expected: "OXYNOTE_AUTH_REALTIME_DB_DSN",
|
||||
},
|
||||
{
|
||||
name: "a required variable present but empty",
|
||||
input: { OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SECRET: "" },
|
||||
expected: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SECRET",
|
||||
},
|
||||
{
|
||||
name: "a URL variable that is not a URL",
|
||||
input: {
|
||||
OXYNOTE_AUTH_REALTIME_BACKEND_URL: "core:8080",
|
||||
},
|
||||
expected: "OXYNOTE_AUTH_REALTIME_BACKEND_URL",
|
||||
},
|
||||
])("names $name in the failure", ({ input, expected }, { expect }) => {
|
||||
expect(() => loadEnv(completeEnv(input))).toThrow(expected)
|
||||
})
|
||||
|
||||
it("reports every invalid variable at once rather than the first", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(() =>
|
||||
loadEnv(
|
||||
completeEnv({
|
||||
OXYNOTE_AUTH_REALTIME_DB_DSN: undefined,
|
||||
OXYNOTE_AUTH_REALTIME_VALKEY_URL:
|
||||
undefined,
|
||||
}),
|
||||
),
|
||||
).toThrow(
|
||||
/OXYNOTE_AUTH_REALTIME_DB_DSN[\s\S]*OXYNOTE_AUTH_REALTIME_VALKEY_URL/,
|
||||
)
|
||||
})
|
||||
|
||||
describe("counters", () => {
|
||||
it("falls back to the defaults when unset", ({ expect }) => {
|
||||
const env = loadEnv(completeEnv())
|
||||
|
||||
expect(env.maxOrganizations).toBe(100)
|
||||
expect(env.maxOrganizationMembers).toBe(5)
|
||||
})
|
||||
|
||||
it("reads the configured values", ({ expect }) => {
|
||||
const env = loadEnv(
|
||||
completeEnv({
|
||||
OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS:
|
||||
"50",
|
||||
OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS:
|
||||
"25",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(env.maxOrganizations).toBe(50)
|
||||
expect(env.maxOrganizationMembers).toBe(25)
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ name: "a word", input: "many" },
|
||||
{ name: "a fraction", input: "2.5" },
|
||||
{ name: "zero", input: "0" },
|
||||
{ name: "a negative count", input: "-1" },
|
||||
])(
|
||||
"rejects $name as an organization limit",
|
||||
({ input }, { expect }) => {
|
||||
expect(() =>
|
||||
loadEnv(
|
||||
completeEnv({
|
||||
OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS:
|
||||
input,
|
||||
}),
|
||||
),
|
||||
).toThrow(
|
||||
"OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS",
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe("rate limiting", () => {
|
||||
it.for([
|
||||
{ name: "unset", input: undefined, expected: true },
|
||||
{ name: '"false"', input: "false", expected: true },
|
||||
{ name: '"true"', input: "true", expected: false },
|
||||
])(
|
||||
"is enabled=$expected when the disable flag is $name",
|
||||
({ input, expected }, { expect }) => {
|
||||
const env = loadEnv(
|
||||
completeEnv({
|
||||
OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED:
|
||||
input,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(env.rateLimitEnabled).toBe(expected)
|
||||
},
|
||||
)
|
||||
|
||||
// a flag spelled any other way would read as false and leave
|
||||
// the limiter on while the operator believes it is off
|
||||
it("rejects a flag that is neither true nor false", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(() =>
|
||||
loadEnv(
|
||||
completeEnv({
|
||||
OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED:
|
||||
"1",
|
||||
}),
|
||||
),
|
||||
).toThrow("OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED")
|
||||
})
|
||||
})
|
||||
|
||||
describe("trusted origins", () => {
|
||||
it("splits the list on commas", ({ expect }) => {
|
||||
const env = loadEnv(completeEnv())
|
||||
|
||||
expect(env.trustedOrigins).toEqual([
|
||||
"http://localhost:8080",
|
||||
"http://localhost:3000",
|
||||
])
|
||||
})
|
||||
|
||||
it("is empty when the variable is unset", ({ expect }) => {
|
||||
const env = loadEnv(
|
||||
completeEnv({
|
||||
OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS:
|
||||
undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(env.trustedOrigins).toEqual([])
|
||||
})
|
||||
|
||||
it("drops the empty entries a trailing comma produces", ({
|
||||
expect,
|
||||
}) => {
|
||||
const env = loadEnv(
|
||||
completeEnv({
|
||||
OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS:
|
||||
"http://localhost:8080,,oxynote://,",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(env.trustedOrigins).toEqual([
|
||||
"http://localhost:8080",
|
||||
"oxynote://",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("social providers", () => {
|
||||
it("registers none when no credentials are configured", ({
|
||||
expect,
|
||||
}) => {
|
||||
const env = loadEnv(completeEnv())
|
||||
|
||||
expect(env.socialProviders).toEqual({})
|
||||
})
|
||||
|
||||
it("registers a provider given both halves of its credentials", ({
|
||||
expect,
|
||||
}) => {
|
||||
const env = loadEnv(
|
||||
completeEnv({
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_ID:
|
||||
"github-id",
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_SECRET:
|
||||
"github-secret",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(env.socialProviders).toEqual({
|
||||
github: {
|
||||
clientId: "github-id",
|
||||
clientSecret: "github-secret",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("registers each configured provider independently", ({
|
||||
expect,
|
||||
}) => {
|
||||
const env = loadEnv(
|
||||
completeEnv({
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_ID:
|
||||
"google-id",
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_SECRET:
|
||||
"google-secret",
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_ID:
|
||||
"slack-id",
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_SECRET:
|
||||
"slack-secret",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(Object.keys(env.socialProviders).sort()).toEqual(
|
||||
["google", "slack"],
|
||||
)
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
name: "the secret",
|
||||
input: {
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_ID:
|
||||
"google-id",
|
||||
},
|
||||
expected: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_SECRET",
|
||||
},
|
||||
{
|
||||
name: "the client id",
|
||||
input: {
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_SECRET:
|
||||
"google-secret",
|
||||
},
|
||||
expected: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_ID",
|
||||
},
|
||||
])(
|
||||
"fails when google is configured without $name",
|
||||
({ input, expected }, { expect }) => {
|
||||
expect(() =>
|
||||
loadEnv(completeEnv(input)),
|
||||
).toThrow(expected)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
240
server/auth-realtime/src/env.ts
Normal file
240
server/auth-realtime/src/env.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { z } from "zod"
|
||||
|
||||
export type SocialProviderName = "google" | "github" | "slack"
|
||||
|
||||
export interface SocialProviderCredentials {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
}
|
||||
|
||||
export interface Env {
|
||||
// where core answers. The variable behind it is still spelled
|
||||
// BACKEND_URL because that name is baked into every deployment's env
|
||||
// file; only the field this side reads it through says "core".
|
||||
coreUrl: string
|
||||
databaseDSN: string
|
||||
valkeyUrl: string
|
||||
// the public base URL carries the reverse proxy's /auth-realtime
|
||||
// prefix; authOrigin is the bare origin the proxy forwards to.
|
||||
publicAuthBaseUrl: string
|
||||
authOrigin: string
|
||||
betterAuthSecret: string
|
||||
cookieDomain: string
|
||||
frontendUrl: string
|
||||
organizationInvitationUrl: string
|
||||
trustedOrigins: string[]
|
||||
mcpResource: string
|
||||
// MCP access tokens carry better-auth's resolved base URL (origin +
|
||||
// basePath) as their issuer. The internal MCP session endpoint
|
||||
// verifies against it.
|
||||
mcpTokenIssuer: string
|
||||
socialProviders: Partial<
|
||||
Record<SocialProviderName, SocialProviderCredentials>
|
||||
>
|
||||
maxOrganizations: number
|
||||
maxOrganizationMembers: number
|
||||
rateLimitEnabled: boolean
|
||||
}
|
||||
|
||||
// every URL here is one something dials or redirects a browser to, so the
|
||||
// scheme is pinned: bare z.url() accepts "core:8080" as a valid URL with a
|
||||
// "core:" scheme, which would boot fine and then fail on first use.
|
||||
function httpUrl() {
|
||||
return z.url({ protocol: /^https?$/ })
|
||||
}
|
||||
|
||||
// an unset counter falls back to its default; a present one must be a
|
||||
// positive integer, so a typo is a boot error instead of a NaN limit that
|
||||
// compares false against every count.
|
||||
function counter(fallback: number) {
|
||||
return z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) =>
|
||||
value === undefined ? fallback : Number(value),
|
||||
)
|
||||
.pipe(z.number().int().positive())
|
||||
}
|
||||
|
||||
// only the two literals are accepted: a flag spelled "1" or "yes" would
|
||||
// otherwise read as false and silently leave the setting at its default.
|
||||
function flag(fallback: boolean) {
|
||||
return z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.transform((value) =>
|
||||
value === undefined ? fallback : value === "true",
|
||||
)
|
||||
}
|
||||
|
||||
const SOCIAL_PROVIDERS = [
|
||||
{
|
||||
name: "google",
|
||||
idKey: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_ID",
|
||||
secretKey: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_SECRET",
|
||||
},
|
||||
{
|
||||
name: "github",
|
||||
idKey: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_ID",
|
||||
secretKey: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_SECRET",
|
||||
},
|
||||
{
|
||||
name: "slack",
|
||||
idKey: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_ID",
|
||||
secretKey: "OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_SECRET",
|
||||
},
|
||||
] as const satisfies readonly {
|
||||
name: SocialProviderName
|
||||
idKey: string
|
||||
secretKey: string
|
||||
}[]
|
||||
|
||||
// every variable the service reads, spelled out. Optional social
|
||||
// credentials are the only ones allowed to be absent — a provider with
|
||||
// neither half is left unregistered, and one with a single half is a boot
|
||||
// error rather than a broken OAuth redirect discovered by a user.
|
||||
const schema = z
|
||||
.object({
|
||||
OXYNOTE_AUTH_REALTIME_BACKEND_URL: httpUrl(),
|
||||
OXYNOTE_AUTH_REALTIME_DB_DSN: z.string().min(1),
|
||||
OXYNOTE_AUTH_REALTIME_VALKEY_URL: z.string().min(1),
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_BASE_URL: httpUrl(),
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SECRET: z.string().min(1),
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_COOKIE_DOMAIN: z
|
||||
.string()
|
||||
.min(1),
|
||||
OXYNOTE_AUTH_REALTIME_FRONTEND_URL: httpUrl(),
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_ORGANIZATION_INVITATION_URL:
|
||||
httpUrl(),
|
||||
OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS: z.string().optional(),
|
||||
OXYNOTE_AUTH_REALTIME_MCP_RESOURCE: httpUrl(),
|
||||
OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS: counter(100),
|
||||
OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS: counter(5),
|
||||
// better-auth's limiter buckets by client IP, and sign-in and
|
||||
// sign-up share one allowance of three requests per ten
|
||||
// seconds. That is wrong for any caller whose traffic arrives
|
||||
// from a single address on behalf of many users — an
|
||||
// end-to-end test run, or a deployment fronted by a proxy that
|
||||
// does not pass the original IP through — so it can be
|
||||
// switched off and left to whatever sits in front.
|
||||
OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED: flag(false),
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_ID: z
|
||||
.string()
|
||||
.optional(),
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GOOGLE_CLIENT_SECRET: z
|
||||
.string()
|
||||
.optional(),
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_ID: z
|
||||
.string()
|
||||
.optional(),
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_GITHUB_CLIENT_SECRET: z
|
||||
.string()
|
||||
.optional(),
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_ID: z
|
||||
.string()
|
||||
.optional(),
|
||||
OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SLACK_CLIENT_SECRET: z
|
||||
.string()
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
for (const provider of SOCIAL_PROVIDERS) {
|
||||
const id = values[provider.idKey]
|
||||
const secret = values[provider.secretKey]
|
||||
|
||||
if (Boolean(id) === Boolean(secret)) {
|
||||
continue
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [
|
||||
id
|
||||
? provider.secretKey
|
||||
: provider.idKey,
|
||||
],
|
||||
message: `${provider.name} needs both a client id and a client secret, or neither`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type ParsedEnv = z.infer<typeof schema>
|
||||
|
||||
function socialProviders(
|
||||
values: ParsedEnv,
|
||||
): Partial<Record<SocialProviderName, SocialProviderCredentials>> {
|
||||
const providers: Partial<
|
||||
Record<SocialProviderName, SocialProviderCredentials>
|
||||
> = {}
|
||||
|
||||
for (const provider of SOCIAL_PROVIDERS) {
|
||||
const clientId = values[provider.idKey]
|
||||
const clientSecret = values[provider.secretKey]
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
continue
|
||||
}
|
||||
|
||||
providers[provider.name] = { clientId, clientSecret }
|
||||
}
|
||||
|
||||
return providers
|
||||
}
|
||||
|
||||
// loadEnv parses the process environment into the typed configuration the
|
||||
// composition root hands to every factory. It is the only place that reads
|
||||
// process.env, so a test builds an Env by calling this with a literal
|
||||
// record instead of mutating the real environment.
|
||||
export function loadEnv(source: Record<string, string | undefined>): Env {
|
||||
// docker's env files list every variable the service reads, including
|
||||
// the ones left blank, so an unset value arrives as "" rather than
|
||||
// absent. Dropping those lets optional values fall back to their
|
||||
// defaults and makes a required one report as missing instead of
|
||||
// empty.
|
||||
const present = Object.fromEntries(
|
||||
Object.entries(source).filter(
|
||||
([, value]) => value !== undefined && value !== "",
|
||||
),
|
||||
)
|
||||
|
||||
const result = schema.safeParse(present)
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`invalid environment:\n${z.prettifyError(result.error)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const values = result.data
|
||||
const publicAuthBaseUrl =
|
||||
values.OXYNOTE_AUTH_REALTIME_BETTER_AUTH_BASE_URL
|
||||
const authOrigin = new URL(publicAuthBaseUrl).origin
|
||||
|
||||
return {
|
||||
coreUrl: values.OXYNOTE_AUTH_REALTIME_BACKEND_URL,
|
||||
databaseDSN: values.OXYNOTE_AUTH_REALTIME_DB_DSN,
|
||||
valkeyUrl: values.OXYNOTE_AUTH_REALTIME_VALKEY_URL,
|
||||
publicAuthBaseUrl,
|
||||
authOrigin,
|
||||
betterAuthSecret:
|
||||
values.OXYNOTE_AUTH_REALTIME_BETTER_AUTH_SECRET,
|
||||
cookieDomain:
|
||||
values.OXYNOTE_AUTH_REALTIME_BETTER_AUTH_COOKIE_DOMAIN,
|
||||
frontendUrl: values.OXYNOTE_AUTH_REALTIME_FRONTEND_URL,
|
||||
organizationInvitationUrl:
|
||||
values.OXYNOTE_AUTH_REALTIME_BETTER_AUTH_ORGANIZATION_INVITATION_URL,
|
||||
trustedOrigins: (
|
||||
values.OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS ?? ""
|
||||
)
|
||||
.split(",")
|
||||
.filter(Boolean),
|
||||
mcpResource: values.OXYNOTE_AUTH_REALTIME_MCP_RESOURCE,
|
||||
mcpTokenIssuer: `${authOrigin}/api/auth`,
|
||||
socialProviders: socialProviders(values),
|
||||
maxOrganizations:
|
||||
values.OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATIONS,
|
||||
maxOrganizationMembers:
|
||||
values.OXYNOTE_AUTH_REALTIME_MAX_ORGANIZATION_MEMBERS,
|
||||
rateLimitEnabled:
|
||||
!values.OXYNOTE_AUTH_REALTIME_RATE_LIMIT_DISABLED,
|
||||
}
|
||||
}
|
||||
378
server/auth-realtime/src/headers.test.ts
Normal file
378
server/auth-realtime/src/headers.test.ts
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import { describe, it } from "vitest"
|
||||
|
||||
import { collectHeaderEntries, toAxiosHeaders, toHeaders } from "./headers.js"
|
||||
|
||||
const nonHeaderShapedCases: { name: string; input: unknown }[] = [
|
||||
{ name: "null", input: null },
|
||||
{ name: "undefined", input: undefined },
|
||||
{ name: "a string", input: "content-type: text/plain" },
|
||||
{ name: "a number", input: 7 },
|
||||
]
|
||||
|
||||
const collectedValueCases: {
|
||||
name: string
|
||||
input: unknown
|
||||
expected: string
|
||||
}[] = [
|
||||
{ name: "a string", input: "text/plain", expected: "text/plain" },
|
||||
{ name: "a number", input: 42, expected: "42" },
|
||||
{ name: "a boolean", input: false, expected: "false" },
|
||||
{
|
||||
name: "a bigint",
|
||||
input: 9007199254740993n,
|
||||
expected: "9007199254740993",
|
||||
},
|
||||
{ name: "an array of strings", input: ["a", "b"], expected: "a, b" },
|
||||
{
|
||||
name: "an array mixing primitives and dropped values",
|
||||
input: ["a", 1, null, undefined],
|
||||
expected: "a, 1",
|
||||
},
|
||||
{
|
||||
name: "a nested array",
|
||||
input: [["a", "b"], "c"],
|
||||
expected: "a, b, c",
|
||||
},
|
||||
]
|
||||
|
||||
const droppedValueCases: { name: string; input: unknown }[] = [
|
||||
{ name: "null", input: null },
|
||||
{ name: "undefined", input: undefined },
|
||||
{ name: "a plain object", input: { nested: "value" } },
|
||||
{ name: "a symbol", input: Symbol("x-a") },
|
||||
{ name: "an empty array", input: [] },
|
||||
{ name: "an array of only dropped values", input: [null, undefined] },
|
||||
]
|
||||
|
||||
describe("headers", () => {
|
||||
describe("collectHeaderEntries", () => {
|
||||
it.for(nonHeaderShapedCases)(
|
||||
"returns null for $name",
|
||||
({ input }, { expect }) => {
|
||||
expect(collectHeaderEntries(input)).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it.for(collectedValueCases)(
|
||||
"collects $name as a single header value",
|
||||
({ input, expected }, { expect }) => {
|
||||
expect(
|
||||
collectHeaderEntries({ "x-a": input }),
|
||||
).toEqual([["x-a", expected]])
|
||||
},
|
||||
)
|
||||
|
||||
it.for(droppedValueCases)(
|
||||
"drops a header whose value is $name",
|
||||
({ input }, { expect }) => {
|
||||
expect(
|
||||
collectHeaderEntries({ "x-a": input }),
|
||||
).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
it("collects the entries of a WHATWG Headers instance", ({
|
||||
expect,
|
||||
}) => {
|
||||
const headers = new Headers({
|
||||
"X-A": "1",
|
||||
"Content-Type": "text/plain",
|
||||
})
|
||||
|
||||
expect(collectHeaderEntries(headers)).toEqual([
|
||||
["content-type", "text/plain"],
|
||||
["x-a", "1"],
|
||||
])
|
||||
})
|
||||
|
||||
it("collects a repeated Headers entry as one comma-separated value", ({
|
||||
expect,
|
||||
}) => {
|
||||
const headers = new Headers()
|
||||
headers.append("X-A", "1")
|
||||
headers.append("X-A", "2")
|
||||
|
||||
expect(collectHeaderEntries(headers)).toEqual([
|
||||
["x-a", "1, 2"],
|
||||
])
|
||||
})
|
||||
|
||||
it("collects the entries of a Map", ({ expect }) => {
|
||||
const headers = new Map<string, unknown>([
|
||||
["x-a", "1"],
|
||||
["x-b", ["2", "3"]],
|
||||
["x-c", null],
|
||||
])
|
||||
|
||||
expect(collectHeaderEntries(headers)).toEqual([
|
||||
["x-a", "1"],
|
||||
["x-b", "2, 3"],
|
||||
])
|
||||
})
|
||||
|
||||
it("collects the entries of an iterable that has no forEach", ({
|
||||
expect,
|
||||
}) => {
|
||||
const headers = {
|
||||
*[Symbol.iterator](): Generator<
|
||||
[string, unknown]
|
||||
> {
|
||||
yield ["x-a", "1"]
|
||||
yield ["x-b", ["2", "3"]]
|
||||
yield ["x-c", undefined]
|
||||
},
|
||||
}
|
||||
|
||||
expect(collectHeaderEntries(headers)).toEqual([
|
||||
["x-a", "1"],
|
||||
["x-b", "2, 3"],
|
||||
])
|
||||
})
|
||||
|
||||
it("collects the entries of a plain object map", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(
|
||||
collectHeaderEntries({
|
||||
"X-A": "1",
|
||||
"content-type": "text/plain",
|
||||
}),
|
||||
).toEqual([
|
||||
["X-A", "1"],
|
||||
["content-type", "text/plain"],
|
||||
])
|
||||
})
|
||||
|
||||
it("collects the entries of a null-prototype object map", ({
|
||||
expect,
|
||||
}) => {
|
||||
const headers = Object.assign(
|
||||
Object.create(null) as object,
|
||||
{
|
||||
"x-a": "1",
|
||||
},
|
||||
)
|
||||
|
||||
expect(collectHeaderEntries(headers)).toEqual([
|
||||
["x-a", "1"],
|
||||
])
|
||||
})
|
||||
|
||||
it("returns an empty list for an object with no own keys", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect(collectHeaderEntries({})).toEqual([])
|
||||
})
|
||||
|
||||
// a symbol key stringifies to "Symbol(x-a)", which
|
||||
// Headers.append rejects as an invalid header name
|
||||
it("drops a symbol key of a plain object map", ({ expect }) => {
|
||||
const key = Symbol("x-a")
|
||||
|
||||
expect(
|
||||
collectHeaderEntries({
|
||||
[key]: "1",
|
||||
"x-b": "2",
|
||||
}),
|
||||
).toEqual([["x-b", "2"]])
|
||||
})
|
||||
})
|
||||
|
||||
describe("toHeaders", () => {
|
||||
it("prefers the header-shaped headers argument over the request", ({
|
||||
expect,
|
||||
}) => {
|
||||
const res = toHeaders(
|
||||
{
|
||||
rawHeaders: ["x-raw", "raw"],
|
||||
headers: { "x-req": "req" },
|
||||
},
|
||||
{ "X-A": "1" },
|
||||
)
|
||||
|
||||
expect([...res.entries()]).toEqual([["x-a", "1"]])
|
||||
})
|
||||
|
||||
it("lowercases the collected header names", ({ expect }) => {
|
||||
const res = toHeaders(null, {
|
||||
"Content-Type": "text/plain",
|
||||
})
|
||||
|
||||
expect(res.get("content-type")).toBe("text/plain")
|
||||
})
|
||||
|
||||
it("combines repeated entries of an iterable into one value", ({
|
||||
expect,
|
||||
}) => {
|
||||
const res = toHeaders(
|
||||
null,
|
||||
new Map<string, unknown>([["x-a", "1"]]),
|
||||
)
|
||||
res.append("x-a", "2")
|
||||
|
||||
expect(res.get("x-a")).toBe("1, 2")
|
||||
})
|
||||
|
||||
it("takes an empty headers argument as header-shaped and ignores rawHeaders", ({
|
||||
expect,
|
||||
}) => {
|
||||
const res = toHeaders({ rawHeaders: ["x-a", "1"] }, {})
|
||||
|
||||
expect([...res.entries()]).toEqual([])
|
||||
})
|
||||
|
||||
it("falls back to rawHeaders when the headers argument is not header-shaped", ({
|
||||
expect,
|
||||
}) => {
|
||||
const res = toHeaders(
|
||||
{
|
||||
rawHeaders: [
|
||||
"X-A",
|
||||
"1",
|
||||
"Content-Type",
|
||||
"text/plain",
|
||||
],
|
||||
headers: { "x-req": "req" },
|
||||
},
|
||||
null,
|
||||
)
|
||||
|
||||
expect([...res.entries()]).toEqual([
|
||||
["content-type", "text/plain"],
|
||||
["x-a", "1"],
|
||||
])
|
||||
})
|
||||
|
||||
it("combines a repeated rawHeaders key into one value", ({
|
||||
expect,
|
||||
}) => {
|
||||
const res = toHeaders(
|
||||
{ rawHeaders: ["X-A", "1", "x-a", "2"] },
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(res.get("x-a")).toBe("1, 2")
|
||||
})
|
||||
|
||||
it("skips a trailing rawHeaders key that has no value", ({
|
||||
expect,
|
||||
}) => {
|
||||
const res = toHeaders(
|
||||
{ rawHeaders: ["x-a", "1", "x-b"] },
|
||||
null,
|
||||
)
|
||||
|
||||
expect([...res.entries()]).toEqual([["x-a", "1"]])
|
||||
})
|
||||
|
||||
it("skips a rawHeaders pair whose key or value is not a primitive", ({
|
||||
expect,
|
||||
}) => {
|
||||
const res = toHeaders(
|
||||
{
|
||||
rawHeaders: [
|
||||
{},
|
||||
"1",
|
||||
"x-b",
|
||||
{},
|
||||
"x-c",
|
||||
"3",
|
||||
],
|
||||
},
|
||||
null,
|
||||
)
|
||||
|
||||
expect([...res.entries()]).toEqual([["x-c", "3"]])
|
||||
})
|
||||
|
||||
it("falls back to req.headers when the request has no rawHeaders", ({
|
||||
expect,
|
||||
}) => {
|
||||
const res = toHeaders(
|
||||
{ headers: { "X-A": "1", "x-b": null } },
|
||||
null,
|
||||
)
|
||||
|
||||
expect([...res.entries()]).toEqual([["x-a", "1"]])
|
||||
})
|
||||
|
||||
it("falls back to req.headers when rawHeaders is not an array", ({
|
||||
expect,
|
||||
}) => {
|
||||
const res = toHeaders(
|
||||
{
|
||||
rawHeaders: "x-a: 1",
|
||||
headers: { "x-b": "2" },
|
||||
},
|
||||
null,
|
||||
)
|
||||
|
||||
expect([...res.entries()]).toEqual([["x-b", "2"]])
|
||||
})
|
||||
|
||||
it("returns an empty Headers when the request is null", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect([...toHeaders(null, null).entries()]).toEqual([])
|
||||
})
|
||||
|
||||
it("returns an empty Headers when the request is undefined", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect([
|
||||
...toHeaders(undefined, undefined).entries(),
|
||||
]).toEqual([])
|
||||
})
|
||||
|
||||
it("returns an empty Headers when neither argument is header-shaped", ({
|
||||
expect,
|
||||
}) => {
|
||||
expect([
|
||||
...toHeaders(
|
||||
"not a request",
|
||||
"not headers",
|
||||
).entries(),
|
||||
]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("toAxiosHeaders", () => {
|
||||
it("sets each collected entry on the returned AxiosHeaders", ({
|
||||
expect,
|
||||
}) => {
|
||||
const res = toAxiosHeaders({
|
||||
"X-A": "1",
|
||||
"content-type": "text/plain",
|
||||
"x-count": 3,
|
||||
})
|
||||
|
||||
expect(res.get("x-a")).toBe("1")
|
||||
expect(res.get("Content-Type")).toBe("text/plain")
|
||||
expect(res.get("x-count")).toBe("3")
|
||||
})
|
||||
|
||||
it("sets the entries of a WHATWG Headers instance", ({
|
||||
expect,
|
||||
}) => {
|
||||
const res = toAxiosHeaders(new Headers({ "X-A": "1" }))
|
||||
|
||||
expect(res.get("x-a")).toBe("1")
|
||||
})
|
||||
|
||||
it("omits a header whose value is dropped", ({ expect }) => {
|
||||
const res = toAxiosHeaders({ "x-a": "1", "x-b": null })
|
||||
|
||||
expect(res.get("x-b")).toBeUndefined()
|
||||
expect(res.get("x-a")).toBe("1")
|
||||
})
|
||||
|
||||
it.for(nonHeaderShapedCases)(
|
||||
"returns empty AxiosHeaders for $name",
|
||||
({ input }, { expect }) => {
|
||||
expect(toAxiosHeaders(input).toJSON()).toEqual(
|
||||
{},
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
163
server/auth-realtime/src/headers.ts
Normal file
163
server/auth-realtime/src/headers.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { AxiosHeaders } from "axios"
|
||||
|
||||
type HeaderEntry = [string, string]
|
||||
|
||||
interface ForEachHeaders {
|
||||
forEach(callback: (value: unknown, key: string) => void): void
|
||||
}
|
||||
|
||||
// a header value is a string, or a list of them. Anything else is not
|
||||
// something a transport produced, and stringifying it would put
|
||||
// "[object Object]" on the wire — so it is dropped instead.
|
||||
function normalizeHeaderValue(value: unknown): string | null {
|
||||
if (value == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const parts = (value as unknown[])
|
||||
.map(normalizeHeaderValue)
|
||||
.filter((part): part is string => part !== null)
|
||||
|
||||
return parts.length > 0 ? parts.join(", ") : null
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "bigint"
|
||||
) {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function hasForEach(value: object): value is ForEachHeaders {
|
||||
return typeof (value as Partial<ForEachHeaders>).forEach === "function"
|
||||
}
|
||||
|
||||
function isEntryIterable(value: object): value is Iterable<[string, unknown]> {
|
||||
return (
|
||||
typeof (value as Partial<Iterable<unknown>>)[
|
||||
Symbol.iterator
|
||||
] === "function"
|
||||
)
|
||||
}
|
||||
|
||||
// hocuspocus hands the request headers through in whatever shape the
|
||||
// underlying transport produced, so every accepted form is normalized to a
|
||||
// list of pairs before anything reads them. Returns null when the input is
|
||||
// not header-shaped at all, which is what makes the raw-request fallback in
|
||||
// toHeaders reachable.
|
||||
export function collectHeaderEntries(
|
||||
requestHeaders: unknown,
|
||||
): HeaderEntry[] | null {
|
||||
if (!requestHeaders || typeof requestHeaders !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
const entries: HeaderEntry[] = []
|
||||
|
||||
// WHATWG Headers or compatible
|
||||
if (hasForEach(requestHeaders)) {
|
||||
requestHeaders.forEach((v, k) => {
|
||||
const value = normalizeHeaderValue(v)
|
||||
if (value != null) {
|
||||
entries.push([k, value])
|
||||
}
|
||||
})
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// iterable header entries
|
||||
if (isEntryIterable(requestHeaders)) {
|
||||
for (const [k, v] of requestHeaders) {
|
||||
const value = normalizeHeaderValue(v)
|
||||
if (value != null) {
|
||||
entries.push([k, value])
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// plain object map. Only enumerable string keys: a symbol key
|
||||
// stringifies to "Symbol(x)", which Headers.append rejects as an
|
||||
// invalid header name — turning a tolerant reader into a throw.
|
||||
const map = requestHeaders as Record<string, unknown>
|
||||
for (const k of Object.keys(map)) {
|
||||
const value = normalizeHeaderValue(map[k])
|
||||
if (value != null) {
|
||||
entries.push([k, value])
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function property(source: unknown, key: string): unknown {
|
||||
if (!source || typeof source !== "object") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (source as Record<string, unknown>)[key]
|
||||
}
|
||||
|
||||
export function toHeaders(req: unknown, requestHeaders: unknown): Headers {
|
||||
const res = new Headers()
|
||||
const entries = collectHeaderEntries(requestHeaders)
|
||||
|
||||
if (entries) {
|
||||
for (const [k, v] of entries) {
|
||||
res.append(k, v)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// fallback: Node IncomingMessage.rawHeaders (always present on Node),
|
||||
// a flat list alternating key and value
|
||||
const rawHeaders = property(req, "rawHeaders")
|
||||
if (Array.isArray(rawHeaders)) {
|
||||
const raw = rawHeaders as unknown[]
|
||||
for (let i = 0; i < raw.length; i += 2) {
|
||||
const key = normalizeHeaderValue(raw[i])
|
||||
const value = normalizeHeaderValue(raw[i + 1])
|
||||
|
||||
if (key != null && value != null) {
|
||||
res.append(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// last resort: regular req.headers
|
||||
for (const [k, v] of collectHeaderEntries(property(req, "headers")) ??
|
||||
[]) {
|
||||
res.append(k, v)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
export function toAxiosHeaders(requestHeaders: unknown): AxiosHeaders {
|
||||
const res = new AxiosHeaders()
|
||||
const entries = collectHeaderEntries(requestHeaders)
|
||||
|
||||
if (entries) {
|
||||
for (const [k, v] of entries) {
|
||||
res.set(k, v)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
691
server/auth-realtime/src/hocuspocus.test.ts
Normal file
691
server/auth-realtime/src/hocuspocus.test.ts
Normal file
|
|
@ -0,0 +1,691 @@
|
|||
import { describe, it, vi, type Mock } from "vitest"
|
||||
import * as Y from "yjs"
|
||||
import {
|
||||
createDocumentHooks,
|
||||
createHocuspocus,
|
||||
parseDocumentName,
|
||||
resolveBranchId,
|
||||
type AuthSession,
|
||||
} from "./hocuspocus.js"
|
||||
import { replaceYdocContent, transformer } from "./ydocument.js"
|
||||
import { fragmentXml, stubCore, type StubCore } from "./test-helpers.js"
|
||||
|
||||
type ConnectedDocument = Y.Doc & { broadcastStateless: Mock }
|
||||
|
||||
const SESSION: AuthSession = { user: { id: "user-1" } }
|
||||
|
||||
function stubAuth(session: AuthSession | null = SESSION) {
|
||||
return { getSession: vi.fn().mockResolvedValue(session) }
|
||||
}
|
||||
|
||||
// the hocuspocus Document adds the stateless broadcast the store hook
|
||||
// reaches for when a persist fails
|
||||
function connectedDocument(): ConnectedDocument {
|
||||
const doc = new Y.Doc() as ConnectedDocument
|
||||
doc.broadcastStateless = vi.fn()
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
function seededDocument(name = "Runbook"): ConnectedDocument {
|
||||
const doc = connectedDocument()
|
||||
replaceYdocContent(doc, {
|
||||
name,
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { uid: "p1" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Restart it",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
icon: "lucide:file",
|
||||
})
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
function hooksWith(core: StubCore, session: AuthSession | null = SESSION) {
|
||||
return createDocumentHooks({ auth: stubAuth(session), core })
|
||||
}
|
||||
|
||||
describe("parseDocumentName", () => {
|
||||
it.for([
|
||||
{
|
||||
name: "a concrete branch id",
|
||||
input: "doc1-branch1",
|
||||
expected: {
|
||||
documentId: "doc1",
|
||||
branchIdentifier: "branch1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "the default branch marker",
|
||||
input: "doc1-default",
|
||||
expected: {
|
||||
documentId: "doc1",
|
||||
branchIdentifier: "default",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a branch identifier containing dashes",
|
||||
input: "doc1-feature-x-2",
|
||||
expected: {
|
||||
documentId: "doc1",
|
||||
branchIdentifier: "feature-x-2",
|
||||
},
|
||||
},
|
||||
])(
|
||||
"splits $name on the first dash",
|
||||
({ input, expected }, { expect }) => {
|
||||
expect(parseDocumentName(input)).toEqual(expected)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe("resolveBranchId", () => {
|
||||
it("returns a concrete branch identifier without asking core", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
|
||||
const branchId = await resolveBranchId(core, "doc1", "branch1")
|
||||
|
||||
expect(branchId).toBe("branch1")
|
||||
expect(core.fetchBranches).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
// the main branch is identified by its default flag, not by being
|
||||
// first in the list or by its name
|
||||
it("resolves 'default' to the branch core flags as default", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
core.fetchBranches.mockResolvedValue([
|
||||
{ branchId: "branch-2", default: false },
|
||||
{ branchId: "branch-1", default: true },
|
||||
])
|
||||
|
||||
const branchId = await resolveBranchId(core, "doc1", "default")
|
||||
|
||||
expect(branchId).toBe("branch-1")
|
||||
expect(core.fetchBranches).toHaveBeenCalledWith("doc1")
|
||||
})
|
||||
|
||||
it("throws when no branch is flagged as default", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
core.fetchBranches.mockResolvedValue([
|
||||
{ branchId: "branch-2", default: false },
|
||||
])
|
||||
|
||||
await expect(
|
||||
resolveBranchId(core, "doc1", "default"),
|
||||
).rejects.toThrow("no default branch found for document doc1")
|
||||
})
|
||||
|
||||
it("propagates a failed branch lookup", async ({ expect }) => {
|
||||
const failure = new Error("core unreachable")
|
||||
const core = stubCore()
|
||||
core.fetchBranches.mockRejectedValue(failure)
|
||||
|
||||
await expect(
|
||||
resolveBranchId(core, "doc1", "default"),
|
||||
).rejects.toBe(failure)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createDocumentHooks", () => {
|
||||
describe("onAuthenticate", () => {
|
||||
it("returns the session for an authenticated connection", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const auth = stubAuth()
|
||||
const hooks = createDocumentHooks({ auth, core })
|
||||
|
||||
const result = await hooks.onAuthenticate({
|
||||
documentName: "doc1-branch1",
|
||||
request: {},
|
||||
requestHeaders: { cookie: "auth.session=abc" },
|
||||
token: "",
|
||||
})
|
||||
|
||||
expect(result).toEqual({ session: SESSION })
|
||||
expect(auth.getSession).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("confirms the document exists with the caller's own headers", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
await hooks.onAuthenticate({
|
||||
documentName: "doc1-branch1",
|
||||
request: {},
|
||||
requestHeaders: { cookie: "auth.session=abc" },
|
||||
token: "",
|
||||
})
|
||||
|
||||
expect(core.fetchBranches).toHaveBeenCalledTimes(1)
|
||||
const [documentId, options] =
|
||||
core.fetchBranches.mock.calls[0] ?? []
|
||||
expect(documentId).toBe("doc1")
|
||||
expect(options?.headers?.get("cookie")).toBe(
|
||||
"auth.session=abc",
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects a connection with no session", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = hooksWith(core, null)
|
||||
|
||||
await expect(
|
||||
hooks.onAuthenticate({
|
||||
documentName: "doc1-branch1",
|
||||
request: {},
|
||||
requestHeaders: {},
|
||||
token: "",
|
||||
}),
|
||||
).rejects.toThrow("not authenticated")
|
||||
expect(core.fetchBranches).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
// the e2e suite uses this token to drive the failure path
|
||||
// without having to break the session
|
||||
it("rejects the force-error token even with a valid session", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
await expect(
|
||||
hooks.onAuthenticate({
|
||||
documentName: "doc1-branch1",
|
||||
request: {},
|
||||
requestHeaders: {},
|
||||
token: "force-error",
|
||||
}),
|
||||
).rejects.toThrow("not authenticated")
|
||||
expect(core.fetchBranches).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("propagates a failed document lookup", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const failure = new Error("document not found")
|
||||
const core = stubCore()
|
||||
core.fetchBranches.mockRejectedValue(failure)
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
await expect(
|
||||
hooks.onAuthenticate({
|
||||
documentName: "doc1-branch1",
|
||||
request: {},
|
||||
requestHeaders: {},
|
||||
token: "",
|
||||
}),
|
||||
).rejects.toBe(failure)
|
||||
})
|
||||
})
|
||||
|
||||
describe("beforeHandleMessage", () => {
|
||||
// a placeholder for re-checking the session mid-connection; it
|
||||
// must still resolve, because hocuspocus awaits it on every
|
||||
// inbound message
|
||||
it("lets every message through", async ({ expect }) => {
|
||||
const hooks = hooksWith(stubCore())
|
||||
|
||||
await expect(
|
||||
hooks.beforeHandleMessage(),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("onChange", () => {
|
||||
it("records the editing user as a maintainer of the document", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
await hooks.onChange({
|
||||
context: { session: SESSION },
|
||||
documentName: "doc1-branch1",
|
||||
})
|
||||
await hooks.onStoreDocument({
|
||||
documentName: "doc1-branch1",
|
||||
document: seededDocument(),
|
||||
})
|
||||
|
||||
expect(
|
||||
core.storeBranchContent.mock.calls[0]?.[2]
|
||||
.maintainers,
|
||||
).toEqual(["user-1"])
|
||||
})
|
||||
|
||||
it("records each editor once", async ({ expect }) => {
|
||||
const core = stubCore()
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
await hooks.onChange({
|
||||
context: { session: SESSION },
|
||||
documentName: "doc1-branch1",
|
||||
})
|
||||
await hooks.onChange({
|
||||
context: { session: SESSION },
|
||||
documentName: "doc1-branch1",
|
||||
})
|
||||
await hooks.onChange({
|
||||
context: {
|
||||
session: { user: { id: "user-2" } },
|
||||
},
|
||||
documentName: "doc1-branch1",
|
||||
})
|
||||
await hooks.onStoreDocument({
|
||||
documentName: "doc1-branch1",
|
||||
document: seededDocument(),
|
||||
})
|
||||
|
||||
expect(
|
||||
core.storeBranchContent.mock.calls[0]?.[2]
|
||||
.maintainers,
|
||||
).toEqual(["user-1", "user-2"])
|
||||
})
|
||||
|
||||
it("ignores a change carrying no session", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
await hooks.onChange({
|
||||
context: null,
|
||||
documentName: "doc1-branch1",
|
||||
})
|
||||
await hooks.onStoreDocument({
|
||||
documentName: "doc1-branch1",
|
||||
document: seededDocument(),
|
||||
})
|
||||
|
||||
expect(
|
||||
core.storeBranchContent.mock.calls[0]?.[2]
|
||||
.maintainers,
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it("keeps each document's editors separate", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
await hooks.onChange({
|
||||
context: { session: SESSION },
|
||||
documentName: "doc1-branch1",
|
||||
})
|
||||
await hooks.onStoreDocument({
|
||||
documentName: "doc2-branch1",
|
||||
document: seededDocument(),
|
||||
})
|
||||
|
||||
expect(
|
||||
core.storeBranchContent.mock.calls[0]?.[2]
|
||||
.maintainers,
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("onLoadDocument", () => {
|
||||
it("restores the stored Yjs state when core has one", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const stored = seededDocument("Stored name")
|
||||
const core = stubCore()
|
||||
core.fetchBranchContent.mockResolvedValue({
|
||||
documentName: "Stored name",
|
||||
content: { type: "doc", content: [] },
|
||||
icon: "lucide:file",
|
||||
rawContent: Buffer.from(
|
||||
Y.encodeStateAsUpdate(stored),
|
||||
).toString("base64"),
|
||||
})
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
const ydoc = await hooks.onLoadDocument({
|
||||
documentName: "doc1-branch1",
|
||||
})
|
||||
|
||||
expect(fragmentXml(ydoc, "content")).toContain(
|
||||
"Restart it",
|
||||
)
|
||||
// the state is already persisted; writing it back would
|
||||
// only churn the row
|
||||
expect(core.storeBranchContent).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("seeds a branch that has never been opened from its stored content", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
core.fetchBranchContent.mockResolvedValue({
|
||||
documentName: "Runbook",
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { uid: "p1" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "From core",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
icon: "lucide:file",
|
||||
rawContent: null,
|
||||
})
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
const ydoc = await hooks.onLoadDocument({
|
||||
documentName: "doc1-branch1",
|
||||
})
|
||||
|
||||
expect(fragmentXml(ydoc, "content")).toContain(
|
||||
"From core",
|
||||
)
|
||||
expect(ydoc.getText("icon").toJSON()).toBe(
|
||||
"lucide:file",
|
||||
)
|
||||
})
|
||||
|
||||
// without this the next restart would build a second Y.Doc with
|
||||
// a different clientID, whose state CRDT-merges with the
|
||||
// reconnecting clients' and duplicates every block
|
||||
it("persists the seeded state immediately as a system write", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
await hooks.onLoadDocument({
|
||||
documentName: "doc1-branch1",
|
||||
})
|
||||
|
||||
expect(core.storeBranchContent).toHaveBeenCalledTimes(1)
|
||||
const [documentId, branchId, update] =
|
||||
core.storeBranchContent.mock.calls[0] ?? []
|
||||
expect(documentId).toBe("doc1")
|
||||
expect(branchId).toBe("branch1")
|
||||
expect(update?.system).toBe(true)
|
||||
expect(update?.maintainers).toEqual([])
|
||||
expect(update?.rawContent).not.toBe("")
|
||||
})
|
||||
|
||||
it("falls back to a placeholder when the stored content is not a document", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
core.fetchBranchContent.mockResolvedValue({
|
||||
documentName: "Runbook",
|
||||
content: { type: "fragment" },
|
||||
icon: "lucide:file",
|
||||
rawContent: null,
|
||||
})
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
const ydoc = await hooks.onLoadDocument({
|
||||
documentName: "doc1-branch1",
|
||||
})
|
||||
|
||||
expect(fragmentXml(ydoc, "content")).toContain(
|
||||
"Hello World!",
|
||||
)
|
||||
})
|
||||
|
||||
// a row with no content at all is broken, not empty. Seeding a
|
||||
// placeholder would persist over whatever it should have held.
|
||||
it.for([
|
||||
{ name: "null", input: null },
|
||||
{ name: "absent", input: undefined },
|
||||
])(
|
||||
"fails the load when the stored content is $name",
|
||||
async ({ input }, { expect }) => {
|
||||
const core = stubCore()
|
||||
core.fetchBranchContent.mockResolvedValue({
|
||||
documentName: "Runbook",
|
||||
content: input,
|
||||
icon: "lucide:file",
|
||||
rawContent: null,
|
||||
})
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
await expect(
|
||||
hooks.onLoadDocument({
|
||||
documentName: "doc1-branch1",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"document doc1 branch branch1 has no stored content",
|
||||
)
|
||||
expect(
|
||||
core.storeBranchContent,
|
||||
).toHaveBeenCalledTimes(0)
|
||||
},
|
||||
)
|
||||
|
||||
it("resolves the default branch before asking for its content", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
core.fetchBranches.mockResolvedValue([
|
||||
{ branchId: "branch-1", default: true },
|
||||
])
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
await hooks.onLoadDocument({
|
||||
documentName: "doc1-default",
|
||||
})
|
||||
|
||||
expect(core.fetchBranchContent).toHaveBeenCalledWith(
|
||||
"doc1",
|
||||
"branch-1",
|
||||
)
|
||||
})
|
||||
|
||||
// the editor is already usable at this point; failing the load
|
||||
// would disconnect the client over a write it can retry
|
||||
it("still returns the document when persisting the seed fails", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
core.storeBranchContent.mockRejectedValue(
|
||||
new Error("core unreachable"),
|
||||
)
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
const ydoc = await hooks.onLoadDocument({
|
||||
documentName: "doc1-branch1",
|
||||
})
|
||||
|
||||
expect(ydoc).toBeInstanceOf(Y.Doc)
|
||||
})
|
||||
|
||||
it("propagates a failed content fetch", async ({ expect }) => {
|
||||
const failure = new Error("core unreachable")
|
||||
const core = stubCore()
|
||||
core.fetchBranchContent.mockRejectedValue(failure)
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
await expect(
|
||||
hooks.onLoadDocument({
|
||||
documentName: "doc1-branch1",
|
||||
}),
|
||||
).rejects.toBe(failure)
|
||||
})
|
||||
})
|
||||
|
||||
describe("onStoreDocument", () => {
|
||||
it("persists the document's name, icon and content as a user write", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
await hooks.onStoreDocument({
|
||||
documentName: "doc1-branch1",
|
||||
document: seededDocument("Runbook"),
|
||||
})
|
||||
|
||||
expect(core.storeBranchContent).toHaveBeenCalledTimes(1)
|
||||
const [documentId, branchId, update] =
|
||||
core.storeBranchContent.mock.calls[0] ?? []
|
||||
expect(documentId).toBe("doc1")
|
||||
expect(branchId).toBe("branch1")
|
||||
expect(update?.name).toBe("Runbook")
|
||||
expect(update?.icon).toBe("lucide:file")
|
||||
expect(update?.system).toBe(false)
|
||||
expect(update?.rawContent).not.toBe("")
|
||||
})
|
||||
|
||||
it("persists content the transformer can read back", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = hooksWith(core)
|
||||
const document = seededDocument()
|
||||
|
||||
await hooks.onStoreDocument({
|
||||
documentName: "doc1-branch1",
|
||||
document,
|
||||
})
|
||||
|
||||
expect(
|
||||
core.storeBranchContent.mock.calls[0]?.[2]
|
||||
.content,
|
||||
).toEqual(transformer.fromYdoc(document, "content"))
|
||||
})
|
||||
|
||||
// the maintainers field is who edited during this persist, not
|
||||
// the document's whole maintainer set, so it has to start empty
|
||||
// again after every write
|
||||
it("sends no maintainers on a second persist with no edits between", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
await hooks.onChange({
|
||||
context: { session: SESSION },
|
||||
documentName: "doc1-branch1",
|
||||
})
|
||||
await hooks.onStoreDocument({
|
||||
documentName: "doc1-branch1",
|
||||
document: seededDocument(),
|
||||
})
|
||||
await hooks.onStoreDocument({
|
||||
documentName: "doc1-branch1",
|
||||
document: seededDocument(),
|
||||
})
|
||||
|
||||
expect(
|
||||
core.storeBranchContent.mock.calls[1]?.[2]
|
||||
.maintainers,
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it("names an untitled document rather than persisting an empty name", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
const hooks = hooksWith(core)
|
||||
|
||||
await hooks.onStoreDocument({
|
||||
documentName: "doc1-branch1",
|
||||
document: connectedDocument(),
|
||||
})
|
||||
|
||||
expect(
|
||||
core.storeBranchContent.mock.calls[0]?.[2].name,
|
||||
).toBe("Untitled Document")
|
||||
})
|
||||
|
||||
// the editors keep their unsaved work either way; telling them
|
||||
// the persist failed is the only thing left to do
|
||||
it("warns the connected editors instead of throwing when the persist fails", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
core.storeBranchContent.mockRejectedValue(
|
||||
new Error("core unreachable"),
|
||||
)
|
||||
const hooks = hooksWith(core)
|
||||
const document = seededDocument()
|
||||
|
||||
await hooks.onStoreDocument({
|
||||
documentName: "doc1-branch1",
|
||||
document,
|
||||
})
|
||||
|
||||
expect(
|
||||
document.broadcastStateless,
|
||||
).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
code: "hocuspocus.store_failed",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("warns the connected editors when the branch cannot be resolved", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
core.fetchBranches.mockResolvedValue([])
|
||||
const hooks = hooksWith(core)
|
||||
const document = seededDocument()
|
||||
|
||||
await hooks.onStoreDocument({
|
||||
documentName: "doc1-default",
|
||||
document,
|
||||
})
|
||||
|
||||
expect(
|
||||
document.broadcastStateless,
|
||||
).toHaveBeenCalledTimes(1)
|
||||
expect(core.storeBranchContent).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createHocuspocus", () => {
|
||||
it("wires the document hooks onto the server", ({ expect }) => {
|
||||
const server = createHocuspocus({
|
||||
auth: stubAuth(),
|
||||
core: stubCore(),
|
||||
})
|
||||
|
||||
expect(typeof server.configuration.onAuthenticate).toBe(
|
||||
"function",
|
||||
)
|
||||
expect(typeof server.configuration.onLoadDocument).toBe(
|
||||
"function",
|
||||
)
|
||||
expect(typeof server.configuration.onStoreDocument).toBe(
|
||||
"function",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,24 +1,44 @@
|
|||
import * as Sentry from "@sentry/node"
|
||||
import * as Y from "yjs"
|
||||
import axios from "axios"
|
||||
import { nanoid } from "nanoid"
|
||||
import { AxiosHeaders } from "axios"
|
||||
import { Hocuspocus } from "@hocuspocus/server"
|
||||
import { Logger } from "@hocuspocus/extension-logger"
|
||||
import { auth } from "./auth.js"
|
||||
import type { CoreClient } from "./core.js"
|
||||
import { toAxiosHeaders, toHeaders } from "./headers.js"
|
||||
import { bestEffort, reported } from "./reporting.js"
|
||||
import { replaceYdocContent, transformer } from "./ydocument.js"
|
||||
|
||||
const url = process.env.OXYNOTE_AUTH_REALTIME_BACKEND_URL
|
||||
export interface AuthSession {
|
||||
user: { id: string }
|
||||
}
|
||||
|
||||
const documentMaintainers = new Map<string, Set<string>>()
|
||||
// the one thing the document hooks ask of better-auth. Narrowing it here
|
||||
// keeps the hooks drivable from a test with a two-line stub instead of a
|
||||
// configured auth instance.
|
||||
export interface SessionResolver {
|
||||
getSession(input: { headers: Headers }): Promise<AuthSession | null>
|
||||
}
|
||||
|
||||
// parseDocumentName splits a document name of the form "documentId-branchId"
|
||||
// into its component parts. branchId may be the special value "default".
|
||||
function parseDocumentName(documentName: string): {
|
||||
export interface DocumentHookDeps {
|
||||
auth: SessionResolver
|
||||
core: CoreClient
|
||||
}
|
||||
|
||||
// hocuspocus's Document adds the stateless broadcast the store hook uses
|
||||
// to tell connected editors that a persist failed.
|
||||
interface ConnectedDocument extends Y.Doc {
|
||||
broadcastStateless(payload: string): void
|
||||
}
|
||||
|
||||
// parseDocumentName splits a document name of the form
|
||||
// "documentId-branchIdentifier" into its component parts. The split is on
|
||||
// the first dash: xids carry none, but a branch identifier may.
|
||||
export function parseDocumentName(documentName: string): {
|
||||
documentId: string
|
||||
branchIdentifier: string
|
||||
} {
|
||||
const idx = documentName.indexOf("-")
|
||||
|
||||
return {
|
||||
documentId: documentName.slice(0, idx),
|
||||
branchIdentifier: documentName.slice(idx + 1),
|
||||
|
|
@ -27,8 +47,9 @@ function parseDocumentName(documentName: string): {
|
|||
|
||||
// resolveBranchId resolves a branchIdentifier to a concrete branch ID.
|
||||
// When the identifier is "default", it fetches the document's branches and
|
||||
// returns the ID of the oldest one (the original branch).
|
||||
async function resolveBranchId(
|
||||
// returns the one flagged as the default.
|
||||
export async function resolveBranchId(
|
||||
core: CoreClient,
|
||||
documentId: string,
|
||||
branchIdentifier: string,
|
||||
): Promise<string> {
|
||||
|
|
@ -36,12 +57,7 @@ async function resolveBranchId(
|
|||
return branchIdentifier
|
||||
}
|
||||
|
||||
const response = await axios.get(
|
||||
`${url}/api/x/documents/${documentId}/branches`,
|
||||
)
|
||||
|
||||
const branches: { branchId: string; default: boolean }[] = response.data
|
||||
|
||||
const branches = await core.fetchBranches(documentId)
|
||||
const defaultBranch = branches.find((b) => b.default)
|
||||
|
||||
if (!defaultBranch) {
|
||||
|
|
@ -53,315 +69,269 @@ async function resolveBranchId(
|
|||
return defaultBranch.branchId
|
||||
}
|
||||
|
||||
export const hocuspocus = new Hocuspocus({
|
||||
extensions: [new Logger()],
|
||||
async onAuthenticate({ documentName, request, requestHeaders, token }) {
|
||||
const { documentId } = parseDocumentName(documentName)
|
||||
// the placeholder a branch gets when core has content for it that is not a
|
||||
// prosemirror document — a branch that has never been written to.
|
||||
function defaultDocumentContent() {
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { uid: nanoid() },
|
||||
content: [
|
||||
{ type: "text", text: "Hello World!" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: toHeaders(request, requestHeaders),
|
||||
})
|
||||
if (!session || token === "force-error") {
|
||||
throw new Error("not authenticated")
|
||||
}
|
||||
// core stores whatever the last persist wrote. Content that is not a
|
||||
// prosemirror document belongs to a branch nobody has written to yet, and
|
||||
// gets the placeholder — but a branch with no content at all is a broken
|
||||
// row, and failing the load is what surfaces that. Seeding an empty
|
||||
// document instead would persist over whatever the row should have held.
|
||||
function seedContent(
|
||||
content: unknown,
|
||||
documentId: string,
|
||||
branchId: string,
|
||||
): unknown {
|
||||
if (content === null || content === undefined) {
|
||||
throw new Error(
|
||||
`document ${documentId} branch ${branchId} has no stored content`,
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the user has access to this document by checking the oldest
|
||||
// branch. FetchDocumentBranchesUnsafe does not require an org filter —
|
||||
// the session check above confirms identity and the branch-list call
|
||||
// below confirms existence.
|
||||
try {
|
||||
await axios.get(
|
||||
`${url}/api/x/documents/${documentId}/branches`,
|
||||
{
|
||||
if (
|
||||
typeof content === "object" &&
|
||||
(content as { type?: unknown }).type === "doc"
|
||||
) {
|
||||
return content
|
||||
}
|
||||
|
||||
return defaultDocumentContent()
|
||||
}
|
||||
|
||||
function documentTitle(doc: Y.Doc): string {
|
||||
const name = transformer.fromYdoc(doc, "name") as {
|
||||
content?: { content?: { text?: string }[] }[]
|
||||
}
|
||||
|
||||
return name.content?.[0]?.content?.[0]?.text || "Untitled Document"
|
||||
}
|
||||
|
||||
function encodeState(doc: Y.Doc): string {
|
||||
return Buffer.from(Y.encodeStateAsUpdate(doc)).toString("base64")
|
||||
}
|
||||
|
||||
export function createDocumentHooks({ auth, core }: DocumentHookDeps) {
|
||||
// who edited each open document since its last persist. Held per
|
||||
// hooks instance so nothing carries over between servers — or, in a
|
||||
// test, between cases.
|
||||
const documentMaintainers = new Map<string, Set<string>>()
|
||||
|
||||
return {
|
||||
async onAuthenticate({
|
||||
documentName,
|
||||
request,
|
||||
requestHeaders,
|
||||
token,
|
||||
}: {
|
||||
documentName: string
|
||||
request: any
|
||||
requestHeaders: any
|
||||
token: string
|
||||
}) {
|
||||
const { documentId } = parseDocumentName(documentName)
|
||||
|
||||
const session = await auth.getSession({
|
||||
headers: toHeaders(request, requestHeaders),
|
||||
})
|
||||
if (!session || token === "force-error") {
|
||||
throw new Error("not authenticated")
|
||||
}
|
||||
|
||||
// verify the user has access to this document by
|
||||
// listing its branches. The call needs no org filter —
|
||||
// the session check above confirms identity and this
|
||||
// confirms the document exists.
|
||||
await reported(() =>
|
||||
core.fetchBranches(documentId, {
|
||||
headers: toAxiosHeaders(requestHeaders),
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
|
||||
/* TODO check edit permissions
|
||||
if (!canEdit) {
|
||||
connectionConfig.readOnly = true // allow read but block edits
|
||||
}
|
||||
*/
|
||||
|
||||
return {
|
||||
session: session,
|
||||
}
|
||||
},
|
||||
// TODO kick users if auth becomes invalid later
|
||||
async beforeHandleMessage() {
|
||||
// periodically re-check better auth session and close the socket if
|
||||
// needed
|
||||
},
|
||||
async onChange({ context, documentName }) {
|
||||
const session = context?.session
|
||||
const userId = session?.user?.id
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
|
||||
let set = documentMaintainers.get(documentName)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
documentMaintainers.set(documentName, set)
|
||||
}
|
||||
|
||||
set.add(userId)
|
||||
},
|
||||
async onLoadDocument({ documentName }) {
|
||||
try {
|
||||
const { documentId, branchIdentifier } =
|
||||
parseDocumentName(documentName)
|
||||
const branchId = await resolveBranchId(
|
||||
documentId,
|
||||
branchIdentifier,
|
||||
}),
|
||||
)
|
||||
|
||||
const response = await axios.get(
|
||||
`${url}/api/x/documents/${documentId}/branch/${branchId}`,
|
||||
)
|
||||
return { session }
|
||||
},
|
||||
|
||||
// TODO kick users if auth becomes invalid later
|
||||
beforeHandleMessage() {
|
||||
// periodically re-check better auth session and close
|
||||
// the socket if needed
|
||||
return Promise.resolve()
|
||||
},
|
||||
|
||||
onChange({
|
||||
context,
|
||||
documentName,
|
||||
}: {
|
||||
context?: { session?: AuthSession | null } | null
|
||||
documentName: string
|
||||
}) {
|
||||
const userId = context?.session?.user.id
|
||||
if (!userId) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
let set = documentMaintainers.get(documentName)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
documentMaintainers.set(documentName, set)
|
||||
}
|
||||
|
||||
set.add(userId)
|
||||
|
||||
return Promise.resolve()
|
||||
},
|
||||
|
||||
onLoadDocument({
|
||||
documentName,
|
||||
}: {
|
||||
documentName: string
|
||||
}): Promise<Y.Doc> {
|
||||
return reported(async () => {
|
||||
const { documentId, branchIdentifier } =
|
||||
parseDocumentName(documentName)
|
||||
const branchId = await resolveBranchId(
|
||||
core,
|
||||
documentId,
|
||||
branchIdentifier,
|
||||
)
|
||||
|
||||
const branch = await core.fetchBranchContent(
|
||||
documentId,
|
||||
branchId,
|
||||
)
|
||||
|
||||
if (branch.rawContent) {
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
Y.applyUpdate(
|
||||
ydoc,
|
||||
new Uint8Array(
|
||||
Buffer.from(
|
||||
branch.rawContent,
|
||||
"base64",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return ydoc
|
||||
}
|
||||
|
||||
if (!response.data.rawContent) {
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
replaceYdocContent(ydoc, {
|
||||
name: response.data.documentName,
|
||||
content:
|
||||
response.data.content.type ==
|
||||
"doc"
|
||||
? response.data.content
|
||||
: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: {
|
||||
uid: nanoid(),
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Hello World!",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
icon: response.data.icon,
|
||||
name: branch.documentName,
|
||||
content: seedContent(
|
||||
branch.content,
|
||||
documentId,
|
||||
branchId,
|
||||
),
|
||||
icon: branch.icon,
|
||||
})
|
||||
|
||||
// Persist rawContent immediately to prevent content duplication.
|
||||
// Without this, if the server restarts before onStoreDocument runs,
|
||||
// onLoadDocument would create a new Y.Doc with a different clientID,
|
||||
// causing the reconnecting client's existing state to merge with the
|
||||
// new state instead of being recognized as identical — duplicating
|
||||
// all content.
|
||||
const rawContent = Buffer.from(
|
||||
Y.encodeStateAsUpdate(ydoc),
|
||||
).toString("base64")
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`${url}/api/x/documents/${documentId}/branch/${branchId}`,
|
||||
// persist rawContent immediately to prevent
|
||||
// content duplication. Without this, if the
|
||||
// server restarts before onStoreDocument runs,
|
||||
// onLoadDocument would create a new Y.Doc with a
|
||||
// different clientID, causing the reconnecting
|
||||
// client's existing state to merge with the new
|
||||
// state instead of being recognized as identical
|
||||
// — duplicating all content.
|
||||
await bestEffort(() =>
|
||||
core.storeBranchContent(
|
||||
documentId,
|
||||
branchId,
|
||||
{
|
||||
name: response.data
|
||||
.documentName,
|
||||
icon: response.data
|
||||
.icon,
|
||||
content: response.data
|
||||
.content,
|
||||
name: branch.documentName,
|
||||
icon: branch.icon,
|
||||
content: branch.content,
|
||||
maintainers: [],
|
||||
rawContent,
|
||||
rawContent: encodeState(
|
||||
ydoc,
|
||||
),
|
||||
system: true,
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
return ydoc
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
const ydoc = new Y.Doc()
|
||||
async onStoreDocument(data: {
|
||||
documentName: string
|
||||
document: ConnectedDocument
|
||||
}) {
|
||||
try {
|
||||
const { documentId, branchIdentifier } =
|
||||
parseDocumentName(data.documentName)
|
||||
const branchId = await resolveBranchId(
|
||||
core,
|
||||
documentId,
|
||||
branchIdentifier,
|
||||
)
|
||||
|
||||
const data = new Uint8Array(
|
||||
Buffer.from(response.data.rawContent, "base64"),
|
||||
)
|
||||
// the field is who edited during this persist,
|
||||
// not the document's maintainer set — core only
|
||||
// ever adds to that set, so draining the map
|
||||
// here is what keeps the two consistent.
|
||||
const maintainers = Array.from(
|
||||
documentMaintainers.get(
|
||||
data.documentName,
|
||||
) ?? [],
|
||||
)
|
||||
documentMaintainers.delete(data.documentName)
|
||||
|
||||
Y.applyUpdate(ydoc, data)
|
||||
|
||||
return ydoc
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
async onStoreDocument(data) {
|
||||
try {
|
||||
const { documentId, branchIdentifier } =
|
||||
parseDocumentName(data.documentName)
|
||||
const branchId = await resolveBranchId(
|
||||
documentId,
|
||||
branchIdentifier,
|
||||
)
|
||||
|
||||
const maintainers = Array.from(
|
||||
documentMaintainers.get(data.documentName) ??
|
||||
[],
|
||||
)
|
||||
documentMaintainers.delete(data.documentName)
|
||||
|
||||
const name =
|
||||
transformer.fromYdoc(data.document, "name")
|
||||
.content[0]?.content[0]?.text ||
|
||||
"Untitled Document"
|
||||
|
||||
await axios.put(
|
||||
`${url}/api/x/documents/${documentId}/branch/${branchId}`,
|
||||
{
|
||||
name: name,
|
||||
icon: data.document
|
||||
.getText("icon")
|
||||
.toString(),
|
||||
content: transformer.fromYdoc(
|
||||
data.document,
|
||||
"content",
|
||||
),
|
||||
maintainers: maintainers,
|
||||
rawContent: Buffer.from(
|
||||
Y.encodeStateAsUpdate(
|
||||
await core.storeBranchContent(
|
||||
documentId,
|
||||
branchId,
|
||||
{
|
||||
name: documentTitle(
|
||||
data.document,
|
||||
),
|
||||
).toString("base64"),
|
||||
system: false,
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
|
||||
data.document.broadcastStateless(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
code: "hocuspocus.store_failed",
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type HeaderEntry = [string, string]
|
||||
|
||||
function normalizeHeaderValue(value: unknown): string | null {
|
||||
if (value == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Array.isArray(value) ? value.join(", ") : String(value)
|
||||
}
|
||||
|
||||
function collectHeaderEntries(requestHeaders: any): HeaderEntry[] | null {
|
||||
if (!requestHeaders) {
|
||||
return null
|
||||
}
|
||||
|
||||
// WHATWG Headers or compatible
|
||||
if (typeof requestHeaders.forEach === "function") {
|
||||
const entries: HeaderEntry[] = []
|
||||
requestHeaders.forEach((v: string, k: string) => {
|
||||
const value = normalizeHeaderValue(v)
|
||||
if (value != null) {
|
||||
entries.push([k, value])
|
||||
}
|
||||
})
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// Iterable header entries
|
||||
if (typeof requestHeaders[Symbol.iterator] === "function") {
|
||||
const entries: HeaderEntry[] = []
|
||||
for (const [k, v] of requestHeaders as any) {
|
||||
const value = normalizeHeaderValue(v)
|
||||
if (value != null) {
|
||||
entries.push([k, value])
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// Plain object map
|
||||
if (typeof requestHeaders === "object") {
|
||||
const entries: HeaderEntry[] = []
|
||||
for (const k of Reflect.ownKeys(requestHeaders) as string[]) {
|
||||
const value = normalizeHeaderValue(requestHeaders[k])
|
||||
if (value != null) {
|
||||
entries.push([k, value])
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function toHeaders(req: any, requestHeaders: any): Headers {
|
||||
const res = new Headers()
|
||||
const entries = collectHeaderEntries(requestHeaders)
|
||||
|
||||
if (entries) {
|
||||
for (const [k, v] of entries) {
|
||||
res.append(k, v)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// fallback: Node IncomingMessage.rawHeaders (always present on Node)
|
||||
if (req?.rawHeaders && Array.isArray(req.rawHeaders)) {
|
||||
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
||||
const k = String(req.rawHeaders[i])
|
||||
const v = String(req.rawHeaders[i + 1])
|
||||
res.append(k, v)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// last resort: regular req.headers
|
||||
if (req?.headers && typeof req.headers === "object") {
|
||||
for (const k of Reflect.ownKeys(req.headers) as string[]) {
|
||||
const v = req.headers[k as any]
|
||||
if (v != null)
|
||||
res.set(
|
||||
k,
|
||||
Array.isArray(v)
|
||||
? v.join(", ")
|
||||
: String(v),
|
||||
icon: data.document
|
||||
.getText("icon")
|
||||
.toJSON(),
|
||||
content: transformer.fromYdoc(
|
||||
data.document,
|
||||
"content",
|
||||
) as unknown,
|
||||
maintainers,
|
||||
rawContent: encodeState(
|
||||
data.document,
|
||||
),
|
||||
system: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
|
||||
return res
|
||||
data.document.broadcastStateless(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
code: "hocuspocus.store_failed",
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
export function toAxiosHeaders(requestHeaders: any): AxiosHeaders {
|
||||
const res = new AxiosHeaders()
|
||||
const entries = collectHeaderEntries(requestHeaders)
|
||||
|
||||
if (entries) {
|
||||
for (const [k, v] of entries) {
|
||||
res.set(k, v)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
return res
|
||||
export function createHocuspocus(deps: DocumentHookDeps): Hocuspocus {
|
||||
return new Hocuspocus({
|
||||
extensions: [new Logger()],
|
||||
...createDocumentHooks(deps),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
1087
server/auth-realtime/src/operations.test.ts
Normal file
1087
server/auth-realtime/src/operations.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
110
server/auth-realtime/src/reporting.test.ts
Normal file
110
server/auth-realtime/src/reporting.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { beforeEach, describe, it, vi } from "vitest"
|
||||
import * as Sentry from "@sentry/node"
|
||||
import { bestEffort, reported } from "./reporting.js"
|
||||
|
||||
vi.mock("@sentry/node", () => ({
|
||||
captureException: vi.fn(),
|
||||
}))
|
||||
|
||||
const captureException = vi.mocked(Sentry.captureException)
|
||||
|
||||
// the sentry module mock is a per-file singleton, so call-count assertions
|
||||
// on it cannot be isolated across interleaving tests. vitest 4's
|
||||
// restoreMocks does not reset a hand-made vi.fn(), so it is cleared here.
|
||||
describe("reported", { concurrent: false }, () => {
|
||||
beforeEach(() => {
|
||||
captureException.mockClear()
|
||||
})
|
||||
|
||||
it("returns what the work resolved to", async ({ expect }) => {
|
||||
expect(await reported(() => Promise.resolve("value"))).toBe(
|
||||
"value",
|
||||
)
|
||||
expect(captureException).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("reports the failure and lets the caller see it", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const failure = new Error("core unreachable")
|
||||
|
||||
await expect(
|
||||
reported(() => Promise.reject(failure)),
|
||||
).rejects.toBe(failure)
|
||||
expect(captureException).toHaveBeenCalledTimes(1)
|
||||
expect(captureException).toHaveBeenCalledWith(failure)
|
||||
})
|
||||
|
||||
// axios rejects with its own error shape rather than an Error, and
|
||||
// nothing here inspects the value on its way past
|
||||
it("reports a rejection that is not an Error", async ({ expect }) => {
|
||||
const rejection = { response: { status: 500 } }
|
||||
const run = vi.fn().mockRejectedValue(rejection)
|
||||
|
||||
await expect(reported(run)).rejects.toBe(rejection)
|
||||
expect(captureException).toHaveBeenCalledWith(rejection)
|
||||
})
|
||||
|
||||
it("reports a synchronous throw from the work itself", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const failure = new Error("built the request wrong")
|
||||
|
||||
await expect(
|
||||
reported(() => {
|
||||
throw failure
|
||||
}),
|
||||
).rejects.toBe(failure)
|
||||
expect(captureException).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("bestEffort", { concurrent: false }, () => {
|
||||
beforeEach(() => {
|
||||
captureException.mockClear()
|
||||
})
|
||||
|
||||
it("runs the effect and reports nothing when it succeeds", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const effect = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await bestEffort(effect)
|
||||
|
||||
expect(effect).toHaveBeenCalledTimes(1)
|
||||
expect(captureException).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
// the work this effect belongs to has already succeeded, so a failure
|
||||
// here must stay visible without reaching the caller
|
||||
it("reports the failure and resolves anyway", async ({ expect }) => {
|
||||
const failure = new Error("core unreachable")
|
||||
|
||||
await expect(
|
||||
bestEffort(() => Promise.reject(failure)),
|
||||
).resolves.toBeUndefined()
|
||||
expect(captureException).toHaveBeenCalledWith(failure)
|
||||
})
|
||||
|
||||
it("reports a synchronous throw from the effect itself", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const failure = new Error("nothing to disconnect")
|
||||
|
||||
await expect(
|
||||
bestEffort(() => {
|
||||
throw failure
|
||||
}),
|
||||
).resolves.toBeUndefined()
|
||||
expect(captureException).toHaveBeenCalledWith(failure)
|
||||
})
|
||||
|
||||
// hocuspocus's disconnect is declared as returning void or a promise,
|
||||
// so the helper has to accept an effect that returns neither
|
||||
it("accepts an effect that returns nothing", async ({ expect }) => {
|
||||
const effect = vi.fn()
|
||||
|
||||
await expect(bestEffort(effect)).resolves.toBeUndefined()
|
||||
expect(captureException).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
27
server/auth-realtime/src/reporting.ts
Normal file
27
server/auth-realtime/src/reporting.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import * as Sentry from "@sentry/node"
|
||||
|
||||
// runs work whose failure the caller must still see, reporting the error on
|
||||
// its way out. The frameworks this service hands callbacks to swallow the
|
||||
// cause — better-auth turns a rejected hook into a failed request and
|
||||
// hocuspocus into a refused connection — so without this the only trace of
|
||||
// what went wrong is a status code.
|
||||
export async function reported<T>(run: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await run()
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// runs an effect whose failure must not fail the caller: the work it
|
||||
// belongs to has already succeeded and the caller has nothing useful to do
|
||||
// about it. The error is reported and swallowed, so the failure is visible
|
||||
// without being fatal.
|
||||
export async function bestEffort(run: () => unknown): Promise<void> {
|
||||
try {
|
||||
await run()
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
}
|
||||
}
|
||||
965
server/auth-realtime/src/routes.test.ts
Normal file
965
server/auth-realtime/src/routes.test.ts
Normal file
|
|
@ -0,0 +1,965 @@
|
|||
import { describe, it, vi } from "vitest"
|
||||
import * as Y from "yjs"
|
||||
import { SignJWT, exportJWK, generateKeyPair, type JWTPayload } from "jose"
|
||||
import { createRoutes, type DocumentRegistry } from "./routes.js"
|
||||
import { replaceYdocContent } from "./ydocument.js"
|
||||
import {
|
||||
fragmentXml,
|
||||
stubCore,
|
||||
stubStore,
|
||||
testEnv,
|
||||
type StubCore,
|
||||
type StubStore,
|
||||
} from "./test-helpers.js"
|
||||
|
||||
const ENV = testEnv()
|
||||
|
||||
function stubAuth() {
|
||||
return {
|
||||
handler: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response("handled by better-auth"),
|
||||
),
|
||||
api: { getJwks: vi.fn().mockResolvedValue({ keys: [] }) },
|
||||
}
|
||||
}
|
||||
|
||||
function stubRegistry(
|
||||
overrides: Partial<{
|
||||
documents: Map<string, Y.Doc>
|
||||
transact: (fn: (doc: Y.Doc) => void) => Promise<void>
|
||||
open: () => Promise<never>
|
||||
}> = {},
|
||||
) {
|
||||
const documents = overrides.documents ?? new Map<string, Y.Doc>()
|
||||
const disconnect = vi.fn()
|
||||
const transact = vi.fn(
|
||||
overrides.transact ??
|
||||
((fn: (doc: Y.Doc) => void) => {
|
||||
fn(new Y.Doc())
|
||||
|
||||
return Promise.resolve()
|
||||
}),
|
||||
)
|
||||
const openDirectConnection = vi.fn(
|
||||
overrides.open ??
|
||||
(() => Promise.resolve({ transact, disconnect })),
|
||||
)
|
||||
|
||||
return {
|
||||
registry: {
|
||||
documents,
|
||||
openDirectConnection,
|
||||
} as unknown as DocumentRegistry,
|
||||
openDirectConnection,
|
||||
transact,
|
||||
disconnect,
|
||||
}
|
||||
}
|
||||
|
||||
function build(
|
||||
overrides: {
|
||||
env?: typeof ENV
|
||||
auth?: ReturnType<typeof stubAuth>
|
||||
store?: StubStore
|
||||
hocuspocus?: DocumentRegistry
|
||||
core?: StubCore
|
||||
} = {},
|
||||
) {
|
||||
const auth = overrides.auth ?? stubAuth()
|
||||
const core = overrides.core ?? stubCore()
|
||||
const store = overrides.store ?? stubStore()
|
||||
const app = createRoutes({
|
||||
env: overrides.env ?? ENV,
|
||||
auth,
|
||||
store,
|
||||
hocuspocus: overrides.hocuspocus ?? stubRegistry().registry,
|
||||
core,
|
||||
})
|
||||
|
||||
return { app, auth, core, store }
|
||||
}
|
||||
|
||||
function seededDocument(text: string): Y.Doc {
|
||||
const doc = new Y.Doc()
|
||||
replaceYdocContent(doc, {
|
||||
name: "Runbook",
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { uid: "p1" },
|
||||
content: [{ type: "text", text }],
|
||||
},
|
||||
],
|
||||
},
|
||||
icon: "lucide:file",
|
||||
})
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
// a real key pair, so the mcp session route runs its actual signature,
|
||||
// issuer and audience checks rather than a stubbed verifier
|
||||
async function mcpKeys() {
|
||||
const { privateKey, publicKey } = await generateKeyPair("ES256", {
|
||||
extractable: true,
|
||||
})
|
||||
const jwk = await exportJWK(publicKey)
|
||||
|
||||
return {
|
||||
privateKey,
|
||||
jwks: { keys: [{ ...jwk, kid: "test-key", alg: "ES256" }] },
|
||||
}
|
||||
}
|
||||
|
||||
async function mcpToken(
|
||||
privateKey: CryptoKey,
|
||||
claims: JWTPayload = {},
|
||||
overrides: { issuer?: string; audience?: string } = {},
|
||||
) {
|
||||
return new SignJWT({
|
||||
azp: "client-1",
|
||||
org_id: "org-1",
|
||||
scope: "documents:read documents:write",
|
||||
...claims,
|
||||
})
|
||||
.setProtectedHeader({ alg: "ES256", kid: "test-key" })
|
||||
.setSubject(claims.sub ?? "user-1")
|
||||
.setIssuer(overrides.issuer ?? ENV.mcpTokenIssuer)
|
||||
.setAudience(overrides.audience ?? ENV.mcpResource)
|
||||
.setExpirationTime("1h")
|
||||
.sign(privateKey)
|
||||
}
|
||||
|
||||
// a store answering for a user whose consent still stands but who has
|
||||
// left the organization the token names
|
||||
function memberlessStore() {
|
||||
const store = stubStore()
|
||||
store.isOrganizationMember.mockResolvedValue(false)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
function bearer(token: string) {
|
||||
return { headers: { Authorization: `Bearer ${token}` } }
|
||||
}
|
||||
|
||||
describe("createRoutes", () => {
|
||||
describe("GET /", () => {
|
||||
it("answers so a health check can see the service is up", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { app } = build()
|
||||
|
||||
const res = await app.request("/")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe("Hello Hono!")
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /auth-config", () => {
|
||||
it("publishes the sign-in methods the service actually accepts", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { app } = build({
|
||||
env: testEnv({
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: "id",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const res = await app.request("/auth-config")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
methods: ["email-password", "github"],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("/auth/**", () => {
|
||||
it("hands the raw request to better-auth", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { app, auth } = build()
|
||||
|
||||
const res = await app.request("/auth/get-session")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe("handled by better-auth")
|
||||
expect(auth.handler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /organizations/stats", () => {
|
||||
it("reports the slots left under the organization limit", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const store = stubStore()
|
||||
store.totalOrganizationCount.mockResolvedValue(40)
|
||||
const { app } = build({
|
||||
store,
|
||||
env: testEnv({ maxOrganizations: 50 }),
|
||||
})
|
||||
|
||||
const res = await app.request("/organizations/stats")
|
||||
|
||||
expect(await res.json()).toEqual({ availableSlots: 10 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("PUT /documents/:documentId/merge", () => {
|
||||
it.for([
|
||||
{ name: "a body that is not JSON", input: "not json" },
|
||||
{ name: "a body that is not an object", input: "null" },
|
||||
{
|
||||
name: "a body missing fromBranchId",
|
||||
input: '{"toBranchId":"b1"}',
|
||||
},
|
||||
{
|
||||
name: "a body missing toBranchId",
|
||||
input: '{"fromBranchId":"b2"}',
|
||||
},
|
||||
{
|
||||
name: "branch ids that are not strings",
|
||||
input: '{"fromBranchId":1,"toBranchId":2}',
|
||||
},
|
||||
])("rejects $name", async ({ input }, { expect }) => {
|
||||
const { app, core } = build()
|
||||
|
||||
const res = await app.request("/documents/doc1/merge", {
|
||||
method: "PUT",
|
||||
body: input,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(core.mergeBranches).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
// core validates the session and authorizes the merge; this
|
||||
// service only mirrors the result into the live document
|
||||
it("forwards the caller's headers to core", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { app, core } = build()
|
||||
|
||||
await app.request("/documents/doc1/merge", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
fromBranchId: "b2",
|
||||
toBranchId: "b1",
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: "auth.session=abc",
|
||||
},
|
||||
})
|
||||
|
||||
expect(core.mergeBranches).toHaveBeenCalledTimes(1)
|
||||
const [documentId, from, to, options] =
|
||||
core.mergeBranches.mock.calls[0] ?? []
|
||||
expect(documentId).toBe("doc1")
|
||||
expect(from).toBe("b2")
|
||||
expect(to).toBe("b1")
|
||||
expect(options?.headers?.get("cookie")).toBe(
|
||||
"auth.session=abc",
|
||||
)
|
||||
})
|
||||
|
||||
it("replaces the open target document with the merged content", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const target = seededDocument("Before the merge")
|
||||
const { registry } = stubRegistry({
|
||||
documents: new Map([["doc1-b1", target]]),
|
||||
})
|
||||
const core = stubCore()
|
||||
core.mergeBranches.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
documentName: "Runbook",
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: {
|
||||
uid: "p9",
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "After the merge",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
icon: "lucide:file",
|
||||
},
|
||||
})
|
||||
const { app } = build({ hocuspocus: registry, core })
|
||||
|
||||
await app.request("/documents/doc1/merge", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
fromBranchId: "b2",
|
||||
toBranchId: "b1",
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
const content = fragmentXml(target, "content")
|
||||
expect(content).toContain("After the merge")
|
||||
expect(content).not.toContain("Before the merge")
|
||||
})
|
||||
|
||||
// core sets rawContent to nil when it merges, so a restart
|
||||
// before the next store would rebuild the doc under a new
|
||||
// clientID and duplicate every block on reconnect
|
||||
it("persists the merged state immediately as a system write", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const target = seededDocument("Before the merge")
|
||||
const { registry } = stubRegistry({
|
||||
documents: new Map([["doc1-b1", target]]),
|
||||
})
|
||||
const { app, core } = build({ hocuspocus: registry })
|
||||
|
||||
await app.request("/documents/doc1/merge", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
fromBranchId: "b2",
|
||||
toBranchId: "b1",
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(core.storeBranchContent).toHaveBeenCalledTimes(1)
|
||||
const [documentId, branchId, update] =
|
||||
core.storeBranchContent.mock.calls[0] ?? []
|
||||
expect(documentId).toBe("doc1")
|
||||
expect(branchId).toBe("b1")
|
||||
expect(update?.system).toBe(true)
|
||||
expect(update?.maintainers).toEqual([])
|
||||
})
|
||||
|
||||
it("leaves nothing to mirror when the branch has no open document", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { app, core } = build()
|
||||
|
||||
const res = await app.request("/documents/doc1/merge", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
fromBranchId: "b2",
|
||||
toBranchId: "b1",
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(core.storeBranchContent).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("does not touch the open document when core refuses the merge", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const target = seededDocument("Before the merge")
|
||||
const { registry } = stubRegistry({
|
||||
documents: new Map([["doc1-b1", target]]),
|
||||
})
|
||||
const core = stubCore()
|
||||
core.mergeBranches.mockResolvedValue({
|
||||
status: 409,
|
||||
data: {
|
||||
documentName: "",
|
||||
content: null,
|
||||
icon: "",
|
||||
},
|
||||
})
|
||||
const { app } = build({ hocuspocus: registry, core })
|
||||
|
||||
const res = await app.request("/documents/doc1/merge", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
fromBranchId: "b2",
|
||||
toBranchId: "b1",
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
expect(fragmentXml(target, "content")).toContain(
|
||||
"Before the merge",
|
||||
)
|
||||
expect(core.storeBranchContent).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("answers 200 even when persisting the merged state fails", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const target = seededDocument("Before the merge")
|
||||
const { registry } = stubRegistry({
|
||||
documents: new Map([["doc1-b1", target]]),
|
||||
})
|
||||
const core = stubCore()
|
||||
core.storeBranchContent.mockRejectedValue(
|
||||
new Error("core unreachable"),
|
||||
)
|
||||
const { app } = build({ hocuspocus: registry, core })
|
||||
|
||||
const res = await app.request("/documents/doc1/merge", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
fromBranchId: "b2",
|
||||
toBranchId: "b1",
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
// the caller gets core's own answer rather than a generic 500,
|
||||
// so a rejected merge reads the same through the proxy as it
|
||||
// would directly
|
||||
it("forwards core's status and body when core rejects the merge", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const core = stubCore()
|
||||
core.mergeBranches.mockRejectedValue({
|
||||
response: {
|
||||
status: 403,
|
||||
data: { error: "not a maintainer" },
|
||||
},
|
||||
})
|
||||
const { app } = build({ core })
|
||||
|
||||
const res = await app.request("/documents/doc1/merge", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
fromBranchId: "b2",
|
||||
toBranchId: "b1",
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
expect(await res.json()).toEqual({
|
||||
error: "not a maintainer",
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
name: "a plain transport failure",
|
||||
input: new Error("connect ECONNREFUSED"),
|
||||
},
|
||||
{
|
||||
name: "a rejection carrying no response",
|
||||
input: { message: "boom" },
|
||||
},
|
||||
{
|
||||
name: "a response with no usable status",
|
||||
input: {
|
||||
response: { status: "403", data: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a rejection that is not an object",
|
||||
input: "boom",
|
||||
},
|
||||
])("answers 500 for $name", async ({ input }, { expect }) => {
|
||||
const core = stubCore()
|
||||
core.mergeBranches.mockRejectedValue(input)
|
||||
const { app } = build({ core })
|
||||
|
||||
const res = await app.request("/documents/doc1/merge", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
fromBranchId: "b2",
|
||||
toBranchId: "b1",
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
expect(await res.json()).toEqual({
|
||||
error: "failed to merge",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /internal/mcp/session", () => {
|
||||
it.for([
|
||||
{ name: "no Authorization header", input: undefined },
|
||||
{ name: "a non-bearer scheme", input: "Basic abc" },
|
||||
{ name: "an empty bearer", input: "Bearer" },
|
||||
])("rejects $name", async ({ input }, { expect }) => {
|
||||
const { app } = build()
|
||||
|
||||
const res = await app.request(
|
||||
"/internal/mcp/session",
|
||||
input
|
||||
? { headers: { Authorization: input } }
|
||||
: {},
|
||||
)
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(await res.json()).toEqual({
|
||||
error: "missing bearer token",
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects a token that is not a signed JWT", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { app } = build()
|
||||
|
||||
const res = await app.request(
|
||||
"/internal/mcp/session",
|
||||
bearer("not-a-jwt"),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(await res.json()).toEqual({
|
||||
error: "invalid token",
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ name: "another issuer", input: "issuer" as const },
|
||||
{
|
||||
name: "another audience",
|
||||
input: "audience" as const,
|
||||
},
|
||||
])(
|
||||
"rejects a token minted for $name",
|
||||
async ({ input }, { expect }) => {
|
||||
const { privateKey, jwks } = await mcpKeys()
|
||||
const auth = stubAuth()
|
||||
auth.api.getJwks.mockResolvedValue(jwks)
|
||||
const { app } = build({ auth })
|
||||
const token = await mcpToken(
|
||||
privateKey,
|
||||
{},
|
||||
{ [input]: "http://evil.example" },
|
||||
)
|
||||
|
||||
const res = await app.request(
|
||||
"/internal/mcp/session",
|
||||
bearer(token),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
},
|
||||
)
|
||||
|
||||
it.for([
|
||||
{ name: "no subject", input: { sub: "" } },
|
||||
{ name: "no client", input: { azp: undefined } },
|
||||
{
|
||||
name: "no organization",
|
||||
input: { org_id: undefined },
|
||||
},
|
||||
])(
|
||||
"rejects a token with $name",
|
||||
async ({ input }, { expect }) => {
|
||||
const { privateKey, jwks } = await mcpKeys()
|
||||
const auth = stubAuth()
|
||||
auth.api.getJwks.mockResolvedValue(jwks)
|
||||
const { app } = build({
|
||||
auth,
|
||||
})
|
||||
const token = await mcpToken(privateKey, input)
|
||||
|
||||
const res = await app.request(
|
||||
"/internal/mcp/session",
|
||||
bearer(token),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(await res.json()).toEqual({
|
||||
error: "invalid token",
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// revoking a client from the settings UI has to cut off the
|
||||
// access tokens it already holds, which is why the consent row
|
||||
// is checked on every call rather than only at issuance
|
||||
it("rejects a token whose consent has been revoked", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { privateKey, jwks } = await mcpKeys()
|
||||
const auth = stubAuth()
|
||||
auth.api.getJwks.mockResolvedValue(jwks)
|
||||
const store = stubStore()
|
||||
store.hasOAuthConsent.mockResolvedValue(false)
|
||||
const { app } = build({ auth, store })
|
||||
const token = await mcpToken(privateKey)
|
||||
|
||||
const res = await app.request(
|
||||
"/internal/mcp/session",
|
||||
bearer(token),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(await res.json()).toEqual({
|
||||
error: "consent revoked",
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects a token for an organization the user has left", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { privateKey, jwks } = await mcpKeys()
|
||||
const auth = stubAuth()
|
||||
auth.api.getJwks.mockResolvedValue(jwks)
|
||||
const { app } = build({
|
||||
auth,
|
||||
store: memberlessStore(),
|
||||
})
|
||||
const token = await mcpToken(privateKey)
|
||||
|
||||
const res = await app.request(
|
||||
"/internal/mcp/session",
|
||||
bearer(token),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(await res.json()).toEqual({
|
||||
error: "not an organization member",
|
||||
})
|
||||
})
|
||||
|
||||
it("describes the session behind a valid token", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { privateKey, jwks } = await mcpKeys()
|
||||
const auth = stubAuth()
|
||||
auth.api.getJwks.mockResolvedValue(jwks)
|
||||
const { app } = build({
|
||||
auth,
|
||||
})
|
||||
const token = await mcpToken(privateKey)
|
||||
|
||||
const res = await app.request(
|
||||
"/internal/mcp/session",
|
||||
bearer(token),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
clientId: "client-1",
|
||||
scopes: ["documents:read", "documents:write"],
|
||||
})
|
||||
})
|
||||
|
||||
it("reports no scopes for a token that carries none", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { privateKey, jwks } = await mcpKeys()
|
||||
const auth = stubAuth()
|
||||
auth.api.getJwks.mockResolvedValue(jwks)
|
||||
const { app } = build({
|
||||
auth,
|
||||
})
|
||||
const token = await mcpToken(privateKey, {
|
||||
scope: undefined,
|
||||
})
|
||||
|
||||
const res = await app.request(
|
||||
"/internal/mcp/session",
|
||||
bearer(token),
|
||||
)
|
||||
|
||||
const body = (await res.json()) as { scopes: string[] }
|
||||
|
||||
expect(body.scopes).toEqual([])
|
||||
})
|
||||
|
||||
it("answers 500 when the consent lookup fails", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { privateKey, jwks } = await mcpKeys()
|
||||
const auth = stubAuth()
|
||||
auth.api.getJwks.mockResolvedValue(jwks)
|
||||
const store = stubStore()
|
||||
store.hasOAuthConsent.mockRejectedValue(
|
||||
new Error("connection terminated"),
|
||||
)
|
||||
const { app } = build({ auth, store })
|
||||
const token = await mcpToken(privateKey)
|
||||
|
||||
const res = await app.request(
|
||||
"/internal/mcp/session",
|
||||
bearer(token),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
expect(await res.json()).toEqual({
|
||||
error: "token validation failed",
|
||||
})
|
||||
})
|
||||
|
||||
it("fetches the key set once across repeated calls", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { privateKey, jwks } = await mcpKeys()
|
||||
const auth = stubAuth()
|
||||
auth.api.getJwks.mockResolvedValue(jwks)
|
||||
const { app } = build({
|
||||
auth,
|
||||
})
|
||||
const token = await mcpToken(privateKey)
|
||||
|
||||
await app.request(
|
||||
"/internal/mcp/session",
|
||||
bearer(token),
|
||||
)
|
||||
await app.request(
|
||||
"/internal/mcp/session",
|
||||
bearer(token),
|
||||
)
|
||||
|
||||
expect(auth.api.getJwks).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// a signing key rotates without warning, so a cached key set
|
||||
// that no longer verifies is refreshed once before the token is
|
||||
// called invalid
|
||||
it("refetches the key set once before rejecting after a rotation", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const stale = await mcpKeys()
|
||||
const current = await mcpKeys()
|
||||
const auth = stubAuth()
|
||||
auth.api.getJwks
|
||||
.mockResolvedValueOnce(stale.jwks)
|
||||
.mockResolvedValue(current.jwks)
|
||||
const { app } = build({
|
||||
auth,
|
||||
})
|
||||
const token = await mcpToken(current.privateKey)
|
||||
|
||||
const res = await app.request(
|
||||
"/internal/mcp/session",
|
||||
bearer(token),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(auth.api.getJwks).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /internal/documents/:documentId/branches/:branchId/operations", () => {
|
||||
const path = "/internal/documents/doc1/branches/b1/operations"
|
||||
|
||||
it.for([
|
||||
{ name: "a body that is not JSON", input: "not json" },
|
||||
{ name: "a body with no operations", input: "{}" },
|
||||
{
|
||||
name: "an operations field that is not an array",
|
||||
input: '{"operations":"append"}',
|
||||
},
|
||||
])("rejects $name", async ({ input }, { expect }) => {
|
||||
const { registry, openDirectConnection } =
|
||||
stubRegistry()
|
||||
const { app } = build({ hocuspocus: registry })
|
||||
|
||||
const res = await app.request(path, {
|
||||
method: "POST",
|
||||
body: input,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(openDirectConnection).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("applies the batch to the branch's live document", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const doc = new Y.Doc()
|
||||
const { registry, openDirectConnection, disconnect } =
|
||||
stubRegistry({
|
||||
transact: (fn) => {
|
||||
fn(doc)
|
||||
|
||||
return Promise.resolve()
|
||||
},
|
||||
})
|
||||
const { app } = build({ hocuspocus: registry })
|
||||
|
||||
const res = await app.request(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
operations: [
|
||||
{
|
||||
kind: "set_icon",
|
||||
icon: "lucide:siren",
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
applied: 1,
|
||||
errors: [],
|
||||
})
|
||||
expect(openDirectConnection).toHaveBeenCalledWith(
|
||||
"doc1-b1",
|
||||
)
|
||||
expect(doc.getText("icon").toJSON()).toBe(
|
||||
"lucide:siren",
|
||||
)
|
||||
expect(disconnect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("reports a failing operation without failing the request", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { registry } = stubRegistry()
|
||||
const { app } = build({ hocuspocus: registry })
|
||||
|
||||
const res = await app.request(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
operations: [
|
||||
{
|
||||
kind: "delete",
|
||||
block_uid: "missing",
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
applied: number
|
||||
errors: { index: number }[]
|
||||
}
|
||||
expect(body.applied).toBe(0)
|
||||
expect(body.errors).toHaveLength(1)
|
||||
expect(body.errors[0]?.index).toBe(0)
|
||||
})
|
||||
|
||||
it("answers 500 when the document cannot be opened", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { registry } = stubRegistry({
|
||||
open: () =>
|
||||
Promise.reject(
|
||||
new Error("no such document"),
|
||||
),
|
||||
})
|
||||
const { app } = build({ hocuspocus: registry })
|
||||
|
||||
const res = await app.request(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ operations: [] }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
expect(await res.json()).toEqual({
|
||||
error: "failed to open document",
|
||||
})
|
||||
})
|
||||
|
||||
it("releases the connection when applying the batch throws", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { registry, disconnect } = stubRegistry({
|
||||
transact: () =>
|
||||
Promise.reject(
|
||||
new Error("transaction failed"),
|
||||
),
|
||||
})
|
||||
const { app } = build({ hocuspocus: registry })
|
||||
|
||||
const res = await app.request(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ operations: [] }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
expect(await res.json()).toEqual({
|
||||
error: "failed to apply operations",
|
||||
})
|
||||
expect(disconnect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("still answers when releasing the connection fails", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { registry, disconnect } = stubRegistry()
|
||||
disconnect.mockRejectedValue(
|
||||
new Error("already closed"),
|
||||
)
|
||||
const { app } = build({ hocuspocus: registry })
|
||||
|
||||
const res = await app.request(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ operations: [] }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe("cors", () => {
|
||||
it("allows a trusted origin to send credentials", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { app } = build()
|
||||
|
||||
const res = await app.request("/auth-config", {
|
||||
headers: { Origin: "http://localhost:8080" },
|
||||
})
|
||||
|
||||
expect(
|
||||
res.headers.get("Access-Control-Allow-Origin"),
|
||||
).toBe("http://localhost:8080")
|
||||
expect(
|
||||
res.headers.get(
|
||||
"Access-Control-Allow-Credentials",
|
||||
),
|
||||
).toBe("true")
|
||||
})
|
||||
|
||||
it("does not answer an untrusted origin with its own origin", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const { app } = build()
|
||||
|
||||
const res = await app.request("/auth-config", {
|
||||
headers: { Origin: "http://evil.example" },
|
||||
})
|
||||
|
||||
expect(
|
||||
res.headers.get("Access-Control-Allow-Origin"),
|
||||
).not.toBe("http://evil.example")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -3,343 +3,440 @@ import { Hono } from "hono"
|
|||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import { cors } from "hono/cors"
|
||||
import { createLocalJWKSet, jwtVerify } from "jose"
|
||||
import {
|
||||
auth,
|
||||
totalOrganizationCount,
|
||||
AUTH_METHODS,
|
||||
MAX_ORGANIZATIONS,
|
||||
MCP_RESOURCE,
|
||||
MCP_TOKEN_ISSUER,
|
||||
} from "./auth.js"
|
||||
import { db } from "./database.js"
|
||||
import * as Y from "yjs"
|
||||
import { hocuspocus, toAxiosHeaders } from "./hocuspocus.js"
|
||||
import type { Store } from "./db.js"
|
||||
import type { CoreClient, MergedDocument } from "./core.js"
|
||||
import type { Env } from "./env.js"
|
||||
import { authMethods } from "./auth.js"
|
||||
import { toAxiosHeaders } from "./headers.js"
|
||||
import { bestEffort } from "./reporting.js"
|
||||
import { replaceYdocContent } from "./ydocument.js"
|
||||
import { applyOperations, type Operation } from "./operations.js"
|
||||
import axios from "axios"
|
||||
|
||||
const app = new Hono()
|
||||
const backendUrl = process.env.OXYNOTE_AUTH_REALTIME_BACKEND_URL
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: (
|
||||
(process.env.OXYNOTE_AUTH_REALTIME_TRUSTED_ORIGINS ||
|
||||
"") as string
|
||||
).split(","),
|
||||
allowMethods: [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"OPTIONS",
|
||||
],
|
||||
allowHeaders: ["Content-Type", "Authorization"],
|
||||
credentials: true,
|
||||
}),
|
||||
)
|
||||
|
||||
app.get("/", (c) => {
|
||||
return c.text("Hello Hono!")
|
||||
})
|
||||
|
||||
app.on(["POST", "GET"], "/auth/**", (c) => {
|
||||
return auth.handler(c.req.raw)
|
||||
})
|
||||
|
||||
// public auth configuration for the login/signup pages: which social
|
||||
// providers are configured (and, later, other signup-related capability
|
||||
// flags). Lives outside the better-auth /auth/** namespace.
|
||||
app.get("/auth-config", (c) => {
|
||||
return c.json({ methods: AUTH_METHODS })
|
||||
})
|
||||
|
||||
app.get("/organizations/stats", async (c) => {
|
||||
const count = await totalOrganizationCount()
|
||||
return c.json({ availableSlots: MAX_ORGANIZATIONS - count })
|
||||
})
|
||||
|
||||
// auth is delegated: the caller's headers (including the session cookie)
|
||||
// are forwarded to core, whose middleware validates the session and
|
||||
// authorizes the merge. The in-memory Y.Doc mutation below only runs when
|
||||
// core responds 200, so an unauthenticated caller changes nothing.
|
||||
app.put("/documents/:documentId/merge", async (c) => {
|
||||
const documentId = c.req.param("documentId")
|
||||
|
||||
let fromBranchId: string
|
||||
let toBranchId: string
|
||||
|
||||
try {
|
||||
const body = await c.req.json()
|
||||
fromBranchId = body.fromBranchId
|
||||
toBranchId = body.toBranchId
|
||||
} catch {
|
||||
return c.json({ error: "invalid request body" }, 400)
|
||||
// the two things the routes ask of better-auth: the request handler
|
||||
// mounted under /auth/**, and the local JWKS the MCP session endpoint
|
||||
// verifies bearer tokens against.
|
||||
export interface AuthHandler {
|
||||
handler(request: Request): Promise<Response>
|
||||
api: {
|
||||
getJwks(): Promise<Parameters<typeof createLocalJWKSet>[0]>
|
||||
}
|
||||
|
||||
if (!fromBranchId || !toBranchId) {
|
||||
return c.json(
|
||||
{ error: "fromBranchId and toBranchId are required" },
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.put(
|
||||
`${backendUrl}/api/documents/${documentId}/merge`,
|
||||
{ fromBranchId, toBranchId },
|
||||
{
|
||||
headers: toAxiosHeaders(c.req.raw.headers),
|
||||
},
|
||||
)
|
||||
|
||||
if (response.status === 200) {
|
||||
const mergedData = {
|
||||
name: response.data.documentName,
|
||||
content: response.data.content,
|
||||
icon: response.data.icon,
|
||||
}
|
||||
|
||||
// update the in-memory target branch Y.Doc using direct cloning to
|
||||
// avoid Y.applyUpdate merge semantics that cause content
|
||||
// duplication when source and target docs have different
|
||||
// client origins.
|
||||
const toDocument = hocuspocus.documents.get(
|
||||
`${documentId}-${toBranchId}`,
|
||||
)
|
||||
if (toDocument) {
|
||||
replaceYdocContent(toDocument, mergedData)
|
||||
|
||||
// persist rawContent immediately because MergeBranches
|
||||
// sets rawContent to nil in the database. Without
|
||||
// this, a server restart before onStoreDocument
|
||||
// would trigger onLoadDocument's first-time path,
|
||||
// creating a new Y.Doc with a different clientID
|
||||
// that CRDT-merges with reconnecting clients and
|
||||
// duplicates content.
|
||||
const rawContent = Buffer.from(
|
||||
Y.encodeStateAsUpdate(toDocument),
|
||||
).toString("base64")
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`${backendUrl}/api/x/documents/${documentId}/branch/${toBranchId}`,
|
||||
{
|
||||
name: mergedData.name,
|
||||
icon: mergedData.icon,
|
||||
content: mergedData.content,
|
||||
maintainers: [],
|
||||
rawContent,
|
||||
system: true,
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(
|
||||
response.data,
|
||||
response.status as ContentfulStatusCode,
|
||||
)
|
||||
} catch (error: any) {
|
||||
Sentry.captureException(error)
|
||||
|
||||
if (error.response) {
|
||||
return c.json(
|
||||
error.response.data,
|
||||
error.response.status,
|
||||
)
|
||||
}
|
||||
|
||||
return c.json({ error: "failed to merge" }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// cached JWKS for MCP access-token verification. Cleared on a failed
|
||||
// verification so a key rotation is picked up on the next request.
|
||||
let mcpKeySet: ReturnType<typeof createLocalJWKSet> | null = null
|
||||
|
||||
async function mcpTokenKeySet(): Promise<ReturnType<typeof createLocalJWKSet>> {
|
||||
if (!mcpKeySet) {
|
||||
const jwks = await auth.api.getJwks()
|
||||
mcpKeySet = createLocalJWKSet(jwks)
|
||||
}
|
||||
|
||||
return mcpKeySet
|
||||
}
|
||||
|
||||
// Server-to-server endpoint called by core to validate an MCP bearer token.
|
||||
// Signature, issuer, audience and expiry are checked against the local JWKS;
|
||||
// on top of that the token is only as alive as the consent that produced it,
|
||||
// so revoking a client from the settings UI cuts off its outstanding access
|
||||
// tokens immediately. Like the other /internal routes, this is protected by
|
||||
// the container network plus Caddy's 403 on /auth-realtime/api/internal/*.
|
||||
app.get("/internal/mcp/session", async (c) => {
|
||||
const header = c.req.header("Authorization") ?? ""
|
||||
if (!header.toLowerCase().startsWith("bearer ")) {
|
||||
return c.json({ error: "missing bearer token" }, 401)
|
||||
export interface DirectConnection {
|
||||
transact(transaction: (doc: Y.Doc) => void): Promise<void>
|
||||
disconnect(): void | Promise<void>
|
||||
}
|
||||
|
||||
// the part of the hocuspocus server the routes reach into: the live
|
||||
// in-memory documents, and the direct connections the operations endpoint
|
||||
// opens against them.
|
||||
export interface DocumentRegistry {
|
||||
documents: { get(documentName: string): Y.Doc | undefined }
|
||||
openDirectConnection(documentName: string): Promise<DirectConnection>
|
||||
}
|
||||
|
||||
export interface RouteDeps {
|
||||
env: Env
|
||||
auth: AuthHandler
|
||||
store: Store
|
||||
hocuspocus: DocumentRegistry
|
||||
core: CoreClient
|
||||
}
|
||||
|
||||
// request bodies arrive as unvalidated JSON, so every field a route reads
|
||||
// is taken through a narrowing read rather than trusted from a type
|
||||
// annotation.
|
||||
function readString(source: unknown, key: string): string | undefined {
|
||||
if (!source || typeof source !== "object") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const token = header.slice("bearer ".length).trim()
|
||||
const value = (source as Record<string, unknown>)[key]
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = (
|
||||
await jwtVerify(token, await mcpTokenKeySet(), {
|
||||
issuer: MCP_TOKEN_ISSUER,
|
||||
audience: MCP_RESOURCE,
|
||||
})
|
||||
).payload
|
||||
} catch {
|
||||
// the cached key set may predate a key rotation; retry once
|
||||
// with a fresh set before rejecting.
|
||||
mcpKeySet = null
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
try {
|
||||
payload = (
|
||||
await jwtVerify(token, await mcpTokenKeySet(), {
|
||||
issuer: MCP_TOKEN_ISSUER,
|
||||
audience: MCP_RESOURCE,
|
||||
})
|
||||
).payload
|
||||
} catch {
|
||||
return c.json({ error: "invalid token" }, 401)
|
||||
}
|
||||
// an axios rejection carrying core's own response, which the merge route
|
||||
// forwards verbatim so the caller sees core's status and error body rather
|
||||
// than a generic 500.
|
||||
function upstreamResponse(
|
||||
error: unknown,
|
||||
): { status: ContentfulStatusCode; data: any } | undefined {
|
||||
if (!error || typeof error !== "object") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const userId = payload.sub
|
||||
const clientId =
|
||||
typeof payload.azp === "string" ? payload.azp : undefined
|
||||
const organizationId =
|
||||
typeof payload.org_id === "string" ? payload.org_id : undefined
|
||||
const scopes =
|
||||
typeof payload.scope === "string"
|
||||
? payload.scope.split(" ").filter(Boolean)
|
||||
: []
|
||||
|
||||
if (!userId || !clientId || !organizationId) {
|
||||
return c.json({ error: "invalid token" }, 401)
|
||||
const response = (error as { response?: unknown }).response
|
||||
if (!response || typeof response !== "object") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const consent = await db
|
||||
.selectFrom("oauth_consents")
|
||||
.where("client_id", "=", clientId)
|
||||
.where("fk_user_id", "=", userId)
|
||||
.select("client_id")
|
||||
.executeTakeFirst()
|
||||
|
||||
if (!consent) {
|
||||
return c.json({ error: "consent revoked" }, 401)
|
||||
}
|
||||
|
||||
const member = await db
|
||||
.selectFrom("organization_members")
|
||||
.where("fk_user_id", "=", userId)
|
||||
.where("fk_organization_id", "=", organizationId)
|
||||
.select("fk_user_id")
|
||||
.executeTakeFirst()
|
||||
|
||||
if (!member) {
|
||||
return c.json(
|
||||
{ error: "not an organization member" },
|
||||
401,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
return c.json({ error: "token validation failed" }, 500)
|
||||
const { status, data } = response as {
|
||||
status?: unknown
|
||||
data?: unknown
|
||||
}
|
||||
if (typeof status !== "number") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return c.json({
|
||||
userId,
|
||||
organizationId,
|
||||
clientId,
|
||||
scopes,
|
||||
expiresAt: payload.exp,
|
||||
return { status: status as ContentfulStatusCode, data }
|
||||
}
|
||||
|
||||
export function createRoutes({
|
||||
env,
|
||||
auth,
|
||||
store,
|
||||
hocuspocus,
|
||||
core,
|
||||
}: RouteDeps): Hono {
|
||||
const app = new Hono()
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: env.trustedOrigins,
|
||||
allowMethods: [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"OPTIONS",
|
||||
],
|
||||
allowHeaders: ["Content-Type", "Authorization"],
|
||||
credentials: true,
|
||||
}),
|
||||
)
|
||||
|
||||
app.get("/", (c) => {
|
||||
return c.text("Hello Hono!")
|
||||
})
|
||||
})
|
||||
|
||||
// Server-to-server endpoint called by the Go assistant to apply
|
||||
// edit operations to a live Y.Doc. Each request opens (or reuses) a
|
||||
// direct connection to the document, runs the batch inside a single
|
||||
// Y.Doc transaction, and disconnects. Hocuspocus handles
|
||||
// broadcasting the resulting update to subscribed clients and
|
||||
// persisting via onStoreDocument.
|
||||
//
|
||||
// This route is service-to-service only, like the Go `/api/x/...`
|
||||
// endpoints: core calls it directly over the container network, and
|
||||
// the reverse proxy blocks `/auth-realtime/api/internal/*` at the
|
||||
// front door.
|
||||
app.post(
|
||||
"/internal/documents/:documentId/branches/:branchId/operations",
|
||||
async (c) => {
|
||||
app.on(["POST", "GET"], "/auth/**", (c) => {
|
||||
return auth.handler(c.req.raw)
|
||||
})
|
||||
|
||||
// public auth configuration for the login/signup pages: which social
|
||||
// providers are configured (and, later, other signup-related
|
||||
// capability flags). Lives outside the better-auth /auth/**
|
||||
// namespace.
|
||||
app.get("/auth-config", (c) => {
|
||||
return c.json({ methods: authMethods(env) })
|
||||
})
|
||||
|
||||
app.get("/organizations/stats", async (c) => {
|
||||
const count = await store.totalOrganizationCount()
|
||||
return c.json({ availableSlots: env.maxOrganizations - count })
|
||||
})
|
||||
|
||||
// auth is delegated: the caller's headers (including the session
|
||||
// cookie) are forwarded to core, whose middleware validates the
|
||||
// session and authorizes the merge. The in-memory Y.Doc mutation
|
||||
// below only runs when core responds 200, so an unauthenticated
|
||||
// caller changes nothing.
|
||||
app.put("/documents/:documentId/merge", async (c) => {
|
||||
const documentId = c.req.param("documentId")
|
||||
const branchId = c.req.param("branchId")
|
||||
|
||||
let body: { operations?: Operation[] }
|
||||
let body: unknown
|
||||
try {
|
||||
body = await c.req.json()
|
||||
} catch {
|
||||
return c.json({ error: "invalid request body" }, 400)
|
||||
}
|
||||
|
||||
const ops = body.operations
|
||||
if (!Array.isArray(ops)) {
|
||||
const fromBranchId = readString(body, "fromBranchId")
|
||||
const toBranchId = readString(body, "toBranchId")
|
||||
|
||||
if (!fromBranchId || !toBranchId) {
|
||||
return c.json(
|
||||
{ error: "operations array is required" },
|
||||
{
|
||||
error: "fromBranchId and toBranchId are required",
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
// Hocuspocus addresses documents by `documentId-branchId`; the
|
||||
// Go caller resolves "default" to a concrete branchId before
|
||||
// sending so this path can be a direct lookup.
|
||||
const documentName = `${documentId}-${branchId}`
|
||||
|
||||
let connection
|
||||
try {
|
||||
connection =
|
||||
await hocuspocus.openDirectConnection(
|
||||
documentName,
|
||||
const response = await core.mergeBranches(
|
||||
documentId,
|
||||
fromBranchId,
|
||||
toBranchId,
|
||||
{ headers: toAxiosHeaders(c.req.raw.headers) },
|
||||
)
|
||||
|
||||
if (response.status === 200) {
|
||||
await applyMergeToOpenDocument(
|
||||
documentId,
|
||||
toBranchId,
|
||||
response.data,
|
||||
)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
return c.json({ error: "failed to open document" }, 500)
|
||||
}
|
||||
|
||||
return c.json(
|
||||
response.data,
|
||||
response.status as ContentfulStatusCode,
|
||||
)
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
|
||||
const upstream = upstreamResponse(error)
|
||||
if (upstream) {
|
||||
return c.json(upstream.data, upstream.status)
|
||||
}
|
||||
|
||||
return c.json({ error: "failed to merge" }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// mirrors the merge into the target branch's live Y.Doc, if one is
|
||||
// open. Direct cloning rather than Y.applyUpdate: source and target
|
||||
// docs have different client origins, and merging them CRDT-style
|
||||
// duplicates the content instead of replacing it.
|
||||
async function applyMergeToOpenDocument(
|
||||
documentId: string,
|
||||
toBranchId: string,
|
||||
merged: MergedDocument,
|
||||
): Promise<void> {
|
||||
const toDocument = hocuspocus.documents.get(
|
||||
`${documentId}-${toBranchId}`,
|
||||
)
|
||||
if (!toDocument) {
|
||||
return
|
||||
}
|
||||
|
||||
replaceYdocContent(toDocument, {
|
||||
name: merged.documentName,
|
||||
content: merged.content,
|
||||
icon: merged.icon,
|
||||
})
|
||||
|
||||
// persist rawContent immediately because MergeBranches sets
|
||||
// rawContent to nil in the database. Without this, a server
|
||||
// restart before onStoreDocument would trigger
|
||||
// onLoadDocument's first-time path, creating a new Y.Doc with
|
||||
// a different clientID that CRDT-merges with reconnecting
|
||||
// clients and duplicates content.
|
||||
await bestEffort(() =>
|
||||
core.storeBranchContent(documentId, toBranchId, {
|
||||
name: merged.documentName,
|
||||
icon: merged.icon,
|
||||
content: merged.content,
|
||||
maintainers: [],
|
||||
rawContent: Buffer.from(
|
||||
Y.encodeStateAsUpdate(toDocument),
|
||||
).toString("base64"),
|
||||
system: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// cached JWKS for MCP access-token verification. Cleared on a failed
|
||||
// verification so a key rotation is picked up on the next request.
|
||||
let mcpKeySet: ReturnType<typeof createLocalJWKSet> | null = null
|
||||
|
||||
async function mcpTokenKeySet(): Promise<
|
||||
ReturnType<typeof createLocalJWKSet>
|
||||
> {
|
||||
mcpKeySet ??= createLocalJWKSet(await auth.api.getJwks())
|
||||
|
||||
return mcpKeySet
|
||||
}
|
||||
|
||||
async function verifyMCPToken(token: string) {
|
||||
const options = {
|
||||
issuer: env.mcpTokenIssuer,
|
||||
audience: env.mcpResource,
|
||||
}
|
||||
|
||||
try {
|
||||
let result: {
|
||||
applied: number
|
||||
errors: { index: number; message: string }[]
|
||||
} = {
|
||||
applied: 0,
|
||||
errors: [],
|
||||
return (
|
||||
await jwtVerify(
|
||||
token,
|
||||
await mcpTokenKeySet(),
|
||||
options,
|
||||
)
|
||||
).payload
|
||||
} catch {
|
||||
// the cached key set may predate a key rotation; retry
|
||||
// once with a fresh set before rejecting.
|
||||
mcpKeySet = null
|
||||
|
||||
return (
|
||||
await jwtVerify(
|
||||
token,
|
||||
await mcpTokenKeySet(),
|
||||
options,
|
||||
)
|
||||
).payload
|
||||
}
|
||||
}
|
||||
|
||||
// Server-to-server endpoint called by core to validate an MCP bearer
|
||||
// token. Signature, issuer, audience and expiry are checked against
|
||||
// the local JWKS; on top of that the token is only as alive as the
|
||||
// consent that produced it, so revoking a client from the settings UI
|
||||
// cuts off its outstanding access tokens immediately. Like the other
|
||||
// /internal routes, this is protected by the container network plus
|
||||
// Caddy's 403 on /auth-realtime/api/internal/*.
|
||||
app.get("/internal/mcp/session", async (c) => {
|
||||
const header = c.req.header("Authorization") ?? ""
|
||||
if (!header.toLowerCase().startsWith("bearer ")) {
|
||||
return c.json({ error: "missing bearer token" }, 401)
|
||||
}
|
||||
|
||||
const token = header.slice("bearer ".length).trim()
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = await verifyMCPToken(token)
|
||||
} catch {
|
||||
return c.json({ error: "invalid token" }, 401)
|
||||
}
|
||||
|
||||
const userId = payload.sub
|
||||
const clientId =
|
||||
typeof payload.azp === "string"
|
||||
? payload.azp
|
||||
: undefined
|
||||
const organizationId =
|
||||
typeof payload.org_id === "string"
|
||||
? payload.org_id
|
||||
: undefined
|
||||
const scopes =
|
||||
typeof payload.scope === "string"
|
||||
? payload.scope.split(" ").filter(Boolean)
|
||||
: []
|
||||
|
||||
if (!userId || !clientId || !organizationId) {
|
||||
return c.json({ error: "invalid token" }, 401)
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await store.hasOAuthConsent(clientId, userId))) {
|
||||
return c.json({ error: "consent revoked" }, 401)
|
||||
}
|
||||
|
||||
await connection.transact((doc) => {
|
||||
result = applyOperations(doc, ops)
|
||||
})
|
||||
|
||||
return c.json(result, 200)
|
||||
if (
|
||||
!(await store.isOrganizationMember(
|
||||
userId,
|
||||
organizationId,
|
||||
))
|
||||
) {
|
||||
return c.json(
|
||||
{ error: "not an organization member" },
|
||||
401,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
return c.json(
|
||||
{ error: "failed to apply operations" },
|
||||
500,
|
||||
)
|
||||
} finally {
|
||||
return c.json({ error: "token validation failed" }, 500)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
userId,
|
||||
organizationId,
|
||||
clientId,
|
||||
scopes,
|
||||
expiresAt: payload.exp,
|
||||
})
|
||||
})
|
||||
|
||||
// Server-to-server endpoint called by the Go assistant to apply
|
||||
// edit operations to a live Y.Doc. Each request opens (or reuses) a
|
||||
// direct connection to the document, runs the batch inside a single
|
||||
// Y.Doc transaction, and disconnects. Hocuspocus handles
|
||||
// broadcasting the resulting update to subscribed clients and
|
||||
// persisting via onStoreDocument.
|
||||
//
|
||||
// This route is service-to-service only, like the Go `/api/x/...`
|
||||
// endpoints: core calls it directly over the container network, and
|
||||
// the reverse proxy blocks `/auth-realtime/api/internal/*` at the
|
||||
// front door.
|
||||
app.post(
|
||||
"/internal/documents/:documentId/branches/:branchId/operations",
|
||||
async (c) => {
|
||||
const documentId = c.req.param("documentId")
|
||||
const branchId = c.req.param("branchId")
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
await connection.disconnect()
|
||||
body = await c.req.json()
|
||||
} catch {
|
||||
return c.json(
|
||||
{ error: "invalid request body" },
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
const operations =
|
||||
body && typeof body === "object"
|
||||
? (body as { operations?: unknown })
|
||||
.operations
|
||||
: undefined
|
||||
|
||||
if (!Array.isArray(operations)) {
|
||||
return c.json(
|
||||
{
|
||||
error: "operations array is required",
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
// each op is validated as it is applied — a malformed
|
||||
// one lands in the batch's errors list rather than
|
||||
// failing the request.
|
||||
const ops = operations as Operation[]
|
||||
|
||||
// Hocuspocus addresses documents by
|
||||
// `documentId-branchId`; the Go caller resolves
|
||||
// "default" to a concrete branchId before sending so
|
||||
// this path can be a direct lookup.
|
||||
const documentName = `${documentId}-${branchId}`
|
||||
|
||||
let connection
|
||||
try {
|
||||
connection =
|
||||
await hocuspocus.openDirectConnection(
|
||||
documentName,
|
||||
)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
return c.json(
|
||||
{ error: "failed to open document" },
|
||||
500,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export default app
|
||||
try {
|
||||
let result: {
|
||||
applied: number
|
||||
errors: {
|
||||
index: number
|
||||
message: string
|
||||
}[]
|
||||
} = {
|
||||
applied: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
await connection.transact((doc) => {
|
||||
result = applyOperations(doc, ops)
|
||||
})
|
||||
|
||||
return c.json(result, 200)
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
return c.json(
|
||||
{ error: "failed to apply operations" },
|
||||
500,
|
||||
)
|
||||
} finally {
|
||||
await bestEffort(() => connection.disconnect())
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return app
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,9 +52,11 @@ export const CodeBlock = CodeBlockLowlight.extend<CodeBlockOptions>({
|
|||
node,
|
||||
HTMLAttributes,
|
||||
}: {
|
||||
node: any
|
||||
node: { attrs: Record<string, unknown> }
|
||||
HTMLAttributes: Record<string, any>
|
||||
}) {
|
||||
const language = node.attrs.language as string | null
|
||||
|
||||
return [
|
||||
"pre",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
|
|
@ -63,10 +65,8 @@ export const CodeBlock = CodeBlockLowlight.extend<CodeBlockOptions>({
|
|||
[
|
||||
"code",
|
||||
{
|
||||
class: node.attrs.language
|
||||
? this.options
|
||||
.languageClassPrefix +
|
||||
node.attrs.language
|
||||
class: language
|
||||
? `${this.options.languageClassPrefix ?? ""}${language}`
|
||||
: null,
|
||||
},
|
||||
0,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
83
server/auth-realtime/src/test-helpers.ts
Normal file
83
server/auth-realtime/src/test-helpers.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { vi, type Mock } from "vitest"
|
||||
import type * as Y from "yjs"
|
||||
import type { CoreClient } from "./core.js"
|
||||
import type { Store } from "./db.js"
|
||||
import type { Env } from "./env.js"
|
||||
|
||||
export type StubStore = {
|
||||
[K in keyof Store]: Mock<Store[K]>
|
||||
}
|
||||
|
||||
// the store as its callers see it: four questions with answers. Defaults
|
||||
// describe a user who belongs to an organization and a client whose
|
||||
// consent still stands, so a test states only the condition it is about.
|
||||
export function stubStore(): StubStore {
|
||||
return {
|
||||
totalOrganizationCount: vi.fn().mockResolvedValue(0),
|
||||
userOrganizationId: vi.fn().mockResolvedValue("org-1"),
|
||||
hasOAuthConsent: vi.fn().mockResolvedValue(true),
|
||||
isOrganizationMember: vi.fn().mockResolvedValue(true),
|
||||
}
|
||||
}
|
||||
|
||||
// the serialized XML of one of a document's fragments. toJSON is the
|
||||
// string-typed reader; toString comes off yjs's untyped base class.
|
||||
export function fragmentXml(doc: Y.Doc, name: string): string {
|
||||
return doc.getXmlFragment(name).toJSON()
|
||||
}
|
||||
|
||||
export type StubCore = {
|
||||
[K in keyof CoreClient]: Mock<CoreClient[K]>
|
||||
}
|
||||
|
||||
// a fully wired core client whose every call resolves. A test that
|
||||
// cares about one call overrides it and reads the others' call counts,
|
||||
// which is what makes "nothing else was persisted" assertable.
|
||||
export function stubCore(): StubCore {
|
||||
return {
|
||||
sendEmail: vi.fn().mockResolvedValue(undefined),
|
||||
initializeOrganization: vi.fn().mockResolvedValue(undefined),
|
||||
teardownOrganization: vi.fn().mockResolvedValue(undefined),
|
||||
fetchBranches: vi.fn().mockResolvedValue([]),
|
||||
fetchBranchContent: vi.fn().mockResolvedValue({
|
||||
documentName: "Doc",
|
||||
content: { type: "doc", content: [] },
|
||||
icon: "lucide:file",
|
||||
rawContent: null,
|
||||
}),
|
||||
storeBranchContent: vi.fn().mockResolvedValue(undefined),
|
||||
mergeBranches: vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
documentName: "Doc",
|
||||
content: { type: "doc", content: [] },
|
||||
icon: "lucide:file",
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// a complete, valid configuration. Tests override only the field under
|
||||
// test so an unrelated env change cannot quietly alter what they assert.
|
||||
export function testEnv(overrides: Partial<Env> = {}): Env {
|
||||
return {
|
||||
coreUrl: "http://core:8080",
|
||||
databaseDSN: "postgresql://devuser:devpass@postgres/devdb",
|
||||
valkeyUrl: "redis://valkey:6379",
|
||||
publicAuthBaseUrl: "http://localhost:8080/auth-realtime",
|
||||
authOrigin: "http://localhost:8080",
|
||||
betterAuthSecret: "sup3rs3cr3t",
|
||||
cookieDomain: "localhost",
|
||||
frontendUrl: "http://localhost:8080",
|
||||
organizationInvitationUrl:
|
||||
"http://localhost:8080/accept-invite",
|
||||
trustedOrigins: ["http://localhost:8080"],
|
||||
mcpResource: "http://localhost:8080/core/api/mcp",
|
||||
mcpTokenIssuer: "http://localhost:8080/api/auth",
|
||||
socialProviders: {},
|
||||
maxOrganizations: 100,
|
||||
maxOrganizationMembers: 5,
|
||||
rateLimitEnabled: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
21
server/auth-realtime/src/test-setup.ts
Normal file
21
server/auth-realtime/src/test-setup.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// yjs answers a read from a type that was never added to a document with a
|
||||
// console warning rather than an error, so a fixture that forgets to attach
|
||||
// its type still passes and the warning scrolls past — and vitest only
|
||||
// prints intercepted stderr under the verbose reporter, so it is invisible
|
||||
// in a piped or CI run. Turn it into a failure attributed to the test that
|
||||
// caused it.
|
||||
const PREMATURE_ACCESS = "Invalid access: Add Yjs type to a document"
|
||||
|
||||
const warn: typeof console.warn = console.warn.bind(console)
|
||||
|
||||
console.warn = (...args: unknown[]) => {
|
||||
const [first] = args
|
||||
|
||||
if (typeof first === "string" && first.includes(PREMATURE_ACCESS)) {
|
||||
throw new Error(
|
||||
`${first} — the yjs type under test was never added to a document, so it cannot be read from. Attach it first.`,
|
||||
)
|
||||
}
|
||||
|
||||
warn(...args)
|
||||
}
|
||||
312
server/auth-realtime/src/ydocument.test.ts
Normal file
312
server/auth-realtime/src/ydocument.test.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { describe, it } from "vitest"
|
||||
import * as Y from "yjs"
|
||||
import { fragmentXml } from "./test-helpers.js"
|
||||
import {
|
||||
cloneXmlElement,
|
||||
replaceYdocContent,
|
||||
transformer,
|
||||
type DocumentData,
|
||||
} from "./ydocument.js"
|
||||
|
||||
function paragraph(uid: string, text: string) {
|
||||
return {
|
||||
type: "paragraph",
|
||||
attrs: { uid },
|
||||
content: [{ type: "text", text }],
|
||||
}
|
||||
}
|
||||
|
||||
function documentData(overrides: Partial<DocumentData> = {}): DocumentData {
|
||||
return {
|
||||
name: "Runbook",
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [paragraph("p1", "Restart the service")],
|
||||
},
|
||||
icon: "lucide:file",
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// the serialized content fragment, which is where duplication shows up
|
||||
function contentXml(ydoc: Y.Doc): string {
|
||||
return fragmentXml(ydoc, "content")
|
||||
}
|
||||
|
||||
// yjs refuses to read a type that is not integrated into a document, so
|
||||
// both the elements under test and the clones taken from them are attached
|
||||
// to a doc of their own before anything inspects them
|
||||
function attached(element: Y.XmlElement): Y.XmlElement {
|
||||
new Y.Doc().getXmlFragment("content").insert(0, [element])
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
// Y.XmlElement.setAttribute is declared as accepting strings, but the
|
||||
// runtime stores arbitrary values — which is exactly what the clone has to
|
||||
// carry over
|
||||
function setAnyAttribute(
|
||||
element: Y.XmlElement,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
;(
|
||||
element as unknown as {
|
||||
setAttribute(key: string, value: unknown): void
|
||||
}
|
||||
).setAttribute(key, value)
|
||||
}
|
||||
|
||||
// an element carrying the kind of non-string attribute Y.XmlElement.clone()
|
||||
// drops — the reason cloneXmlElement exists at all
|
||||
function elementWithComplexAttributes(): Y.XmlElement {
|
||||
const element = attached(new Y.XmlElement("metricBlock"))
|
||||
|
||||
setAnyAttribute(element, "uid", "m1")
|
||||
setAnyAttribute(element, "queries", [{ expr: "up", legend: "up" }])
|
||||
setAnyAttribute(element, "config", { unit: "short", refresh: 30 })
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
describe("cloneXmlElement", () => {
|
||||
it("copies the source's node name", ({ expect }) => {
|
||||
const source = attached(new Y.XmlElement("calloutBlock"))
|
||||
|
||||
expect(attached(cloneXmlElement(source)).nodeName).toBe(
|
||||
"calloutBlock",
|
||||
)
|
||||
})
|
||||
|
||||
it("copies string attributes", ({ expect }) => {
|
||||
const source = attached(new Y.XmlElement("paragraph"))
|
||||
source.setAttribute("uid", "p1")
|
||||
|
||||
const clone = attached(cloneXmlElement(source))
|
||||
|
||||
expect(clone.getAttribute("uid")).toBe("p1")
|
||||
})
|
||||
|
||||
// Y.XmlElement.clone() copies only string attributes, silently
|
||||
// dropping arrays and objects — which is what would strip a metric
|
||||
// block's queries on every branch fork and merge
|
||||
it("preserves array and object attributes", ({ expect }) => {
|
||||
const clone = attached(
|
||||
cloneXmlElement(elementWithComplexAttributes()),
|
||||
)
|
||||
|
||||
expect(clone.getAttribute("queries")).toEqual([
|
||||
{ expr: "up", legend: "up" },
|
||||
])
|
||||
expect(clone.getAttribute("config")).toEqual({
|
||||
unit: "short",
|
||||
refresh: 30,
|
||||
})
|
||||
})
|
||||
|
||||
it("deep-clones nested elements", ({ expect }) => {
|
||||
const source = attached(new Y.XmlElement("calloutBlock"))
|
||||
const child = new Y.XmlElement("paragraph")
|
||||
source.insert(0, [child])
|
||||
child.setAttribute("uid", "p1")
|
||||
|
||||
const clone = attached(cloneXmlElement(source))
|
||||
const clonedChild = clone.get(0)
|
||||
|
||||
expect(clonedChild).toBeInstanceOf(Y.XmlElement)
|
||||
expect((clonedChild as Y.XmlElement).getAttribute("uid")).toBe(
|
||||
"p1",
|
||||
)
|
||||
})
|
||||
|
||||
it("clones text children with their formatting", ({ expect }) => {
|
||||
const source = attached(new Y.XmlElement("paragraph"))
|
||||
const text = new Y.XmlText()
|
||||
source.insert(0, [text])
|
||||
text.insert(0, "bold", { bold: true })
|
||||
|
||||
const clone = attached(cloneXmlElement(source))
|
||||
const clonedText = clone.get(0) as Y.XmlText
|
||||
|
||||
expect(clonedText).toBeInstanceOf(Y.XmlText)
|
||||
expect(clonedText.toDelta()).toEqual([
|
||||
{ insert: "bold", attributes: { bold: true } },
|
||||
])
|
||||
})
|
||||
|
||||
it("returns an element with no children when the source has none", ({
|
||||
expect,
|
||||
}) => {
|
||||
const source = attached(new Y.XmlElement("horizontalRule"))
|
||||
|
||||
expect(attached(cloneXmlElement(source)).length).toBe(0)
|
||||
})
|
||||
|
||||
it("detaches the clone so mutating it leaves the source alone", ({
|
||||
expect,
|
||||
}) => {
|
||||
const source = attached(new Y.XmlElement("paragraph"))
|
||||
source.setAttribute("uid", "p1")
|
||||
|
||||
const clone = attached(cloneXmlElement(source))
|
||||
clone.setAttribute("uid", "p2")
|
||||
|
||||
expect(source.getAttribute("uid")).toBe("p1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("replaceYdocContent", () => {
|
||||
it("writes the name into the name fragment", ({ expect }) => {
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
replaceYdocContent(ydoc, documentData({ name: "Runbook" }))
|
||||
|
||||
expect(fragmentXml(ydoc, "name")).toContain("Runbook")
|
||||
})
|
||||
|
||||
it("writes the content into the content fragment", ({ expect }) => {
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
replaceYdocContent(ydoc, documentData())
|
||||
|
||||
expect(contentXml(ydoc)).toContain("Restart the service")
|
||||
})
|
||||
|
||||
it("writes the icon into the icon text", ({ expect }) => {
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
replaceYdocContent(ydoc, documentData({ icon: "lucide:siren" }))
|
||||
|
||||
expect(ydoc.getText("icon").toJSON()).toBe("lucide:siren")
|
||||
})
|
||||
|
||||
it("clears the icon when the replacement has none", ({ expect }) => {
|
||||
const ydoc = new Y.Doc()
|
||||
replaceYdocContent(ydoc, documentData({ icon: "lucide:siren" }))
|
||||
|
||||
replaceYdocContent(ydoc, documentData({ icon: "" }))
|
||||
|
||||
expect(ydoc.getText("icon").toJSON()).toBe("")
|
||||
})
|
||||
|
||||
// the whole reason this function exists instead of Y.applyUpdate:
|
||||
// merging two states with different client origins keeps both copies
|
||||
it("leaves one copy when the same content is applied twice", ({
|
||||
expect,
|
||||
}) => {
|
||||
const ydoc = new Y.Doc()
|
||||
|
||||
replaceYdocContent(ydoc, documentData())
|
||||
const afterFirst = contentXml(ydoc)
|
||||
replaceYdocContent(ydoc, documentData())
|
||||
|
||||
expect(contentXml(ydoc)).toEqual(afterFirst)
|
||||
})
|
||||
|
||||
it("removes the previous content rather than appending to it", ({
|
||||
expect,
|
||||
}) => {
|
||||
const ydoc = new Y.Doc()
|
||||
replaceYdocContent(ydoc, documentData())
|
||||
|
||||
replaceYdocContent(
|
||||
ydoc,
|
||||
documentData({
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
paragraph(
|
||||
"p2",
|
||||
"Page the on-call",
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const text = contentXml(ydoc)
|
||||
expect(text).toContain("Page the on-call")
|
||||
expect(text).not.toContain("Restart the service")
|
||||
})
|
||||
|
||||
it("replaces the name rather than appending to it", ({ expect }) => {
|
||||
const ydoc = new Y.Doc()
|
||||
replaceYdocContent(ydoc, documentData({ name: "Runbook" }))
|
||||
|
||||
replaceYdocContent(ydoc, documentData({ name: "Playbook" }))
|
||||
|
||||
const name = fragmentXml(ydoc, "name")
|
||||
expect(name).toContain("Playbook")
|
||||
expect(name).not.toContain("Runbook")
|
||||
})
|
||||
|
||||
// content written by a doc with a different clientID is what a branch
|
||||
// merge carries. Cloning it in must not CRDT-merge with what is
|
||||
// already there.
|
||||
it("takes over content from a document with a different client id", ({
|
||||
expect,
|
||||
}) => {
|
||||
const source = new Y.Doc()
|
||||
replaceYdocContent(
|
||||
source,
|
||||
documentData({
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
paragraph(
|
||||
"p9",
|
||||
"Merged from a branch",
|
||||
),
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const target = new Y.Doc()
|
||||
replaceYdocContent(target, documentData())
|
||||
expect(target.clientID).not.toBe(source.clientID)
|
||||
|
||||
replaceYdocContent(target, {
|
||||
name: "Runbook",
|
||||
content: transformer.fromYdoc(
|
||||
source,
|
||||
"content",
|
||||
) as unknown,
|
||||
icon: "lucide:file",
|
||||
})
|
||||
|
||||
const text = contentXml(target)
|
||||
expect(text).toContain("Merged from a branch")
|
||||
expect(text).not.toContain("Restart the service")
|
||||
})
|
||||
|
||||
// the persisted rawContent has to reload into an identical document,
|
||||
// because that is what stops a restart from creating a second clientID
|
||||
// whose state merges with the reconnecting clients'
|
||||
it("produces a state that reloads into an identical document", ({
|
||||
expect,
|
||||
}) => {
|
||||
const ydoc = new Y.Doc()
|
||||
replaceYdocContent(ydoc, documentData())
|
||||
|
||||
const reloaded = new Y.Doc()
|
||||
Y.applyUpdate(reloaded, Y.encodeStateAsUpdate(ydoc))
|
||||
|
||||
expect(contentXml(reloaded)).toEqual(contentXml(ydoc))
|
||||
expect(reloaded.getText("icon").toJSON()).toBe(
|
||||
ydoc.getText("icon").toJSON(),
|
||||
)
|
||||
})
|
||||
|
||||
it("emits a single update for the whole replacement", ({ expect }) => {
|
||||
const ydoc = new Y.Doc()
|
||||
let updates = 0
|
||||
ydoc.on("update", () => {
|
||||
updates++
|
||||
})
|
||||
|
||||
replaceYdocContent(ydoc, documentData())
|
||||
|
||||
expect(updates).toBe(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
server/auth-realtime/tsconfig.build.json
Normal file
10
server/auth-realtime/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/test-helpers.ts",
|
||||
"src/test-setup.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
59
server/auth-realtime/vitest.config.ts
Normal file
59
server/auth-realtime/vitest.config.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// every test runs concurrently, and no mock, stubbed global, or
|
||||
// env stub may leak between tests
|
||||
sequence: {
|
||||
concurrent: true,
|
||||
},
|
||||
restoreMocks: true,
|
||||
unstubGlobals: true,
|
||||
unstubEnvs: true,
|
||||
|
||||
// the service is plain node — no DOM, no framework runtime, so
|
||||
// there is one environment and tests are named .test.ts
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
// turns yjs's premature-access warning into a failure; see the
|
||||
// file for why a warning is not enough
|
||||
setupFiles: ["./src/test-setup.ts"],
|
||||
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
// terminal summary only — no coverage/ artifact
|
||||
// directory for git, prettier, and eslint to trip over.
|
||||
// Pass --coverage.reporter=html ad hoc for the
|
||||
// browsable report.
|
||||
reporter: ["text"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
// the composition root: it reads the environment,
|
||||
// opens connections and listens, so evaluating it
|
||||
// is starting the service. What it wires together
|
||||
// is covered through the factories it calls.
|
||||
"src/index.ts",
|
||||
// loaded through node's --import before the app
|
||||
// graph exists, so it can hold no imports of ours
|
||||
"src/sentry.ts",
|
||||
// the editor schema mirrors web's tiptap
|
||||
// extension list; it is configuration, and the
|
||||
// behaviour that depends on it is covered through
|
||||
// the transformer in ydocument and operations
|
||||
"src/schema/**",
|
||||
"src/**/test-helpers.ts",
|
||||
"src/test-setup.ts",
|
||||
],
|
||||
// set from the measured baseline and ratcheted up as
|
||||
// suites grow — never lowered. 100% is not the target:
|
||||
// genuinely untestable branches stay visible in the
|
||||
// report, marked with NOCOV comments in place
|
||||
thresholds: {
|
||||
statements: 98.4,
|
||||
branches: 96.3,
|
||||
functions: 98.4,
|
||||
lines: 98.4,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user