From 4d20941bdaf840e34cc4e0f25b549934a68d832a Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Mon, 7 Sep 2026 17:28:41 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F(yhub)=20migrate=20yhub-se?= =?UTF-8?q?rver=20to=20typescript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the yhub-server source and tests from plain JS/mjs to TypeScript: move sources under src/, add tsconfig, tsconfig.build, eslint flat config and nodemon config, and update the Dockerfile, compose and CI workflow for the build step. --- .github/workflows/impress.yml | 10 +- compose.yml | 7 +- src/yhub-server/.gitignore | 1 + src/yhub-server/Dockerfile | 44 +- src/yhub-server/README.md | 92 +-- src/yhub-server/__tests__/_helpers.mjs | 125 ---- src/yhub-server/__tests__/_helpers.ts | 155 +++++ .../{migration.spec.mjs => migration.spec.ts} | 72 ++- ...rmissions.spec.mjs => permissions.spec.ts} | 50 +- src/yhub-server/__tests__/permissions.test.js | 433 -------------- .../{server.spec.mjs => server.spec.ts} | 32 +- src/yhub-server/env.js | 10 - src/yhub-server/eslint.config.mjs | 33 + src/yhub-server/nodemon.json | 6 + src/yhub-server/package.json | 15 +- src/yhub-server/src/env.ts | 17 + .../{migration.js => src/migration.ts} | 209 +++++-- .../{permissions.js => src/permissions.ts} | 65 +- src/yhub-server/{server.js => src/server.ts} | 214 +++++-- src/yhub-server/src/yhub.ts | 11 + src/yhub-server/tsconfig.build.json | 9 + src/yhub-server/tsconfig.json | 24 + src/yhub-server/vitest.config.mts | 10 +- src/yhub-server/yarn.lock | 566 +++++++++++++++++- 24 files changed, 1369 insertions(+), 841 deletions(-) create mode 100644 src/yhub-server/.gitignore delete mode 100644 src/yhub-server/__tests__/_helpers.mjs create mode 100644 src/yhub-server/__tests__/_helpers.ts rename src/yhub-server/__tests__/{migration.spec.mjs => migration.spec.ts} (91%) rename src/yhub-server/__tests__/{permissions.spec.mjs => permissions.spec.ts} (83%) delete mode 100644 src/yhub-server/__tests__/permissions.test.js rename src/yhub-server/__tests__/{server.spec.mjs => server.spec.ts} (89%) delete mode 100644 src/yhub-server/env.js create mode 100644 src/yhub-server/eslint.config.mjs create mode 100644 src/yhub-server/nodemon.json create mode 100644 src/yhub-server/src/env.ts rename src/yhub-server/{migration.js => src/migration.ts} (84%) rename src/yhub-server/{permissions.js => src/permissions.ts} (81%) rename src/yhub-server/{server.js => src/server.ts} (89%) create mode 100644 src/yhub-server/src/yhub.ts create mode 100644 src/yhub-server/tsconfig.build.json create mode 100644 src/yhub-server/tsconfig.json diff --git a/.github/workflows/impress.yml b/.github/workflows/impress.yml index 64e43c273..8a8395e1d 100644 --- a/.github/workflows/impress.yml +++ b/.github/workflows/impress.yml @@ -234,9 +234,13 @@ jobs: with: node-version: "22.x" - - name: Install the collaboration server + - name: Install and build the collaboration server working-directory: src/yhub-server - run: yarn install --frozen-lockfile --production + # a full install (typescript is a dev dependency) so `yarn build` can + # compile src/ to the dist/ started below + run: | + yarn install --frozen-lockfile + yarn build # yhub ships its own DDL and creates the database as well, so this needs # the dependencies installed above — hence its place after them @@ -271,7 +275,7 @@ jobs: LEGACY_S3_SECRET_ACCESS_KEY: password LEGACY_S3_BUCKET_NAME: impress-media-storage run: | - nohup node server.js > /tmp/yhub.log 2>&1 & + nohup node dist/server.js > /tmp/yhub.log 2>&1 & dockerize -wait tcp://localhost:3002 -timeout 30s - name: Run tests diff --git a/compose.yml b/compose.yml index 42ec200c7..92f553fe7 100644 --- a/compose.yml +++ b/compose.yml @@ -263,11 +263,12 @@ services: - env.d/development/yhub.local volumes: - ./data/jwt:/data/jwt:ro - # editing a source file restarts the server (nodemon), no rebuild + # editing a source file recompiles and restarts the server (nodemon) - ./src/yhub-server:/app - # node_modules is installed in the image, not in the source tree: keep - # the bind mount above from hiding it + # installed / built in the image, not in the source tree: keep the bind + # mount above from hiding node_modules, and the compiled output out of it - /app/node_modules + - /app/dist restart: unless-stopped ports: - "3002:3002" diff --git a/src/yhub-server/.gitignore b/src/yhub-server/.gitignore new file mode 100644 index 000000000..849ddff3b --- /dev/null +++ b/src/yhub-server/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/src/yhub-server/Dockerfile b/src/yhub-server/Dockerfile index ef7a28994..405418992 100644 --- a/src/yhub-server/Dockerfile +++ b/src/yhub-server/Dockerfile @@ -8,33 +8,49 @@ WORKDIR /app COPY ./src/yhub-server/package.json ./src/yhub-server/yarn.lock ./ -# ---- Development image ---- -FROM base AS yhub-development +# ---- Dependencies + sources ---- +# Dev dependencies included (typescript, nodemon, eslint): this stage is the +# base of both the development image and the build stage. `make migrate-yhub` +# also runs its one-off `yarn init-db` here. +FROM base AS yhub-deps -# dev dependencies included: nodemon, plus this is where one-off scripts run -# (`make migrate-yhub` runs `yarn init-db` in it) RUN yarn install --frozen-lockfile -# server.js, migration.js, env.js — glob so a new module cannot be forgotten. -# compose bind-mounts the sources over /app on top of this copy, so an edit on -# the host is seen immediately; the copy keeps the image usable on its own. -COPY ./src/yhub-server/*.js ./ +# whole directories rather than a glob, so a new module or tsconfig cannot be +# forgotten +COPY ./src/yhub-server/tsconfig.json ./src/yhub-server/tsconfig.build.json ./ +COPY ./src/yhub-server/src ./src + + +# ---- Development image ---- +FROM yhub-deps AS yhub-development + +# compose bind-mounts the sources over /app on top of this copy, so a host edit +# is seen immediately; these keep the image usable on its own. +COPY ./src/yhub-server/nodemon.json ./src/yhub-server/eslint.config.mjs ./src/yhub-server/vitest.config.mts ./ +COPY ./src/yhub-server/__tests__ ./__tests__ EXPOSE 3002 -# `yarn dev` restarts the server on every source change, no rebuild needed. -# nodemon rather than node's own --watch: the latter watches inodes, so it goes -# deaf as soon as a file is replaced by a rename — which is what `git checkout` -# and most editors do when saving. +# `yarn dev` recompiles (tsc) and restarts the server on every change under +# src/. nodemon rather than node's own --watch: the latter watches inodes, so +# it goes deaf as soon as a file is replaced by a rename — which is what `git +# checkout` and most editors do when saving. CMD ["yarn", "dev"] +# ---- Build stage ---- +FROM yhub-deps AS yhub-builder + +RUN yarn build + + # ---- Production image ---- FROM base AS yhub RUN yarn install --frozen-lockfile --production -COPY ./src/yhub-server/*.js ./ +COPY --from=yhub-builder /app/dist ./dist EXPOSE 3002 @@ -54,4 +70,4 @@ USER ${DOCKER_USER} ENTRYPOINT [ "/usr/local/bin/entrypoint" ] -CMD ["node", "server.js"] +CMD ["node", "dist/server.js"] diff --git a/src/yhub-server/README.md b/src/yhub-server/README.md index b2be55fae..12309345e 100644 --- a/src/yhub-server/README.md +++ b/src/yhub-server/README.md @@ -4,14 +4,18 @@ This directory contains the La Suite Docs-specific configuration for [yhub](https://www.npmjs.com/package/@y/hub) (`@y/hub`), the collaboration server that synchronizes Yjs documents between editors in real time. -It is not a fork of yhub — it is a thin wrapper: +It is not a fork of yhub — it is a thin wrapper, written in TypeScript under +`src/` and compiled to `dist/` with `yarn build` (see "Container image" below): -- `server.js` — configuration, the auth plugin, and the custom REST endpoints, -- `migration.js` — everything that reads the legacy Django/S3 document store +- `src/server.ts` — configuration, the auth plugin, and the custom REST endpoints, +- `src/permissions.ts` — Docs' access policy as yhub permission objects (see + "Access control" below), +- `src/migration.ts` — everything that reads the legacy Django/S3 document store (both migrations described below), -- `env.js` — the `*_FILE` secret indirection shared by the two. +- `src/env.ts` — the `*_FILE` secret indirection shared by the rest, +- `src/yhub.ts` — the couple of `@y/hub` types its package index does not export. -`server.js`: +`src/server.ts`: - starts a yhub instance (websocket sync on port 3002, backed by Redis/Valkey and PostgreSQL — and, when `YHUB_S3_PERSISTENCE` asks for it, a bucket the @@ -98,10 +102,10 @@ hides, so they stay in-cluster on their own. yhub 0.8 replaced the `'r' | 'rw' | null` access vocabulary with **permission objects**: the auth plugin answers, per facet, what a subject may do with one document, and yhub enforces every facet itself — on the websocket and on the -REST routes alike. Docs' whole policy is three tables in `permissions.js`, kept -out of `server.js` so they can be read and tested without redis and postgres. -`__tests__/permissions.test.js` asks them the same questions yhub's gates ask; -run it with `yarn test` (see "Tests" below). +REST routes alike. Docs' whole policy is three tables in `src/permissions.ts`, +kept out of `src/server.ts` so they can be read and tested without redis and +postgres. `__tests__/permissions.spec.ts` asks them the same questions yhub's +gates ask; run it with `yarn test` (see "Tests" below). Masks are positional `crud` strings where `-` denies, so `'-r--'` is read-only. @@ -382,45 +386,42 @@ from an `mc` container on the stack's network. ## Tests -`yarn test` runs two suites, neither of which needs redis, postgres or S3: +`yarn test` runs the **vitest** suite under `__tests__/*.spec.ts` +(`yarn test:watch` for the watcher), none of which needs redis, postgres or S3: -- `__tests__/permissions.test.js` on node's own runner (`node:test`) — it - imports `@y/hub/permissions` (a subpath export, no redis/postgres pulled in) - to run the real permission pipeline, and is kept on `node:test` on purpose, -- the `__tests__/*.spec.mjs` files on **vitest** (`yarn test:watch` for the - watcher): - - `__tests__/migration.spec.mjs` drives `maybeMigrate` and `fullMigrate` end - to end with `@aws-sdk/client-s3` and the yhub instance faked and `@y/y` - real, so the content maps under assertion are the real ones — plus the - module-load validation (`SOFT_MIGRATION`, the `LEGACY_S3_*` checks, - addressing style), - - `__tests__/server.spec.mjs` covers the boot contract: the environment - `server.js` refuses (`YHUB_ROLE`, the numeric knobs, a half-configured - bucket) and the configuration it hands `createYHub` when it accepts, - - `__tests__/permissions.spec.mjs` is the vitest counterpart of - `permissions.test.js`. +- `__tests__/migration.spec.ts` drives `maybeMigrate` and `fullMigrate` end to + end with `@aws-sdk/client-s3` and the yhub instance faked and `@y/y` real, so + the content maps under assertion are the real ones — plus the module-load + validation (`SOFT_MIGRATION`, the `LEGACY_S3_*` checks, addressing style), +- `__tests__/server.spec.ts` covers the boot contract: the environment + `src/server.ts` refuses (`YHUB_ROLE`, the numeric knobs, a half-configured + bucket) and the configuration it hands `createYHub` when it accepts, +- `__tests__/permissions.spec.ts` asks the policy tables the same questions + yhub's gates ask, through the real `@y/hub/permissions` pipeline (a subpath + export, no redis/postgres pulled in). -The `__tests__/` directory (and the `.mjs` extension) keeps the specs out of -`node --test`'s discovery and out of the Docker image (`COPY *.js`); shared -fakes live in `__tests__/_helpers.mjs`. CI does not run these yet — it installs with `yarn install --frozen-lockfile --production`, so `vitest` is absent -there; the pytest-driven integration suite in `.github/workflows/impress.yml` is -what exercises a real collaboration server. +Shared fakes live in `__tests__/_helpers.ts`. `yarn typecheck` type-checks +`src/` and the specs; `yarn lint` runs ESLint. CI does not run the unit suite +yet; the pytest-driven integration suite in `.github/workflows/impress.yml` +exercises a real collaboration server (built with `yarn build`). ## Container image The `Dockerfile` has two final stages, like the other services of this -repository: +repository, both fed by a shared `yhub-deps` stage (full install + `src/`) and, +for production, a `yhub-builder` stage that runs `yarn build`: - `yhub-development` — what the `yhub` service of `compose.yml` builds. It - installs the dev dependencies and starts the server through `yarn dev` - (nodemon), and compose bind-mounts `src/yhub-server` over `/app`: **editing - `server.js`, `migration.js` or `env.js` restarts the server, no rebuild**. - Watch it happen with `docker compose logs -f yhub`. A syntax error stops at + starts the server through `yarn dev` (nodemon), and compose bind-mounts + `src/yhub-server` over `/app`: **editing anything under `src/` recompiles + (`tsc`) and restarts the server**. Watch it happen with + `docker compose logs -f yhub`. A type or syntax error stops at `app crashed - waiting for file changes` and the next save starts the server again, -- `yhub` — the production image: production dependencies only, `node - server.js`, sources baked in, and the un-privileged user and the entrypoint - the other services use (kubernetes runs the pod with `runAsNonRoot`). +- `yhub` — the production image: production dependencies only, the compiled + `dist/` copied from the builder, `node dist/server.js`, and the un-privileged + user and the entrypoint the other services use (kubernetes runs the pod with + `runAsNonRoot`). Both are built **from the repository root**, like every other image here — the entrypoint they share lives outside this directory: @@ -431,13 +432,13 @@ docker build -f src/yhub-server/Dockerfile --target yhub . nodemon rather than node's own `--watch`: the latter watches inodes, so it stops seeing a file as soon as it is replaced by a rename — which is what `git -checkout` and most editors do when saving. The one-second `--delay` debounces -partial writes, so a branch switch restarts the server once, after the files -have settled. +checkout` and most editors do when saving. The one-second `delay` (in +`nodemon.json`) debounces partial writes, so a branch switch recompiles and +restarts the server once, after the files have settled. Only source edits are picked up live. A dependency change (`package.json`) is a -rebuild, and `node_modules` lives in an anonymous volume that survives a plain -recreate, so it needs renewing: +rebuild, and `node_modules` and the compiled `dist/` live in anonymous volumes +that survive a plain recreate, so they need renewing: ``` make build-yhub @@ -776,8 +777,9 @@ This directory depends on `@y/hub`, which is licensed under the the rest of this repository (MIT), the code in this directory is loaded into the same process as AGPL-licensed code. As a consequence: -- **Any modification to the code in this directory (in particular `server.js` - and `migration.js`) must be released under an AGPL-compatible license** if +- **Any modification to the code in this directory (in particular + `src/server.ts` and `src/migration.ts`) must be released under an + AGPL-compatible license** if you run or distribute the resulting server, including making it available to users over a network (AGPL section 13). - See the [LICENSE](./LICENSE) file in this directory for details. diff --git a/src/yhub-server/__tests__/_helpers.mjs b/src/yhub-server/__tests__/_helpers.mjs deleted file mode 100644 index 563e9d2c3..000000000 --- a/src/yhub-server/__tests__/_helpers.mjs +++ /dev/null @@ -1,125 +0,0 @@ -// Shared fixtures and fakes for the vitest specs. Named `.mjs` (not `.js`) so -// neither the Dockerfile's `COPY *.js` nor `node --test` ever picks it up, and -// `*.spec.mjs` so the same is true of the spec files themselves. - -import { vi } from 'vitest'; - -import * as Y from '@y/y'; - -// --- Yjs fixtures --------------------------------------------------------- - -// A raw Yjs update (what the legacy S3 object base64-decodes to) for a text -// document holding `text`. Deterministic: one client, one insert. -export const makeUpdate = (text = 'hello world') => { - const doc = new Y.Doc(); - doc.get('t', 'text').insert(0, text); - return Y.encodeStateAsUpdate(doc); -}; - -// A sequence of *full snapshots* of one lineage, the shape `{docid}/file`'s S3 -// versions have: each element is `encodeStateAsUpdate` after one more edit, so -// applying them in order to a gc:false doc yields fresh content ids per step. -export const makeVersionChain = (steps = ['a', 'bc', 'def']) => { - const doc = new Y.Doc(); - const text = doc.get('t', 'text'); - const updates = []; - for (const step of steps) { - text.insert(text.length, step); - updates.push(Y.encodeStateAsUpdate(doc)); - } - return updates; -}; - -export const toBase64 = (update) => Buffer.from(update).toString('base64'); - -// What `fetchLegacyDoc` reads back: a Body with `transformToString`. -export const s3Body = (update) => ({ - transformToString: async () => toBase64(update), -}); - -// A GetObject rejection the sdk would raise for an absent key/version. -export const s3NotFound = (name = 'NoSuchKey') => - Object.assign(new Error(name), { name }); - -// --- a minimal in-memory redis ----------------------------------------- - -// Enough of the client for the lock dance and the migrated set. `set` honors -// the `NX` condition migrate.js relies on; `eval` emulates the compare-and- -// delete lua script it releases the lock with. -export const fakeRedis = () => { - const kv = new Map(); - const sets = new Map(); - const setOf = (k) => sets.get(k) ?? sets.set(k, new Set()).get(k); - return { - kv, - sets, - sIsMember: vi.fn(async (k, m) => setOf(k).has(m)), - sAdd: vi.fn(async (k, m) => void setOf(k).add(m)), - set: vi.fn(async (k, v, opts) => { - if (opts?.condition === 'NX' && kv.has(k)) return null; - kv.set(k, v); - return 'OK'; - }), - exists: vi.fn(async (k) => (kv.has(k) ? 1 : 0)), - del: vi.fn(async (k) => void kv.delete(k)), - eval: vi.fn(async (_script, { keys, arguments: args }) => { - if (kv.get(keys[0]) === args[0]) { - kv.delete(keys[0]); - return 1; - } - return 0; - }), - }; -}; - -// --- a fake yhub instance --------------------------------------------- - -// The surface migration.js touches, all as vi.fns with harmless defaults. Pass -// overrides for the handful a given test drives; reach into `.stream.redis` -// (a fakeRedis) for the lock/set state. -export const makeYhub = (overrides = {}) => { - const redis = overrides.redis ?? fakeRedis(); - const yhub = { - stream: { - prefix: 'yhub', - redis, - // ydocExists: no messages on the stream by default - getMessages: vi.fn(async () => [{ messages: [] }]), - addMessage: vi.fn(async () => {}), - }, - persistence: { - // ydocExists: lastClock '0' means "yhub does not know this room" - retrieveDoc: vi.fn(async () => ({ lastClock: '0' })), - store: vi.fn(async () => {}), - }, - computePool: { - // fullMigrate merges the nongc snapshot into a gc one; hand a marker back - mergeUpdates: vi.fn(async (_gc, [update]) => update), - }, - }; - return deepAssign(yhub, overrides); -}; - -const deepAssign = (target, source) => { - for (const [key, value] of Object.entries(source)) { - if ( - value && - typeof value === 'object' && - !Array.isArray(value) && - typeof value !== 'function' && - target[key] - ) { - deepAssign(target[key], value); - } else { - target[key] = value; - } - } - return target; -}; - -// A stand-in for `@y/hub`'s pino logger: swallow the lines, keep the spies. -export const fakeLoggerModule = () => { - const record = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; - const child = () => ({ ...record, child }); - return { logger: { ...record, child } }; -}; diff --git a/src/yhub-server/__tests__/_helpers.ts b/src/yhub-server/__tests__/_helpers.ts new file mode 100644 index 000000000..b53bd96df --- /dev/null +++ b/src/yhub-server/__tests__/_helpers.ts @@ -0,0 +1,155 @@ +// Shared fixtures and fakes for the vitest specs. Named `_helpers.ts` (not a +// `*.spec.ts`) so the runner's `include` glob never picks it up as a suite. + +import { vi } from 'vitest'; + +import * as Y from '@y/y'; + +import type { YHub } from '../src/yhub.js'; + +// --- Yjs fixtures --------------------------------------------------------- + +// A raw Yjs update (what the legacy S3 object base64-decodes to) for a text +// document holding `text`. Deterministic: one client, one insert. +export const makeUpdate = (text = 'hello world'): Uint8Array => { + const doc = new Y.Doc(); + doc.get('t', 'text').insert(0, text); + return Y.encodeStateAsUpdate(doc); +}; + +// A sequence of *full snapshots* of one lineage, the shape `{docid}/file`'s S3 +// versions have: each element is `encodeStateAsUpdate` after one more edit, so +// applying them in order to a gc:false doc yields fresh content ids per step. +export const makeVersionChain = ( + steps = ['a', 'bc', 'def'], +): Uint8Array[] => { + const doc = new Y.Doc(); + const text = doc.get('t', 'text'); + const updates: Uint8Array[] = []; + for (const step of steps) { + text.insert(text.length, step); + updates.push(Y.encodeStateAsUpdate(doc)); + } + return updates; +}; + +export const toBase64 = (update: Uint8Array): string => + Buffer.from(update).toString('base64'); + +// What `fetchLegacyDoc` reads back: a Body with `transformToString`. +export const s3Body = (update: Uint8Array) => ({ + transformToString: async () => toBase64(update), +}); + +// A GetObject rejection the sdk would raise for an absent key/version. +export const s3NotFound = (name = 'NoSuchKey'): Error => + Object.assign(new Error(name), { name }); + +// --- a minimal in-memory redis ----------------------------------------- + +// Enough of the client for the lock dance and the migrated set. `set` honors +// the `NX` condition migration.ts relies on; `eval` emulates the compare-and- +// delete lua script it releases the lock with. +export const fakeRedis = () => { + const kv = new Map(); + const sets = new Map>(); + const setOf = (k: string): Set => { + let set = sets.get(k); + if (!set) { + set = new Set(); + sets.set(k, set); + } + return set; + }; + return { + kv, + sets, + sIsMember: vi.fn(async (k: string, m: string) => setOf(k).has(m)), + sAdd: vi.fn(async (k: string, m: string) => void setOf(k).add(m)), + set: vi.fn( + async ( + k: string, + v: string, + opts?: { condition?: string }, + ): Promise => { + if (opts?.condition === 'NX' && kv.has(k)) return null; + kv.set(k, v); + return 'OK'; + }, + ), + exists: vi.fn(async (k: string) => (kv.has(k) ? 1 : 0)), + del: vi.fn(async (k: string) => void kv.delete(k)), + eval: vi.fn( + async ( + _script: string, + { keys, arguments: args }: { keys: string[]; arguments: string[] }, + ) => { + if (kv.get(keys[0]) === args[0]) { + kv.delete(keys[0]); + return 1; + } + return 0; + }, + ), + }; +}; + +// --- a fake yhub instance --------------------------------------------- + +type AnyRecord = Record; + +// The surface migration.ts touches, all as vi.fns with harmless defaults. Pass +// overrides for the handful a given test drives; reach into `.stream.redis` +// (a fakeRedis) for the lock/set state. Cast to `YHub` at the boundary — the +// functions under test only ever touch this subset. +export const makeYhub = (overrides: AnyRecord = {}): YHub => { + const redis = (overrides.redis as ReturnType) ?? fakeRedis(); + const yhub: AnyRecord = { + stream: { + prefix: 'yhub', + redis, + // ydocExists: no messages on the stream by default + getMessages: vi.fn(async () => [{ messages: [] }]), + addMessage: vi.fn(async () => {}), + }, + persistence: { + // ydocExists: lastClock '0' means "yhub does not know this room" + retrieveDoc: vi.fn(async () => ({ lastClock: '0' })), + store: vi.fn(async () => {}), + }, + computePool: { + // fullMigrate merges the nongc snapshot into a gc one; hand a marker back + mergeUpdates: vi.fn(async (_gc: boolean, [update]: Uint8Array[]) => update), + }, + }; + return deepAssign(yhub, overrides) as unknown as YHub; +}; + +const deepAssign = (target: AnyRecord, source: AnyRecord): AnyRecord => { + for (const [key, value] of Object.entries(source)) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + typeof value !== 'function' && + target[key] + ) { + deepAssign(target[key] as AnyRecord, value as AnyRecord); + } else { + target[key] = value; + } + } + return target; +}; + +// A stand-in for `@y/hub`'s pino logger: swallow the lines, keep the spies. +export const fakeLoggerModule = () => { + const record = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + const child = () => ({ ...record, child }); + return { logger: { ...record, child } }; +}; diff --git a/src/yhub-server/__tests__/migration.spec.mjs b/src/yhub-server/__tests__/migration.spec.ts similarity index 91% rename from src/yhub-server/__tests__/migration.spec.mjs rename to src/yhub-server/__tests__/migration.spec.ts index 2b8f2e359..6230dbc4e 100644 --- a/src/yhub-server/__tests__/migration.spec.mjs +++ b/src/yhub-server/__tests__/migration.spec.ts @@ -1,15 +1,20 @@ -// migration.js — the legacy Django/S3 document store reader. +// migration.ts — the legacy Django/S3 document store reader. // -// The two entry points server.js calls are exercised end to end here: +// The two entry points server.ts calls are exercised end to end here: // maybeMigrate — the lazy seed on first access to an unknown room // fullMigrate — the version-history backfill // with `@aws-sdk/client-s3` and the yhub instance faked, and `@y/y` real, so the // content maps these build are the real ones. import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; import * as Y from '@y/y'; +// the faked yhub methods are `vi.fn()`s behind a `YHub` cast — reach their +// recorded calls through this rather than sprinkling casts +const mockCalls = (fn: unknown): unknown[][] => (fn as Mock).mock.calls; + import { makeUpdate, makeVersionChain, @@ -17,9 +22,9 @@ import { fakeRedis, s3Body, s3NotFound, -} from './_helpers.mjs'; +} from './_helpers.js'; -// migration.js reads these once, at import time, and builds its S3 client from +// migration.ts reads these once, at import time, and builds its S3 client from // them. Set before the module is ever imported (below), overriding the floor // vitest.config.mts puts down. process.env.SOFT_MIGRATION = 'true'; @@ -34,28 +39,30 @@ delete process.env.LEGACY_S3_REGION_NAME; // here. `s3configs` captures each client's constructor config. const { s3send, s3configs } = vi.hoisted(() => ({ s3send: vi.fn(), - s3configs: [], + s3configs: [] as any[], })); vi.mock('@aws-sdk/client-s3', () => ({ S3Client: class { - constructor(config) { + constructor(config: unknown) { s3configs.push(config); } - send(command, options) { + send(command: unknown, options: unknown) { return s3send(command, options); } }, GetObjectCommand: class { - constructor(input) { + input: unknown; + kind = 'get'; + constructor(input: unknown) { this.input = input; - this.kind = 'get'; } }, ListObjectVersionsCommand: class { - constructor(input) { + input: unknown; + kind = 'list'; + constructor(input: unknown) { this.input = input; - this.kind = 'list'; } }, })); @@ -66,7 +73,7 @@ vi.mock('@y/hub', () => { return { logger: { ...rec, child } }; }); -const load = () => import('../migration.js'); +const load = () => import('../src/migration.js'); const docRef = (docid = 'doc-1') => ({ org: 'docs', docid, branch: 'main' }); const LOCK_KEY = 'yhub:softmigrate:docs:doc-1:main'; const MIGRATED_SET = 'yhub:migrated:v1'; @@ -74,8 +81,13 @@ const MIGRATED_SET = 'yhub:migrated:v1'; // Route S3 by command. `getByVersion` maps a VersionId to a Body (or an Error to // throw); `get` handles the versionless "newest object" read; `list` is the // ListObjectVersions response (or a function of its input). -const routeS3 = ({ getByVersion, get, list } = {}) => { - s3send.mockImplementation(async (command) => { +interface RouteS3Opts { + getByVersion?: Record; + get?: (input: any) => unknown; + list?: unknown; +} +const routeS3 = ({ getByVersion, get, list }: RouteS3Opts = {}) => { + s3send.mockImplementation(async (command: any) => { if (command.kind === 'get') { const versionId = command.input.VersionId; if (versionId != null && getByVersion) { @@ -97,10 +109,13 @@ const routeS3 = ({ getByVersion, get, list } = {}) => { }; // The [name, value] attribute pairs a stored/seeded content map carries. -const attrsOf = (contentmapBytes) => { - const decoded = Y.decodeContentMap(contentmapBytes); - const out = { inserts: [], deletes: [] }; - for (const side of ['inserts', 'deletes']) { +const attrsOf = (contentmapBytes: Uint8Array) => { + const decoded = Y.decodeContentMap(contentmapBytes) as any; + const out: { inserts: unknown[][]; deletes: unknown[][] } = { + inserts: [], + deletes: [], + }; + for (const side of ['inserts', 'deletes'] as const) { for (const [, entry] of decoded[side].clients) { for (const id of entry._ids) { for (const attr of id.attrs) out[side].push([attr.name, attr.val]); @@ -192,13 +207,16 @@ describe('maybeMigrate — the lazy seed', () => { await maybeMigrate(yhub, docRef()); expect(yhub.stream.addMessage).toHaveBeenCalledTimes(1); - const [ref, message] = yhub.stream.addMessage.mock.calls[0]; + const [ref, message] = mockCalls(yhub.stream.addMessage)[0] as [ + unknown, + any, + ]; expect(ref).toEqual(docRef()); expect(message.type).toBe('ydoc:update:v1'); expect(Buffer.from(message.update)).toEqual(Buffer.from(update)); expect(message.contentmap).toBeInstanceOf(Uint8Array); - const get = s3send.mock.calls.find(([c]) => c.kind === 'get')[0]; + const get = s3send.mock.calls.find(([c]: any) => c.kind === 'get')![0]; expect(get.input).toEqual({ Bucket: 'legacy-media', Key: 'doc-1/file' }); }); @@ -209,7 +227,7 @@ describe('maybeMigrate — the lazy seed', () => { await maybeMigrate(yhub, docRef()); - const { contentmap } = yhub.stream.addMessage.mock.calls[0][1]; + const { contentmap } = mockCalls(yhub.stream.addMessage)[0][1] as any; const { inserts } = attrsOf(contentmap); expect(inserts).toContainEqual(['insert', 'system']); expect(inserts).toContainEqual(['insert:migration', 's3']); @@ -372,7 +390,9 @@ describe('maybeMigrate — the lazy seed', () => { }); describe('fullMigrate — the version-history backfill', () => { - const listNewestFirst = (entries) => ({ + const listNewestFirst = ( + entries: Array<{ versionId: string; ms: number }>, + ) => ({ Versions: entries.map(({ versionId, ms }) => ({ Key: 'doc-1/file', VersionId: versionId, @@ -422,7 +442,7 @@ describe('fullMigrate — the version-history backfill', () => { expect(fetchedVersionIds).toEqual(['ver-1', 'ver-2']); expect(yhub.persistence.store).toHaveBeenCalledTimes(1); - const [ref, row] = yhub.persistence.store.mock.calls[0]; + const [ref, row] = mockCalls(yhub.persistence.store)[0] as [unknown, any]; expect(ref).toEqual(docRef()); expect(row.lastClock).toBe('0'); expect(row.contentmap).toBeInstanceOf(Uint8Array); @@ -445,7 +465,9 @@ describe('fullMigrate — the version-history backfill', () => { await fullMigrate(yhub, docRef()); - const { inserts } = attrsOf(yhub.persistence.store.mock.calls[0][1].contentmap); + const { inserts } = attrsOf( + (mockCalls(yhub.persistence.store)[0][1] as any).contentmap, + ); expect(inserts).toContainEqual(['insert', 'system']); expect(inserts).toContainEqual(['insertAt', 1000]); expect(inserts).toContainEqual(['insertAt', 2000]); @@ -563,7 +585,7 @@ describe('fullMigrate — the version-history backfill', () => { ]; let call = 0; routeS3({ - list: (input) => { + list: (input: any) => { const page = pages[call++]; if (call === 2) { expect(input.KeyMarker).toBe('key-marker'); diff --git a/src/yhub-server/__tests__/permissions.spec.mjs b/src/yhub-server/__tests__/permissions.spec.ts similarity index 83% rename from src/yhub-server/__tests__/permissions.spec.mjs rename to src/yhub-server/__tests__/permissions.spec.ts index eec0672e3..c83e52914 100644 --- a/src/yhub-server/__tests__/permissions.spec.mjs +++ b/src/yhub-server/__tests__/permissions.spec.ts @@ -1,15 +1,17 @@ -// permissions.js — Docs' access policy as yhub permission objects. +// src/permissions.ts — Docs' access policy as yhub permission objects. // -// `permissions.test.js` (node:test) covers the same ground reaching into -// `@y/hub` by path; this is the vitest-suite counterpart, phrased as the -// questions yhub's gates ask (`hasPermissions`) rather than as a snapshot of -// the tables — a mask may be respelled, but it may not start answering +// The assertions run yhub's real permission pipeline (`@y/hub/permissions`, a +// subpath export that pulls in neither redis nor postgres) and are phrased as +// the questions yhub's gates ask (`hasPermissions`) rather than as a snapshot +// of the tables — a mask may be respelled, but it may not start answering // differently. import { describe, expect, it } from 'vitest'; +import type { DocumentPermissionsV1Normalized } from '@y/hub/permissions'; import { createDocumentPermissions, + createGlobalPermissions, hasPermissions, normalizePermissions, } from '@y/hub/permissions'; @@ -19,18 +21,23 @@ import { browserDocumentPermissions, publicGlobalPermissions, resolveHistoryFrom, -} from '../permissions.js'; +} from '../src/permissions.js'; const ACCESS_SINCE = 1_700_000_000_000; -const reader = normalizePermissions(browserDocumentPermissions(false, ACCESS_SINCE)); -const editor = normalizePermissions(browserDocumentPermissions(true, ACCESS_SINCE)); -const linkReader = normalizePermissions(browserDocumentPermissions(false)); -const linkEditor = normalizePermissions(browserDocumentPermissions(true)); -const admin = normalizePermissions(adminDocumentPermissions); +const norm = (p: Parameters[0]) => + normalizePermissions(p) as DocumentPermissionsV1Normalized; -const grants = (permissions, required) => - hasPermissions(permissions, createDocumentPermissions(required)); +const reader = norm(browserDocumentPermissions(false, ACCESS_SINCE)); +const editor = norm(browserDocumentPermissions(true, ACCESS_SINCE)); +const linkReader = norm(browserDocumentPermissions(false)); +const linkEditor = norm(browserDocumentPermissions(true)); +const admin = norm(adminDocumentPermissions); + +const grants = ( + permissions: DocumentPermissionsV1Normalized, + required: Parameters[0], +) => hasPermissions(permissions, createDocumentPermissions(required)); describe('presence', () => { it('lets a reader receive presence but never broadcast it', () => { @@ -88,8 +95,9 @@ describe('the history a user may read', () => { it('never grants the full ray that would unlock gc=false', () => { for (const who of [reader, editor]) { - expect(who.history).not.toBe(false); - expect(who.history.from).toBeGreaterThan(0); + const history = who.history; + expect(history).not.toBe(false); + expect(history === false ? -1 : history.from).toBeGreaterThan(0); } }); @@ -126,11 +134,12 @@ describe('rollback', () => { }); it('is a dead grant without the write it rides on', () => { - const readerWithRollback = normalizePermissions({ + const readerWithRollback = norm({ ...browserDocumentPermissions(false, ACCESS_SINCE), history: { from: ACCESS_SINCE, rollback: true }, }); - expect(readerWithRollback.history.rollback).toBe(false); + const history = readerWithRollback.history; + expect(history === false ? null : history.rollback).toBe(false); }); it('is withheld from the admin token', () => { @@ -192,8 +201,9 @@ describe('the admin token', () => { describe('the public global routes', () => { const globalPerms = normalizePermissions(publicGlobalPermissions); - const globalGrants = (required) => - hasPermissions(globalPerms, { type: 'permissions:global:v1', ...required }); + const globalGrants = ( + required: Parameters[0], + ) => hasPermissions(globalPerms, createGlobalPermissions(required)); it.each(['ping', 'ready', 'jwks'])('serves %s to anyone, read only', (name) => { expect(globalGrants({ endpoint: { [name]: '-r--' } })).toBe(true); @@ -206,7 +216,7 @@ describe('the public global routes', () => { }); describe('resolveHistoryFrom', () => { - const httpError = (status) => + const httpError = (status: number) => Object.assign(new Error(`HTTP ${status}`), { status }); const never = () => { throw new Error('the access must not be fetched'); diff --git a/src/yhub-server/__tests__/permissions.test.js b/src/yhub-server/__tests__/permissions.test.js deleted file mode 100644 index 3cd16bdf5..000000000 --- a/src/yhub-server/__tests__/permissions.test.js +++ /dev/null @@ -1,433 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -// `@y/hub/permissions` is a subpath export of its own (since 0.9.0): the two -// functions this test needs to run yhub's real pipeline — normalize the -// plugin's answer, then ask it the gate's question — without the package index -// pulling in uws, redis and postgres. -import { - createDocumentPermissions, - hasPermissions, - normalizePermissions, -} from '@y/hub/permissions'; - -import { - adminDocumentPermissions, - browserDocumentPermissions, - publicGlobalPermissions, - resolveHistoryFrom, -} from '../permissions.js'; - -/** - * These tables are Docs' entire access policy, and yhub reads them literally — - * a wrong character in a mask is a silent grant. So the assertions below are - * written as the questions yhub itself asks at each gate (`hasPermissions`), - * not as a snapshot of the objects: a table may be respelled freely, but it may - * not start answering a question differently. - */ -/** - * When these two were given access to the document. Any positive number would - * do — what the assertions care about is that it is the value the ray starts at, - * and that it is not zero. - */ -const ACCESS_SINCE = 1_700_000_000_000; - -const reader = normalizePermissions( - browserDocumentPermissions(false, ACCESS_SINCE), -); -const editor = normalizePermissions( - browserDocumentPermissions(true, ACCESS_SINCE), -); -// the same two reaching the document by link alone: no access row, so no date -const linkReader = normalizePermissions(browserDocumentPermissions(false)); -const linkEditor = normalizePermissions(browserDocumentPermissions(true)); -const admin = normalizePermissions(adminDocumentPermissions); - -const grants = (permissions, required) => - hasPermissions(permissions, createDocumentPermissions(required)); - -describe('read-only users and presence (suitenumerique/docs#2544)', () => { - it('lets a reader receive presence', () => { - assert.equal(grants(reader, { awareness: '-r--' }), true); - }); - - it('never lets a reader broadcast presence', () => { - // the requirement this migration exists for. yhub's own default grants a - // reader '-ru-' here; Docs deliberately does not. - assert.equal(grants(reader, { awareness: '--u-' }), false); - }); - - it('lets an editor broadcast presence', () => { - assert.equal(grants(editor, { awareness: '--u-' }), true); - }); -}); - -describe('the document itself', () => { - it('lets a reader read but not write', () => { - assert.equal(grants(reader, { ydoc: '-r--' }), true); - assert.equal(grants(reader, { ydoc: '--u-' }), false); - }); - - it('lets an editor write', () => { - assert.equal(grants(editor, { ydoc: '--u-' }), true); - }); - - it('opens the socket to a reader but admits no update over it', () => { - assert.equal(grants(reader, { endpoint: { ws: '-r--' } }), true); - assert.equal(grants(reader, { endpoint: { ws: '--u-' } }), false); - }); - - it('lets an editor send updates over the socket', () => { - assert.equal(grants(editor, { endpoint: { ws: '--u-' } }), true); - }); -}); - -describe('the http fallback route', () => { - it('lets a reader GET the document and no more', () => { - assert.equal(grants(reader, { endpoint: { ydoc: '-r--' } }), true); - assert.equal(grants(reader, { endpoint: { ydoc: '--u-' } }), false); - }); - - it('lets an editor GET and PATCH', () => { - assert.equal(grants(editor, { endpoint: { ydoc: '-r--' } }), true); - assert.equal(grants(editor, { endpoint: { ydoc: '--u-' } }), true); - }); - - it('never lets a browser DELETE the document', () => { - // deletion is Django's, through the admin token - assert.equal(grants(editor, { endpoint: { ydoc: '---d' } }), false); - assert.equal(grants(editor, { delete: ['soft'] }), false); - }); -}); - -describe('the history a user may read', () => { - /** - * The rule Docs has always had, now expressed as a permission: a user sees the - * document's history from the moment they were given access to it, and no - * further back. yhub clamps every changeset/activity read up to this, so the - * bound is enforced on the server and the client never has to know it. - */ - it('starts the ray where the user got access', () => { - for (const who of [reader, editor]) { - assert.equal(grants(who, { history: { from: ACCESS_SINCE } }), true); - } - }); - - it('does not reach back before that', () => { - // a requirement asking for a wider ray than the grant is not contained in it - for (const who of [reader, editor]) { - assert.equal(grants(who, { history: { from: ACCESS_SINCE - 1 } }), false); - assert.equal(grants(who, { history: { from: 0 } }), false); - } - }); - - it('opens the timeline and one point in it, read only', () => { - for (const who of [reader, editor]) { - for (const name of ['activity', 'changeset']) { - assert.equal(grants(who, { endpoint: { [name]: '-r--' } }), true); - // GET only: neither endpoint has another verb, and granting one would - // be granting a route that does not exist - assert.equal(grants(who, { endpoint: { [name]: '--u-' } }), false); - } - } - }); - - it('never grants the full ray, which is what would unlock gc=false', () => { - // `gc=false` demands `history.from === 0` exactly. A real access date is - // ~1.8e12, so this can only regress through a bug — assert on the object - // rather than through `hasPermissions`, because it is the literal value - // that matters here - for (const who of [reader, editor]) { - assert.notEqual(who.history, false); - assert.ok(who.history.from > 0); - } - }); - - it('never grants prune', () => { - // erasure, granted by name, and nothing a browser does needs it - for (const who of [reader, editor]) { - assert.equal( - grants(who, { history: { from: ACCESS_SINCE, prune: true } }), - false, - ); - assert.equal(grants(who, { endpoint: { prune: 'c---' } }), false); - } - }); -}); - -describe('undoing a stretch of history', () => { - /** - * `POST /rollback` appends an update that undoes every change in a window: it - * is what the version history's restore button does. An editor may, a reader - * may not, and neither may reach back past the moment they arrived. - */ - it('lets an editor undo a window of its own history', () => { - assert.equal( - grants(editor, { history: { from: ACCESS_SINCE, rollback: true } }), - true, - ); - assert.equal(grants(editor, { endpoint: { rollback: 'c---' } }), true); - }); - - it('refuses an editor a window wider than its ray', () => { - // the difference between a read and a mutation: `activity` would clamp this - // silently, `rollback` refuses it. `from: 0` is what a rollback with no - // bound at all asks for, so this is also what stops "undo everything" - assert.equal( - grants(editor, { history: { from: 0, rollback: true } }), - false, - ); - assert.equal( - grants(editor, { history: { from: ACCESS_SINCE - 1, rollback: true } }), - false, - ); - }); - - it('never lets a reader undo anything', () => { - assert.equal( - grants(reader, { history: { from: ACCESS_SINCE, rollback: true } }), - false, - ); - assert.equal(grants(reader, { endpoint: { rollback: 'c---' } }), false); - }); - - it('is a dead grant without the write it rides on', () => { - // yhub's own rule, asserted here because it is what makes the reader case - // safe even if this policy ever spelled it wrong: `rollback` normalizes to - // false unless `ydoc` carries `u` - const readerWithRollback = normalizePermissions({ - ...browserDocumentPermissions(false, ACCESS_SINCE), - history: { from: ACCESS_SINCE, rollback: true }, - }); - assert.equal(readerWithRollback.history.rollback, false); - }); - - it('is withheld from a browser that holds no access', () => { - for (const who of [linkReader, linkEditor]) { - assert.equal( - grants(who, { history: { from: ACCESS_SINCE, rollback: true } }), - false, - ); - assert.equal(grants(who, { endpoint: { rollback: 'c---' } }), false); - } - }); - - it('is not granted to the admin token', () => { - // 0.8 stopped implying it from write access, and the backend does not - // restore versions — the browser does - assert.equal(grants(admin, { history: { from: 0, rollback: true } }), false); - }); -}); - -describe('a reader who holds no access, only the link', () => { - /** - * There is no access row and so no date. The backend has always refused these - * users their version history for exactly that reason — "we wouldn't know from - * which date to allow them anyway" — and the grant says the same thing by - * withholding the facet. - */ - it('gets no history at all', () => { - for (const who of [linkReader, linkEditor]) { - assert.equal(who.history, false); - assert.equal(grants(who, { history: { from: 0 } }), false); - assert.equal(grants(who, { history: { from: ACCESS_SINCE } }), false); - } - }); - - it('cannot reach the timeline either', () => { - // withheld together with the ray: without it these two answer 403 on their - // own, so granting them would open nothing and only muddy the grant - for (const who of [linkReader, linkEditor]) { - for (const name of ['activity', 'changeset']) { - assert.equal(grants(who, { endpoint: { [name]: '-r--' } }), false); - } - } - }); - - it('still reads and syncs the document like anyone else', () => { - assert.equal(grants(linkReader, { ydoc: '-r--' }), true); - assert.equal(grants(linkEditor, { ydoc: '--u-' }), true); - assert.equal(grants(linkEditor, { endpoint: { ws: '--u-' } }), true); - }); -}); - -describe('everything the browser must not reach', () => { - // there is no '*' fallback in the browser grant, so an endpoint yhub adds in - // a future release is denied until it is named — this is the property that - // replaced 0.7's `purpose != null` check - for (const name of [ - 'prune', - 'create-ydoc', - 'migrate', - 'reset-connections', - 'restore-ydoc', - 'reset-ydoc', - 'some-endpoint-added-later', - ]) { - it(`refuses ${name} to an editor`, () => { - assert.equal(grants(editor, { endpoint: { [name]: '-r--' } }), false); - assert.equal(grants(editor, { endpoint: { [name]: 'c---' } }), false); - }); - } -}); - -describe('the admin token', () => { - it('reaches every endpoint, named or not', () => { - assert.equal( - grants(admin, { endpoint: { 'create-ydoc': 'crud', migrate: 'crud' } }), - true, - ); - }); - - it('reads and writes the document, with its full history', () => { - assert.equal(grants(admin, { ydoc: '-ru-' }), true); - assert.equal(grants(admin, { history: { from: 0 } }), true); - }); - - it('soft-deletes but never hard-deletes over REST', () => { - // DELETE /ydoc?hard=true became reachable over REST in yhub 0.8; Docs keeps - // irreversible erasure programmatic, behind reset-ydoc - assert.equal(grants(admin, { delete: ['soft'] }), true); - assert.equal(grants(admin, { delete: ['hard'] }), false); - }); - - it('is not granted prune', () => { - // 0.8 stopped implying it from write access; Docs does not use it - assert.equal(grants(admin, { history: { from: 0, prune: true } }), false); - }); -}); - -describe('the public global routes', () => { - const globalPerms = normalizePermissions(publicGlobalPermissions); - const globalGrants = (required) => - hasPermissions(globalPerms, { - type: 'permissions:global:v1', - ...required, - }); - - for (const name of ['ping', 'ready', 'jwks']) { - it(`serves ${name} to anyone, read only`, () => { - assert.equal(globalGrants({ endpoint: { [name]: '-r--' } }), true); - assert.equal(globalGrants({ endpoint: { [name]: '--u-' } }), false); - }); - } - - it('refuses a global endpoint it does not name', () => { - assert.equal(globalGrants({ endpoint: { anything: '-r--' } }), false); - }); -}); - -/** - * Where a caller's history starts, from the backend's answers about them. This is - * the one input `browserDocumentPermissions` cannot check for itself: every - * assertion above takes `historyFrom` as given, and this is what decides it. - * - * The `status`-carrying errors are `backendFetch`'s, which is the only thing that - * ever rejects the callback in server.js. - */ -describe('resolveHistoryFrom', () => { - const httpError = (status) => Object.assign(new Error(`HTTP ${status}`), { - status, - }); - const never = () => { - throw new Error('the access must not be fetched'); - }; - - it('does not ask for an access the caller has no history to bound', async () => { - // a link-reach reader: no access, no date, and no extra request on their way in - assert.equal(await resolveHistoryFrom({ versions_list: false }, never), null); - assert.equal(await resolveHistoryFrom({}, never), null); - assert.equal(await resolveHistoryFrom(undefined, never), null); - }); - - it('turns the access date into unix milliseconds', async () => { - const from = await resolveHistoryFrom({ versions_list: true }, async () => ({ - created_at: '2023-11-14T22:13:20Z', - })); - - assert.equal(from, Date.parse('2023-11-14T22:13:20Z')); - assert.equal(from, ACCESS_SINCE); - }); - - it('grants no history when the backend refuses the access', async () => { - // abilities and access disagree — a revocation racing this connection - for (const status of [401, 403, 404]) { - assert.equal( - await resolveHistoryFrom({ versions_list: true }, async () => { - throw httpError(status); - }), - null, - ); - } - }); - - it('rethrows when the backend does not answer at all', async () => { - // not a permission decision: the caller turns this into a retryable 503 - for (const status of [500, 502, 503]) { - await assert.rejects( - resolveHistoryFrom({ versions_list: true }, async () => { - throw httpError(status); - }), - { status }, - ); - } - - await assert.rejects( - resolveHistoryFrom({ versions_list: true }, async () => { - throw new TypeError('fetch failed'); - }), - TypeError, - ); - }); - - it('grants no history rather than all of it on an unusable date', async () => { - for (const created_at of [undefined, null, '', 'not a date']) { - assert.equal( - await resolveHistoryFrom({ versions_list: true }, async () => ({ - created_at, - })), - null, - ); - } - - // and on no payload at all - assert.equal( - await resolveHistoryFrom({ versions_list: true }, async () => undefined), - null, - ); - }); - - it('refuses the epoch, which would unlock a gc=false socket', async () => { - // `from: 0` is the one value that also opens the ungarbage-collected - // connection; no real access date is ever zero - assert.equal( - await resolveHistoryFrom({ versions_list: true }, async () => ({ - created_at: '1970-01-01T00:00:00Z', - })), - null, - ); - }); - - it('answers with a bound a reader is actually held to', async () => { - // the round trip: what this returns is what the tables above are given - const from = await resolveHistoryFrom({ versions_list: true }, async () => ({ - created_at: new Date(ACCESS_SINCE).toISOString(), - })); - const perms = normalizePermissions(browserDocumentPermissions(false, from)); - - assert.equal( - hasPermissions(perms, { - type: 'permissions:document:v1', - history: { from: ACCESS_SINCE }, - }), - true, - ); - assert.equal( - hasPermissions(perms, { - type: 'permissions:document:v1', - history: { from: ACCESS_SINCE - 1 }, - }), - false, - ); - }); -}); diff --git a/src/yhub-server/__tests__/server.spec.mjs b/src/yhub-server/__tests__/server.spec.ts similarity index 89% rename from src/yhub-server/__tests__/server.spec.mjs rename to src/yhub-server/__tests__/server.spec.ts index 3ca782ed0..fa2e66689 100644 --- a/src/yhub-server/__tests__/server.spec.mjs +++ b/src/yhub-server/__tests__/server.spec.ts @@ -1,20 +1,20 @@ -// server.js — configuration, the auth plugin, the custom REST endpoints. +// server.ts — configuration, the auth plugin, the custom REST endpoints. // // The module has no exports and ends on `await createYHub(...)`, so what a unit // test can hold onto is its boot contract: the environment it refuses, and the // configuration it hands yhub when it accepts. `@y/hub`, its S3 plugin and -// `./migration.js` are faked; the config object passed to `createYHub` and the +// `./migration.ts` are faked; the config object passed to `createYHub` and the // args passed to `S3PersistenceV1` are captured. import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { createYHub, s3PluginArgs } = vi.hoisted(() => ({ - createYHub: vi.fn(async (config) => { +const { createYHub, s3PluginArgs } = vi.hoisted(() => { + const createYHub = vi.fn(async (config: any) => { createYHub.lastConfig = config; return { stream: { taskDebounce: 0, minMessageLifetime: 0 } }; - }), - s3PluginArgs: [], -})); + }) as ReturnType & { lastConfig?: any }; + return { createYHub, s3PluginArgs: [] as any[] }; +}); vi.mock('@y/hub', () => { const logger = { @@ -26,25 +26,27 @@ vi.mock('@y/hub', () => { return { createYHub, logger, - apiError: (status, message) => Object.assign(new Error(message), { status }), + apiError: (status: number, message: string) => + Object.assign(new Error(message), { status }), checkPermissions: vi.fn(), - createApiEndpoint: (name, opts) => ({ name, opts }), - createAuthPlugin: (plugin) => plugin, - createAuthorize: (handlers) => handlers, - createDocumentPermissions: (x) => x, + createApiEndpoint: (name: string, opts: unknown) => ({ name, opts }), + createAuthPlugin: (plugin: unknown) => plugin, + createAuthorize: (handlers: unknown) => handlers, + createDocumentPermissions: (x: unknown) => x, }; }); vi.mock('@y/hub/plugins/s3', () => ({ S3PersistenceV1: class { - constructor(args) { + args: unknown; + constructor(args: unknown) { this.args = args; s3PluginArgs.push(args); } }, })); -vi.mock('../migration.js', () => ({ +vi.mock('../src/migration.js', () => ({ SOFT_MIGRATION: false, fullMigrate: vi.fn(), isPermanentFailure: vi.fn(() => false), @@ -61,7 +63,7 @@ const BASE_ENV = { SOFT_MIGRATION: 'false', }; -const boot = () => import('../server.js'); +const boot = () => import('../src/server.js'); beforeEach(() => { vi.unstubAllEnvs(); diff --git a/src/yhub-server/env.js b/src/yhub-server/env.js deleted file mode 100644 index a962bc9a7..000000000 --- a/src/yhub-server/env.js +++ /dev/null @@ -1,10 +0,0 @@ -import { readFileSync } from 'node:fs'; - -// Read a config value that may be supplied either directly (`NAME`) or as a -// path to a file holding it (`NAME_FILE`) — the secret-file convention used -// across this repository, mirroring y-provider's env.ts. Shared by server.js -// and migration.js. -export const secret = (name, dflt) => - process.env[`${name}_FILE`] - ? readFileSync(process.env[`${name}_FILE`], 'utf8').trim() - : process.env[name] || dflt; diff --git a/src/yhub-server/eslint.config.mjs b/src/yhub-server/eslint.config.mjs new file mode 100644 index 000000000..d1c711b5e --- /dev/null +++ b/src/yhub-server/eslint.config.mjs @@ -0,0 +1,33 @@ +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier/flat'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['dist/**', 'node_modules/**'], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }, + ], + }, + }, + { + // the specs mock `@y/hub`, the S3 sdk and the yhub instance — narrow, local + // fakes where full typing buys nothing + files: ['__tests__/**/*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + }, + }, + prettier, +); diff --git a/src/yhub-server/nodemon.json b/src/yhub-server/nodemon.json new file mode 100644 index 000000000..cf75aae7b --- /dev/null +++ b/src/yhub-server/nodemon.json @@ -0,0 +1,6 @@ +{ + "watch": ["src"], + "ext": "ts", + "delay": 1000, + "exec": "yarn build && yarn start" +} diff --git a/src/yhub-server/package.json b/src/yhub-server/package.json index 502b97bde..55c9048d8 100644 --- a/src/yhub-server/package.json +++ b/src/yhub-server/package.json @@ -3,10 +3,13 @@ "private": true, "type": "module", "scripts": { - "start": "node server.js", - "dev": "nodemon --delay 1 server.js", + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "start": "node dist/server.js", + "dev": "nodemon --config nodemon.json", "init-db": "node node_modules/@y/hub/bin/init-db.js", - "test": "node --test __tests__/permissions.test.js && vitest run", + "lint": "eslint", + "test": "vitest run", "test:watch": "vitest" }, "dependencies": { @@ -16,7 +19,13 @@ "jose": "6.2.8" }, "devDependencies": { + "@eslint/js": "10.0.1", + "@types/node": "24.13.3", + "eslint": "10.9.0", + "eslint-config-prettier": "10.1.8", "nodemon": "3.1.14", + "typescript": "6.0.3", + "typescript-eslint": "8.69.0", "vitest": "3.2.7" }, "engines": { diff --git a/src/yhub-server/src/env.ts b/src/yhub-server/src/env.ts new file mode 100644 index 000000000..58c887239 --- /dev/null +++ b/src/yhub-server/src/env.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs'; + +// Read a config value that may be supplied either directly (`NAME`) or as a +// path to a file holding it (`NAME_FILE`) — the secret-file convention used +// across this repository, mirroring y-provider's env.ts. Shared by server.ts +// and migration.ts. +// +// With a default it always returns a string; without one it may return +// `undefined`, and the overloads carry that through to the caller. +export function secret(name: string, dflt: string): string; +export function secret(name: string, dflt?: string): string | undefined; +export function secret(name: string, dflt?: string): string | undefined { + const file = process.env[`${name}_FILE`]; + return file + ? readFileSync(file, 'utf8').trim() + : process.env[name] || dflt; +} diff --git a/src/yhub-server/migration.js b/src/yhub-server/src/migration.ts similarity index 84% rename from src/yhub-server/migration.js rename to src/yhub-server/src/migration.ts index f895d9ef0..848bf9e6a 100644 --- a/src/yhub-server/migration.js +++ b/src/yhub-server/src/migration.ts @@ -31,11 +31,12 @@ import { logger } from '@y/hub'; import * as Y from '@y/y'; import { secret } from './env.js'; +import type { DocRef, YHub } from './yhub.js'; export const SOFT_MIGRATION = process.env.SOFT_MIGRATION === 'true'; // The legacy Django media bucket, the one documents are migrated *out of*. It // carries a prefix of its own because it is not the only bucket in play: the -// S3 persistence plugin (`YHUB_S3_*`, server.js) persists *into* a bucket that +// S3 persistence plugin (`YHUB_S3_*`, server.ts) persists *into* a bucket that // may sit on another provider with credentials of its own, and the backend's // `AWS_S3_*` settings — which a pod may perfectly well carry — name a third. // Each set is read by exactly the process it belongs to. @@ -57,7 +58,7 @@ const LEGACY_S3_SIGNATURE_VERSION = // SigV4 are the whole set: a value asking for SigV2 (`s3`, boto3's other // choice) is refused at boot rather than silently signed the other way and // bounced by the provider as a credentials error. -const SIGNATURE_VERSIONS = { s3v4: 'sigv4', v4: 'sigv4' }; +const SIGNATURE_VERSIONS: Record = { s3v4: 'sigv4', v4: 'sigv4' }; const S3_FETCH_TIMEOUT_MS = 10000; const MIGRATE_LOCK_TTL_MS = 30000; const MAX_CONCURRENT_SEEDS = 20; @@ -70,8 +71,6 @@ const S3_LIST_TIMEOUT_MS = 30000; // response reports how many were dropped. The replay runs on the main thread, // so the cap also bounds how long the event loop is blocked. const MAX_MIGRATE_VERSIONS = 500; -// an empty Yjs update, what patchYdoc diffs the first snapshot against -const EMPTY_YDOC = Y.encodeStateAsUpdate(new Y.Doc()); if ( SOFT_MIGRATION && @@ -84,9 +83,9 @@ if ( 'SOFT_MIGRATION=true requires LEGACY_S3_ENDPOINT_URL, LEGACY_S3_ACCESS_KEY_ID and LEGACY_S3_SECRET_ACCESS_KEY', ); } -const s3 = SOFT_MIGRATION +const s3: S3Client | null = SOFT_MIGRATION ? (() => { - const url = new URL(LEGACY_S3_ENDPOINT_URL); + const url = new URL(LEGACY_S3_ENDPOINT_URL as string); if (url.pathname !== '/' && url.pathname !== '') { // boto3 accepts path-prefixed endpoints but an S3 endpoint cannot // carry a base path — dropping it silently would probe the wrong keys @@ -108,8 +107,8 @@ const s3 = SOFT_MIGRATION // what every S3-compatible implementation answers to by default region: LEGACY_S3_REGION_NAME || 'us-east-1', credentials: { - accessKeyId: LEGACY_S3_ACCESS_KEY_ID, - secretAccessKey: LEGACY_S3_SECRET_ACCESS_KEY, + accessKeyId: LEGACY_S3_ACCESS_KEY_ID as string, + secretAccessKey: LEGACY_S3_SECRET_ACCESS_KEY as string, }, // `sigv4` today, and the client is built from the setting rather than // from the default so that the value is what decides @@ -121,27 +120,65 @@ const s3 = SOFT_MIGRATION }); })() : null; + +// Reach the S3 client only from a path that has already checked SOFT_MIGRATION; +// this turns "used it anyway" into a loud error rather than a null dereference. +const requireS3 = (): S3Client => { + if (s3 == null) { + throw new Error('the legacy S3 document store is not configured'); + } + return s3; +}; + // exported so the auth path can report, under the same module name, that it // admitted a caller to a document it could not migrate export const migrationLog = logger.child({ module: 'soft-migration' }); +/** An error carrying the ad-hoc shape the helpers below tag onto it. */ +interface TaggedError { + name?: string; + message?: string; + permanent?: boolean; + noCache?: boolean; +} +const asTagged = (err: unknown): TaggedError => + typeof err === 'object' && err !== null ? (err as TaggedError) : {}; + +// Is this legacy object beyond saving, as opposed to merely out of reach right +// now? Only a failure raised while *interpreting* bytes we already hold +// qualifies: the object does not decode, or it is larger than we will load. +// Those are marked at the throw site, and nothing else counts — an allowlist +// of retryable errors would have to enumerate every way S3 can say no +// (AccessDenied on a rotated key, NoSuchBucket on a misconfigured name, a +// region redirect), and each one it missed would be read as "this document has +// no content" and open the room empty over content that is alive in S3. +// Guessing wrong in this direction costs a retry; guessing wrong in the other +// costs the document. +export const isPermanentFailure = (err: unknown): boolean => + asTagged(err).permanent === true; + // Both keys are derived from the prefix yhub itself resolved, so they cannot // drift from the room keys, and both sit outside its scanned `:room:*` pattern. // // One seeder per room: -const migrateLockKey = (yhub, docRef) => +const migrateLockKey = (yhub: YHub, docRef: DocRef): string => `${yhub.stream.prefix}:softmigrate:${docRef.org}:${docRef.docid}:${docRef.branch}`; // Documents whose version history has been replayed into postgres. Membership // is permanent: a second replay of the same versions would attribute the same // content twice (see fullMigrate). -const migratedSetKey = (yhub) => `${yhub.stream.prefix}:migrated:v1`; +const migratedSetKey = (yhub: YHub): string => + `${yhub.stream.prefix}:migrated:v1`; // An aborted request surfaces as whatever the sdk or the body stream raises // when the socket goes away ("aborted", TimeoutError, …). Say what actually // happened instead, and leave it unmarked so it stays retryable — a slow S3 // may well recover. -const asTimeout = (err, signal, what, ms) => - signal.aborted ? new Error(`${what} timed out after ${ms}ms`) : err; +const asTimeout = ( + err: unknown, + signal: AbortSignal, + what: string, + ms: number, +): unknown => (signal.aborted ? new Error(`${what} timed out after ${ms}ms`) : err); // Legacy Django document store: object `{docid}/file`, body = UTF-8 text that // is the base64 encoding of a raw Yjs update. With `versionId`, reads that @@ -149,14 +186,17 @@ const asTimeout = (err, signal, what, ms) => // object (or version) does not exist — a document that never had content // saved, e.g. brand new. Throws on any other failure (network, auth, timeout); // corrupt base64 decodes leniently to garbage that the callers reject. -const fetchLegacyDoc = async (docid, versionId = null) => { +const fetchLegacyDoc = async ( + docid: string, + versionId: string | null = null, +): Promise | null> => { // One budget for the whole read, headers and body alike: the sdk aborts the // request when it fires and the body stream dies with it, so a stalled // transfer cannot hold the ws upgrade open. const abortSignal = AbortSignal.timeout(S3_FETCH_TIMEOUT_MS); let body; try { - ({ Body: body } = await s3.send( + ({ Body: body } = await requireS3().send( new GetObjectCommand({ Bucket: LEGACY_S3_BUCKET_NAME, Key: `${docid}/file`, @@ -169,43 +209,57 @@ const fetchLegacyDoc = async (docid, versionId = null) => { // NotFound is the bare 404 some S3-compatible providers answer with // instead; a missing *bucket* has a name of its own and is not caught // here — that one is a misconfiguration, not an absent document. + const name = asTagged(err).name; if ( - err?.name === 'NoSuchKey' || - err?.name === 'NoSuchVersion' || - err?.name === 'NotFound' + name === 'NoSuchKey' || + name === 'NoSuchVersion' || + name === 'NotFound' ) { return null; } throw asTimeout(err, abortSignal, 's3 fetch', S3_FETCH_TIMEOUT_MS); } - let encoded; + let encoded: string; try { // the object whole, whatever its size: it is one document's content, and // refusing to read it is refusing to migrate that document at all - encoded = await body.transformToString('utf8'); + encoded = (await body?.transformToString('utf8')) ?? ''; } catch (err) { throw asTimeout(err, abortSignal, 's3 fetch', S3_FETCH_TIMEOUT_MS); } const decoded = Buffer.from(encoded, 'base64'); // compute-task schema requires an exact Uint8Array (lib0 compares the - // constructor) — re-view the Buffer without copying - return new Uint8Array(decoded.buffer, decoded.byteOffset, decoded.byteLength); + // constructor) — re-view the Buffer without copying. A Node Buffer is always + // backed by a plain ArrayBuffer (never a SharedArrayBuffer), which the + // `.buffer` type does not narrow to on its own. + return new Uint8Array( + decoded.buffer as ArrayBuffer, + decoded.byteOffset, + decoded.byteLength, + ); }; +interface LegacyVersion { + versionId: string; + timestamp: number; +} + // Every version of the legacy object, oldest first. Delete markers are skipped // (they record a deletion and carry no body), and so are keys that merely share // the prefix — S3 has no exact-key version listing. -const listLegacyVersions = async (docid) => { +const listLegacyVersions = async ( + docid: string, +): Promise<{ versions: LegacyVersion[]; dropped: number }> => { const key = `${docid}/file`; // one budget for the whole listing, however many pages it takes const abortSignal = AbortSignal.timeout(S3_LIST_TIMEOUT_MS); - const found = []; + const found: LegacyVersion[] = []; try { - let keyMarker; - let versionIdMarker; + let keyMarker: string | undefined; + let versionIdMarker: string | undefined; let truncated = true; while (truncated) { - const page = await s3.send( + const page = await requireS3().send( new ListObjectVersionsCommand({ Bucket: LEGACY_S3_BUCKET_NAME, Prefix: key, @@ -265,17 +319,21 @@ const listLegacyVersions = async (docid) => { // the stream but carry no content) — then the SELECT again, which closes the // store-before-trim compaction race (and the worst case of a miss is only a // redundant, idempotent re-seed). -const ydocExists = async (yhub, docRef) => { +const ydocExists = async (yhub: YHub, docRef: DocRef): Promise => { if ((await yhub.persistence.retrieveDoc(docRef, {})).lastClock !== '0') { return true; } const streams = await yhub.stream.getMessages([{ docRef, clock: '0' }]); - if ((streams[0]?.messages ?? []).some((m) => m.type === 'ydoc:update:v1')) { + if ( + (streams[0]?.messages ?? []).some((m) => m.type === 'ydoc:update:v1') + ) { return true; } return (await yhub.persistence.retrieveDoc(docRef, {})).lastClock !== '0'; }; +type Verdict = 'exists' | 'empty' | 'failed'; + // Per-docid migration verdicts, in-memory (per replica). 'exists' is monotone // in normal operation — its TTL only bounds staleness after an operator // manually wipes a room's yhub state (restart yhub after a wipe to drop the @@ -283,34 +341,36 @@ const ydocExists = async (yhub, docRef) => { // rechecks off S3; 'failed' breaks the retry-refetch storm a permanently // corrupt object would otherwise sustain (y-websocket retries denied upgrades // forever). -const VERDICT_TTL_MS = { exists: 600000, empty: 60000, failed: 300000 }; +const VERDICT_TTL_MS: Record = { + exists: 600000, + empty: 60000, + failed: 300000, +}; // transient failures (network blips, timeouts, S3 restarting) are cached just // long enough to blunt a retry storm without turning a hiccup into a lockout const TRANSIENT_TTL_MS = 15000; -// Is this legacy object beyond saving, as opposed to merely out of reach right -// now? Only a failure raised while *interpreting* bytes we already hold -// qualifies: the object does not decode, or it is larger than we will load. -// Those are marked at the throw site, and nothing else counts — an allowlist -// of retryable errors would have to enumerate every way S3 can say no -// (AccessDenied on a rotated key, NoSuchBucket on a misconfigured name, a -// region redirect), and each one it missed would be read as "this document has -// no content" and open the room empty over content that is alive in S3. -// Guessing wrong in this direction costs a retry; guessing wrong in the other -// costs the document. -export const isPermanentFailure = (err) => err?.permanent === true; const VERDICT_CACHE_MAX = 50000; -const verdicts = new Map(); // docid -> { verdict, error, expires } + +interface CachedVerdict { + verdict: Verdict; + error: unknown; + expires: number; +} +const verdicts = new Map(); const rememberVerdict = ( - docid, - verdict, - error = null, - ttl = VERDICT_TTL_MS[verdict], -) => { + docid: string, + verdict: Verdict, + error: unknown = null, + ttl: number = VERDICT_TTL_MS[verdict], +): void => { // delete-then-set keeps Map insertion order ≈ recency, so the FIFO eviction // drops the stalest entry — and re-setting an existing docid never evicts // an unrelated one if (!verdicts.delete(docid) && verdicts.size >= VERDICT_CACHE_MAX) { - verdicts.delete(verdicts.keys().next().value); + const oldest = verdicts.keys().next().value; + if (oldest !== undefined) { + verdicts.delete(oldest); + } } verdicts.set(docid, { verdict, @@ -318,15 +378,17 @@ const rememberVerdict = ( expires: Date.now() + ttl, }); }; -const inflightMigrations = new Map(); // docid -> Promise +const inflightMigrations = new Map>(); let activeSeeds = 0; -const migrate = async (yhub, docRef) => { +const migrate = async (yhub: YHub, docRef: DocRef): Promise => { // A fully migrated room holds a single row at clock 0, which leaves // `lastClock` at '0' — so ydocExists cannot see it and would seed on top of a // complete history. Harmless (the seed's attributions are excluded as already // known) but a pointless S3 round-trip per document during a backfill. - if (await yhub.stream.redis.sIsMember(migratedSetKey(yhub), docRef.docid)) { + if ( + await yhub.stream.redis.sIsMember(migratedSetKey(yhub), docRef.docid) + ) { return 'exists'; } if (await ydocExists(yhub, docRef)) return 'exists'; @@ -346,7 +408,10 @@ const migrate = async (yhub, docRef) => { // fetch failed), fall through and seed ourselves: duplicate seeds use // byte-identical updates from one lineage and merge as CRDT no-ops. const deadline = Date.now() + MIGRATE_LOCK_TTL_MS + 5000; - while (Date.now() < deadline && (await redis.exists(lockKey)) === 1) { + while ( + Date.now() < deadline && + (await redis.exists(lockKey)) === 1 + ) { await new Promise((resolve) => setTimeout(resolve, 300)); } if (await ydocExists(yhub, docRef)) return 'exists'; @@ -356,7 +421,9 @@ const migrate = async (yhub, docRef) => { // backoff spreads the load. Probes above stay uncapped. noCache: // momentary per-replica backpressure must deny once, not be cached as // a failure — a slot frees up within seconds - const err = new Error('too many concurrent soft migrations'); + const err: Error & { noCache?: boolean } = new Error( + 'too many concurrent soft migrations', + ); err.noCache = true; throw err; } @@ -378,7 +445,7 @@ const migrate = async (yhub, docRef) => { try { contentids = Y.createContentIdsFromUpdate(update); } catch (err) { - err.permanent = true; + asTagged(err).permanent = true; throw err; } await yhub.stream.addMessage(docRef, { @@ -436,7 +503,10 @@ const migrate = async (yhub, docRef) => { // Resolves when the room is usable (already known, freshly seeded, or // legitimately empty); rejects to deny access. Idempotent and safe to // re-enter — it also runs on rechecks and default-purpose REST calls. -export const maybeMigrate = async (yhub, docRef) => { +export const maybeMigrate = async ( + yhub: YHub, + docRef: DocRef, +): Promise => { const cached = verdicts.get(docRef.docid); if (cached != null && cached.expires > Date.now()) { if (cached.verdict === 'failed') throw cached.error; @@ -447,7 +517,7 @@ export const maybeMigrate = async (yhub, docRef) => { migration = migrate(yhub, docRef) .then( (verdict) => rememberVerdict(docRef.docid, verdict), - (err) => { + (err: unknown) => { // The one place the *cause* is recorded, once per attempt rather // than per access: a cached verdict re-raises this error without // logging again until it expires. @@ -465,7 +535,7 @@ export const maybeMigrate = async (yhub, docRef) => { ? 'soft migration is not possible for this legacy object' : 'soft migration failed; the caller is asked to retry', ); - if (err?.noCache !== true) { + if (asTagged(err).noCache !== true) { // a retryable failure is remembered only briefly, so a hiccup // cannot lock a document out for the full poison-object window rememberVerdict( @@ -484,6 +554,16 @@ export const maybeMigrate = async (yhub, docRef) => { return migration; }; +interface FullMigrateResult { + status: 'already' | 'empty' | 'nothing' | 'ok'; + versions?: number; + applied?: number; + skipped?: number; + dropped?: number; + bytes?: number; + durationMs?: number; +} + // Add the legacy version history to the room. Returns a `status` the endpoint // maps to a response: // 'already' — already replayed for this docid; the room is left untouched @@ -500,14 +580,21 @@ export const maybeMigrate = async (yhub, docRef) => { // messages a live editor is writing; and the history is genuinely the oldest // thing in the room. The next compact task merges the row into the room's // normal state and drops it — yhub needs no special case for any of this. -export const fullMigrate = async (yhub, docRef, { force = false } = {}) => { +export const fullMigrate = async ( + yhub: YHub, + docRef: DocRef, + { force = false }: { force?: boolean } = {}, +): Promise => { const start = Date.now(); const redis = yhub.stream.redis; // Membership is the guard against attributing the same content twice: once // compaction has folded the clock-0 row into a normal one and deleted it, // a second replay would insert a second contentmap for ids that already // carry one, and the two timestamps would both survive the merge. - if (!force && (await redis.sIsMember(migratedSetKey(yhub), docRef.docid))) { + if ( + !force && + (await redis.sIsMember(migratedSetKey(yhub), docRef.docid)) + ) { return { status: 'already' }; } const { versions, dropped } = await listLegacyVersions(docRef.docid); @@ -520,7 +607,7 @@ export const fullMigrate = async (yhub, docRef, { force = false } = {}) => { const ydoc = new Y.Doc({ gc: false }); // ids already attributed, so each version is credited only with what it added let seen = Y.createContentIds(); - const contentmaps = []; + const contentmaps: Y.ContentMap[] = []; let bytes = 0; let skipped = 0; try { @@ -566,7 +653,7 @@ export const fullMigrate = async (yhub, docRef, { force = false } = {}) => { // 'system' identity (as the lazy seed is). The timestamp is what makes an // entry identifiable: it is the version's S3 `LastModified`, so activity // entries line up with the backend's version listing by time. - const attrs = (verb) => [ + const attrs = (verb: string) => [ Y.createContentAttribute(verb, 'system'), Y.createContentAttribute(`${verb}At`, version.timestamp), ]; @@ -603,7 +690,7 @@ export const fullMigrate = async (yhub, docRef, { force = false } = {}) => { ydoc.destroy(); } await redis.sAdd(migratedSetKey(yhub), docRef.docid); - const result = { + const result: FullMigrateResult = { status: 'ok', versions: versions.length, applied: contentmaps.length, diff --git a/src/yhub-server/permissions.js b/src/yhub-server/src/permissions.ts similarity index 81% rename from src/yhub-server/permissions.js rename to src/yhub-server/src/permissions.ts index 3ab013150..b992627f0 100644 --- a/src/yhub-server/permissions.js +++ b/src/yhub-server/src/permissions.ts @@ -1,7 +1,7 @@ /** * Docs' access policy, as yhub 0.8 permission objects. * - * Kept apart from `server.js` so it can be read — and tested — without standing + * Kept apart from `server.ts` so it can be read — and tested — without standing * up redis and postgres: these three tables *are* the policy, and they are the * only thing between a reader and someone else's document. * @@ -11,6 +11,26 @@ * `'-r--'` is read-only and `'----'` grants nothing. */ +import type { + DocumentPermissionsV1, + GlobalPermissionsV1, +} from '@y/hub/permissions'; + +/** + * The backend's verdict on a document (`GET /api/v1.0/documents/{id}/`), of + * which only the abilities the policy reads are named here. + */ +export interface DocumentAbilities { + retrieve?: boolean; + update?: boolean; + versions_list?: boolean; +} + +/** The `GET /api/v1.0/documents/{id}/accesses/me/` payload, likewise trimmed. */ +export interface AccessPayload { + created_at?: string | null; +} + /** * What a browser may do with a document, from the backend's verdict on it. * `canEdit` is `abilities.update`; read access was settled by `abilities.retrieve` @@ -50,7 +70,7 @@ * A bounded ray is not a wall-clock-relative grant: it comes from a stored * `created_at`, so it re-derives identically on every websocket recheck, which is * what yhub's determinism contract asks for. And it never unlocks a `gc=false` - * connection, which requires `from === 0` exactly — see the guard in server.js. + * connection, which requires `from === 0` exactly — see the guard in server.ts. * * No `delete` facet: deleting a document is Django's, through the admin token. * @@ -83,12 +103,20 @@ * `purpose != null` check in `getAccessType`, which `create-ydoc` slipped through * by declaring no purpose. */ -export const browserDocumentPermissions = (canEdit, historyFrom = null) => ({ +export const browserDocumentPermissions = ( + canEdit: boolean, + historyFrom: number | null = null, +): DocumentPermissionsV1 => ({ type: 'permissions:document:v1', ydoc: canEdit ? '-ru-' : '-r--', awareness: canEdit ? '-ru-' : '-r--', ...(historyFrom - ? { history: { from: historyFrom, ...(canEdit && { rollback: true }) } } + ? { + history: { + from: historyFrom, + ...(canEdit ? { rollback: true } : null), + }, + } : null), endpoint: { // `r` opens the socket, `u` admits document updates over it @@ -102,7 +130,7 @@ export const browserDocumentPermissions = (canEdit, historyFrom = null) => ({ ? { activity: '-r--', changeset: '-r--', - ...(canEdit && { rollback: 'c---' }), + ...(canEdit ? { rollback: 'c---' } : null), } : null), }, @@ -114,7 +142,7 @@ export const browserDocumentPermissions = (canEdit, historyFrom = null) => ({ * for the first time, and Docs keeps irreversible erasure programmatic, behind * `reset-ydoc`, exactly as `yhub_services.delete_ydoc` describes. */ -export const adminDocumentPermissions = { +export const adminDocumentPermissions: DocumentPermissionsV1 = { type: 'permissions:document:v1', ydoc: 'cru-', awareness: '-ru-', @@ -130,7 +158,7 @@ export const adminDocumentPermissions = { * only, and named individually — a global endpoint added later is denied until it * is listed here. */ -export const publicGlobalPermissions = { +export const publicGlobalPermissions: GlobalPermissionsV1 = { type: 'permissions:global:v1', endpoint: { ping: '-r--', ready: '-r--', jwks: '-r--' }, }; @@ -153,7 +181,7 @@ export const publicGlobalPermissions = { * Two ways this ends with no history rather than with a date. The backend refusing * (401/403/404) means the abilities and the access disagree — a race with a * revocation, most likely — and the safe reading of that is no history. And - * anything unparseable is *no* history rather than full history, zero refused with + * anything unparsable is *no* history rather than full history, zero refused with * it: `from: 0` is the one value that also unlocks a `gc=false` websocket, and no * real access date is ever zero, so a zero here could only ever be a bug upstream. * @@ -162,16 +190,20 @@ export const publicGlobalPermissions = { * Silently dropping the history there would cost the connection its version panel * for as long as it lives, on a blip. */ -export const resolveHistoryFrom = async (abilities, fetchAccess) => { +export const resolveHistoryFrom = async ( + abilities: DocumentAbilities | null | undefined, + fetchAccess: () => Promise, +): Promise => { if (abilities?.versions_list !== true) { return null; } - let access; + let access: AccessPayload | null | undefined; try { access = await fetchAccess(); } catch (err) { - if (err?.status === 401 || err?.status === 403 || err?.status === 404) { + const status = errStatus(err); + if (status === 401 || status === 403 || status === 404) { return null; } throw err; @@ -180,3 +212,14 @@ export const resolveHistoryFrom = async (abilities, fetchAccess) => { const from = Date.parse(access?.created_at ?? ''); return Number.isFinite(from) && from > 0 ? from : null; }; + +// `backendFetch` (server.ts) is the only thing that rejects `fetchAccess`, and it +// tags its rejections with the HTTP status — read it back without trusting the +// error's shape. +const errStatus = (err: unknown): number | undefined => + typeof err === 'object' && + err !== null && + 'status' in err && + typeof (err as { status: unknown }).status === 'number' + ? (err as { status: number }).status + : undefined; diff --git a/src/yhub-server/server.js b/src/yhub-server/src/server.ts similarity index 89% rename from src/yhub-server/server.js rename to src/yhub-server/src/server.ts index adeb25e5a..299955940 100644 --- a/src/yhub-server/server.js +++ b/src/yhub-server/src/server.ts @@ -11,6 +11,8 @@ import { logger, } from '@y/hub'; import { S3PersistenceV1 } from '@y/hub/plugins/s3'; +import type { DocRef, YHub } from './yhub.js'; +import type { JWK, JWTPayload } from 'jose'; import { calculateJwkThumbprint, createRemoteJWKSet, @@ -21,14 +23,15 @@ import { } from 'jose'; import { secret } from './env.js'; -// Docs' access policy as yhub permission objects — see permissions.test.js +// Docs' access policy as yhub permission objects — see permissions.spec.ts +import type { DocumentAbilities } from './permissions.js'; import { adminDocumentPermissions, browserDocumentPermissions, publicGlobalPermissions, resolveHistoryFrom, } from './permissions.js'; -// legacy Django/S3 document store — see migration.js and README.md +// legacy Django/S3 document store — see migration.ts and README.md import { SOFT_MIGRATION, fullMigrate, @@ -37,13 +40,50 @@ import { migrationLog, } from './migration.js'; +// The caller identity `authenticate` returns and `authorize` is handed. yhub +// only requires `userid`; the rest is Docs' own, read back off `req.authInfo` +// (typed by yhub as the bare `{ userid }`, hence the cast at those sites). +interface AppAuthInfo { + userid: string; + admin?: boolean; + endpoint?: string; + cookie?: string; + origin?: string; +} + +// The shape of the backend payload a caller name resolves to. +interface BackendUser { + id: string | number; +} +// `GET /api/v1.0/documents/{id}/` — only the abilities the policy reads. +interface BackendDocument { + abilities?: DocumentAbilities; +} + +// Read the ad-hoc tags the error paths below switch on without trusting the +// error's runtime shape. +const errStatus = (err: unknown): number | undefined => + typeof err === 'object' && + err !== null && + 'status' in err && + typeof (err as { status: unknown }).status === 'number' + ? (err as { status: number }).status + : undefined; +const errCode = (err: unknown): string | undefined => + typeof err === 'object' && + err !== null && + 'code' in err && + typeof (err as { code: unknown }).code === 'string' + ? (err as { code: string }).code + : undefined; + // A numeric setting, read from the environment and refused rather than guessed // when it is not a whole number at or above `min`: `Number()` reads a typo as // NaN, which yhub takes as-is and turns into a worker that claims nothing or a // stream that is never trimmed — a deployment that looks healthy and is not. // An unset or empty variable is the default, so a kubernetes env var left blank // behaves as if it had not been set at all. -const intEnv = (name, dflt, min = 1) => { +const intEnv = (name: string, dflt: number, min = 1): number => { const raw = process.env[name]; const value = raw == null || raw === '' ? dflt : Number(raw); if (!Number.isInteger(value) || value < min) { @@ -117,7 +157,7 @@ const TASK_CONCURRENCY = intEnv('YHUB_TASK_CONCURRENCY', 5, 1); // reading alone. See README.md. const S3_PERSISTENCE = process.env.YHUB_S3_PERSISTENCE === 'true'; // Its own bucket, named apart from the backend's `AWS_S3_*` and from the legacy -// document store's `LEGACY_S3_*` (migration.js): three buckets that may sit on +// document store's `LEGACY_S3_*` (migration.ts): three buckets that may sit on // three providers with credentials of their own, each read by the process it // belongs to. const YHUB_S3_ENDPOINT_URL = process.env.YHUB_S3_ENDPOINT_URL; @@ -199,7 +239,7 @@ if (backendSigningKey == null) { // the key is rolled. Every token we sign carries it, which is how the backend // picks the matching key — and how it knows to fetch the set again when it // does not know the key yet, so rolling this key needs no change on its side. -const backendPublicJwk = +const backendPublicJwk: (JWK & { kid: string }) | null = backendSigningKey == null ? null : await (async () => { @@ -214,16 +254,19 @@ const backendPublicJwk = }; })(); -/** - * @type {{ token: string, expiresAt: number } | null} - */ -let backendToken = null; +let backendToken: { token: string; expiresAt: number } | null = null; // The token carries no per-document claim, so one is reused until it is about // to expire rather than signing on every notification. -const getBackendToken = async () => { +const getBackendToken = async (): Promise => { + if (backendSigningKey == null || backendPublicJwk == null) { + throw new Error('no backend signing key is configured'); + } const now = Date.now(); - if (backendToken != null && backendToken.expiresAt - BACKEND_TOKEN_MARGIN_MS > now) { + if ( + backendToken != null && + backendToken.expiresAt - BACKEND_TOKEN_MARGIN_MS > now + ) { return backendToken.token; } const token = await new SignJWT({}) @@ -245,7 +288,10 @@ const JWKS = createRemoteJWKSet( new URL(`${COLLABORATION_BACKEND_BASE_URL}/api/v1.0/jwks`), ); -const backendFetch = async (path, { cookie, origin }) => { +const backendFetch = async ( + path: string, + { cookie, origin }: { cookie?: string; origin?: string }, +): Promise => { const res = await fetch(`${COLLABORATION_BACKEND_BASE_URL}${path}`, { headers: { // an anonymous caller may have no session at all; `cookie: undefined` would @@ -258,11 +304,13 @@ const backendFetch = async (path, { cookie, origin }) => { }, }); if (!res.ok) { - const err = new Error(`Failed to fetch ${path}: ${res.status}`); + const err: Error & { status?: number } = new Error( + `Failed to fetch ${path}: ${res.status}`, + ); err.status = res.status; throw err; } - return res.json(); + return res.json() as Promise; }; // First access to a room yhub does not know: seed it from the legacy Django S3 @@ -281,7 +329,7 @@ const backendFetch = async (path, { cookie, origin }) => { // the legacy store could not be reached (timeout, network, backpressure) — // the same request later may well succeed, so it answers 503 rather than // silently starting an empty document on top of content that exists. -const seedFromLegacyStore = async (docRef) => { +const seedFromLegacyStore = async (docRef: DocRef): Promise => { try { // `yhub` is declared at the bottom of this file — safe: auth callbacks only // fire once the server is up, i.e. after that assignment @@ -292,13 +340,17 @@ const seedFromLegacyStore = async (docRef) => { } // why it failed was logged once, at the attempt, inside maybeMigrate migrationLog.warn( - { event: 'seed.skipped', docid: docRef.docid, err: err?.message }, + { + event: 'seed.skipped', + docid: docRef.docid, + err: err instanceof Error ? err.message : undefined, + }, 'admitting caller to a document that could not be migrated; it opens as new', ); } }; -const auth = createAuthPlugin({ +const auth = createAuthPlugin({ /** * Who is asking. Returning `null` here would mean "an anonymous caller" — it is * not a refusal, and `authorize` is still asked — so every rejection below is a @@ -329,7 +381,7 @@ const auth = createAuthPlugin({ const token = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : authorization; - let payload; + let payload: JWTPayload; try { // clockTolerance absorbs Django's cache-at-exp race (the admin token // is cached for exactly its lifetime, so it can arrive here moments @@ -347,10 +399,11 @@ const auth = createAuthPlugin({ // such code (or ERR_JWKS_TIMEOUT): the token may be perfectly valid and // we simply cannot check it, so report it as retryable instead of // accusing the caller of forging it. + const code = errCode(err); if ( - err?.code === 'ERR_JWKS_TIMEOUT' || - typeof err?.code !== 'string' || - !err.code.startsWith('ERR_J') + code === 'ERR_JWKS_TIMEOUT' || + typeof code !== 'string' || + !code.startsWith('ERR_J') ) { throw apiError(503, 'Token verification keys are unavailable'); } @@ -377,7 +430,7 @@ const auth = createAuthPlugin({ return { userid: ANONYMOUS_USERID, origin, endpoint }; } try { - const user = await backendFetch('/api/v1.0/users/me/', { + const user = await backendFetch('/api/v1.0/users/me/', { cookie, origin, }); @@ -389,7 +442,8 @@ const auth = createAuthPlugin({ // to the targeted reset-connections recheck (users: []) for the // connection's whole lifetime — but report it as retryable rather than as // an authentication failure the client should give up on. - if (err?.status !== 401 && err?.status !== 403) { + const status = errStatus(err); + if (status !== 401 && status !== 403) { throw apiError(503, 'Authentication backend is unavailable'); } // the cookie is kept: it is a session the backend still answers document @@ -404,7 +458,7 @@ const auth = createAuthPlugin({ * disconnects with the transient close code 1013 instead of the revoke code * 4401. */ - authorize: createAuthorize({ + authorize: createAuthorize({ async document({ org, docid, branch }, user) { if (user?.admin === true) { // Django's admin token: full access. It still goes through the legacy @@ -435,12 +489,16 @@ const auth = createAuthPlugin({ if (org !== ORG || branch !== 'main' || !UUID4.test(docid)) { return null; } - let doc; + let doc: BackendDocument; try { - doc = await backendFetch(`/api/v1.0/documents/${docid}/`, user); + doc = await backendFetch( + `/api/v1.0/documents/${docid}/`, + user ?? {}, + ); } catch (err) { // the backend answered "no": a real, permanent denial (403 Forbidden) - if (err?.status === 401 || err?.status === 403 || err?.status === 404) { + const status = errStatus(err); + if (status === 401 || status === 403 || status === 404) { return null; } // it did not answer at all — say so, so the caller retries instead of @@ -458,17 +516,20 @@ const auth = createAuthPlugin({ // When this caller was given access, which is where the history they may // read starts. `resolveHistoryFrom` decides whether to ask for it at all - // and what an unusable answer means — see permissions.js. - let accessSince; + // and what an unusable answer means — see permissions.ts. + let accessSince: number | null; try { accessSince = await resolveHistoryFrom(doc.abilities, () => - backendFetch(`/api/v1.0/documents/${docid}/accesses/me/`, user), + backendFetch(`/api/v1.0/documents/${docid}/accesses/me/`, user ?? {}), ); } catch { // the backend did not answer; same treatment as the document fetch above throw apiError(503, 'Document authorization backend is unavailable'); } - return browserDocumentPermissions(doc.abilities.update === true, accessSince); + return browserDocumentPermissions( + doc.abilities.update === true, + accessSince, + ); }, async global() { return publicGlobalPermissions; @@ -478,7 +539,7 @@ const auth = createAuthPlugin({ // Mimic the old y-provider REST responses (JSON, not yhub's lib0-any // encoding) so the Django caller keeps its historical contract. -const jsonResponse = (status, body) => +const jsonResponse = (status: number, body: unknown): Response => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' }, @@ -487,8 +548,8 @@ const jsonResponse = (status, body) => // Does the room hold anything? Covers persisted rows and the messages still on // the stream, which is what makes it an answer about the content rather than // about the storage. -const hasContent = async (yhub, docRef) => { - const { gcDoc } = await yhub.getDoc( +const hasContent = async (yhubApi: YHub, docRef: DocRef): Promise => { + const { gcDoc } = await yhubApi.getDoc( docRef, { gc: true, nongc: false }, { gcOnMerge: false }, @@ -509,9 +570,13 @@ const hasContent = async (yhub, docRef) => { // yhub has no such operation, a hard deletion is final for the room and even // `restoreDoc` refuses it. Here the document id belongs to a Django document // that goes on living, so the room has to be usable again. -const eraseContent = async (yhub, docRef, by) => { - await yhub.deleteDoc(docRef, { hard: true, by }); - await yhub.persistence.deleteTombstone(docRef); +const eraseContent = async ( + yhubApi: YHub, + docRef: DocRef, + by: string, +): Promise => { + await yhubApi.deleteDoc(docRef, { hard: true, by }); + await yhubApi.persistence.deleteTombstone(docRef); }; const readyLog = logger.child({ module: 'readiness' }); @@ -519,8 +584,11 @@ const readyLog = logger.child({ module: 'readiness' }); // One readiness check: is that store answering? The error never leaves the // server — the route is unauthenticated, and a postgres client is happy to put // its connection string, password included, in the message it raises. -const checkStore = async (name, probe) => { - let timer; +const checkStore = async ( + name: string, + probe: () => PromiseLike, +): Promise<[string, string]> => { + let timer: NodeJS.Timeout | undefined; try { await Promise.race([ probe(), @@ -533,7 +601,10 @@ const checkStore = async (name, probe) => { ]); return [name, 'ok']; } catch (err) { - readyLog.warn({ store: name, err: err?.message }, 'store is unreachable'); + readyLog.warn( + { store: name, err: err instanceof Error ? err.message : String(err) }, + 'store is unreachable', + ); return [name, 'unreachable']; } finally { clearTimeout(timer); @@ -637,7 +708,7 @@ const api = [ return jsonResponse(400, { error: 'Unknown branch' }); } if (!SOFT_MIGRATION) { - // the flag is what configures the S3 client (migration.js) + // the flag is what configures the S3 client (migration.ts) return jsonResponse(503, { error: 'Legacy store is not configured' }); } // ?force=true replays a document that is already in the migrated set. @@ -652,7 +723,7 @@ const api = [ // 'nothing' (versions exist, none readable). All four are done, hence // one 2xx; `message` says the same thing to a human, `migrated` // whether this call is the one that wrote the history. - const messages = { + const messages: Record = { already: 'Already migrated', empty: 'No legacy document in s3', nothing: 'No usable content in the legacy versions', @@ -742,9 +813,13 @@ const api = [ // user; regular callers always author as themselves — honoring a // client-supplied header would let any editor forge the attribution // history (the ws path likewise stamps the server-side identity). + const authInfo = req.authInfo as AppAuthInfo | null; + if (authInfo == null) { + return jsonResponse(401, { error: 'Authentication required' }); + } const userid = - (req.authInfo.admin === true && req.headers['x-user-id']) || - req.authInfo.userid; + (authInfo.admin === true && req.headers['x-user-id']) || + authInfo.userid; let result; try { // diffs the posted update against the (empty) current doc and @@ -808,7 +883,9 @@ const api = [ // Error for a document whose content was erased, and that is a // conflict to report as one — catching around the call would turn // every failure alike, a database outage included, into the same answer - const tombstone = await req.yhub.persistence.retrieveTombstone(req.docRef); + const tombstone = await req.yhub.persistence.retrieveTombstone( + req.docRef, + ); if (tombstone == null) { // not an error: the backend restores a whole subtree, of which only // the part that was deleted with it has anything to put back @@ -849,7 +926,11 @@ const api = [ if (req.branch !== 'main') { return jsonResponse(400, { error: 'Unknown branch' }); } - const by = req.headers['x-user-id'] || req.authInfo.userid; + const authInfo = req.authInfo as AppAuthInfo | null; + const by = req.headers['x-user-id'] || authInfo?.userid; + if (!by) { + return jsonResponse(401, { error: 'Authentication required' }); + } // No `checkPermissions` here, deliberately. The honest facet for what // follows would be `delete: ['hard']`, and the admin grant withholds // `'hard'` on purpose so that `DELETE /ydoc?hard=true` stays refused over @@ -892,7 +973,7 @@ const api = [ // Django orders the document lists by `updated_at` and no edit goes through it // anymore, so it is told here that a document moved on. -const touchDocument = async (docid) => { +const touchDocument = async (docid: string): Promise => { if (backendSigningKey == null) return; try { const res = await fetch( @@ -904,7 +985,10 @@ const touchDocument = async (docid) => { }, ); if (!res.ok) { - touchLog.warn({ docid, status: res.status }, 'backend refused the notification'); + touchLog.warn( + { docid, status: res.status }, + 'backend refused the notification', + ); } } catch (err) { // best effort: a lost notification only leaves `updated_at` behind until @@ -918,7 +1002,7 @@ const touchDocument = async (docid) => { // traffic of someone merely opening a document never reaches it. Since yhub // 0.5.0 it is handed the room of the task alongside the merged document. const workerEvents = { - docUpdate: ({ docRef }) => { + docUpdate: ({ docRef }: { docRef: DocRef }) => { // Django knows the documents of this org, on the main branch, by their uuid if ( docRef.org !== ORG || @@ -928,7 +1012,7 @@ const workerEvents = { return; } // deliberately not awaited: a slow backend must not hold the worker - touchDocument(docRef.docid); + void touchDocument(docRef.docid); }, }; @@ -948,7 +1032,7 @@ const workerEvents = { // compaction, which is a background task — the failure would show up as // documents quietly not being persisted. const persistencePlugins = () => { - const settings = [ + const settings: Array<[string, string | undefined]> = [ ['YHUB_S3_ENDPOINT_URL', YHUB_S3_ENDPOINT_URL], ['YHUB_S3_ACCESS_KEY_ID', YHUB_S3_ACCESS_KEY_ID], ['YHUB_S3_SECRET_ACCESS_KEY', YHUB_S3_SECRET_ACCESS_KEY], @@ -969,7 +1053,8 @@ const persistencePlugins = () => { ); } - const url = new URL(YHUB_S3_ENDPOINT_URL); + // every entry of `settings` was checked non-empty just above + const url = new URL(YHUB_S3_ENDPOINT_URL as string); if (url.pathname !== '/' && url.pathname !== '') { // the client is given a host and a port, so a base path would be dropped // without a word and the objects written next to where they belong @@ -984,23 +1069,26 @@ const persistencePlugins = () => { return [ new S3PersistenceV1({ - bucket: YHUB_S3_BUCKET_NAME, + bucket: YHUB_S3_BUCKET_NAME as string, endPoint: url.hostname, // an implicit port parses as "", which the client reads as 0 — its way // of saying "whatever the scheme defaults to" port: Number(url.port), useSSL, - accessKey: YHUB_S3_ACCESS_KEY_ID, - secretKey: YHUB_S3_SECRET_ACCESS_KEY, - // left out rather than passed empty: unset, the client discovers the - // region of the bucket instead of validating an empty string - ...(YHUB_S3_REGION_NAME ? { region: YHUB_S3_REGION_NAME } : {}), + accessKey: YHUB_S3_ACCESS_KEY_ID as string, + secretKey: YHUB_S3_SECRET_ACCESS_KEY as string, // The branches whose blobs are written here: all of them, or none, which // is how the plugin is kept for reading while the writing goes back to // postgres. `store` declines a branch it is not given and yhub falls // through to the database, while `retrieve` and `delete` go on answering // for every object already in the bucket. branches: S3_PERSISTENCE ? true : [], + // minio's Client — which the plugin spreads this whole object into — + // discovers the bucket's region when it is not given one; passed only + // when set, so an empty string cannot fail that discovery. Not part of + // the plugin's published `S3Conf`, so it rides in through the conditional + // spread rather than as a named key. + ...(YHUB_S3_REGION_NAME ? { region: YHUB_S3_REGION_NAME } : {}), // On a versioned bucket a plain delete deletes nothing: it writes a // delete marker over the object and keeps every version underneath it. // Each compaction supersedes the blobs of the one before, so that would @@ -1017,14 +1105,16 @@ const persistencePlugins = () => { // the instance is referenced by the soft-migration helpers above — safe: auth // callbacks only fire once the server is up, i.e. after this assignment -const yhub = await createYHub({ +const yhub: YHub = (await createYHub({ redis: { - url: REDIS, + // yhub validates that both stores are configured and fails with a clear + // message when they are not + url: REDIS as string, prefix: REDIS_PREFIX, taskDebounce: TASK_DEBOUNCE_MS, minMessageLifetime: MIN_MESSAGE_LIFETIME_MS, }, - postgres: POSTGRES, + postgres: POSTGRES as string, // where the blobs live: nothing here keeps them in yhub's postgres persistence: persistencePlugins(), // Both halves are declared, and YHUB_ROLE decides which are built: a null @@ -1050,7 +1140,7 @@ const yhub = await createYHub({ worker: RUNS_WORKER ? { taskConcurrency: TASK_CONCURRENCY, events: workerEvents } : null, -}); +})) as YHub; // What this process was configured to be, in one line: yhub's own startup log // reports neither the role nor the stream settings, and every one of them is an diff --git a/src/yhub-server/src/yhub.ts b/src/yhub-server/src/yhub.ts new file mode 100644 index 000000000..140573d92 --- /dev/null +++ b/src/yhub-server/src/yhub.ts @@ -0,0 +1,11 @@ +// `@y/hub` re-exports its runtime helpers and the `YHub` class from the package +// index, but not the plain data types — and its `exports` map blocks the deep +// path they actually live at. Recover the one this wrapper passes around off a +// method signature of the class itself, so it stays yhub's own type rather than +// a look-alike. +import type { YHub } from '@y/hub'; + +export type { YHub }; + +/** `{ org, docid, branch }` — how yhub addresses one document on one branch. */ +export type DocRef = Parameters[0]; diff --git a/src/yhub-server/tsconfig.build.json b/src/yhub-server/tsconfig.build.json new file mode 100644 index 000000000..4559ad96b --- /dev/null +++ b/src/yhub-server/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "noEmit": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "__tests__"] +} diff --git a/src/yhub-server/tsconfig.json b/src/yhub-server/tsconfig.json new file mode 100644 index 000000000..d189f21c6 --- /dev/null +++ b/src/yhub-server/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["es2023"], + // the server runs the compiled JS straight through Node's ESM loader, so + // resolution has to be Node's — import specifiers keep their `.js` + // extension, as they already did in the JS sources. + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"], + "strict": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "outDir": "./dist", + "sourceMap": true + }, + "include": ["src/**/*.ts", "__tests__/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/yhub-server/vitest.config.mts b/src/yhub-server/vitest.config.mts index 8d702623b..3036dcf20 100644 --- a/src/yhub-server/vitest.config.mts +++ b/src/yhub-server/vitest.config.mts @@ -1,13 +1,11 @@ import { defineConfig } from 'vitest/config'; -// The unit suite. `permissions.test.js` stays on node's own test runner -// (`npm test` runs it first) — it reaches into `@y/hub`'s internals by path and -// is deliberately kept to plain `node:test`. Everything vitest runs lives in -// `__tests__/*.spec.mjs`, so the two runners never fight over the same file. +// The unit suite: `__tests__/*.spec.ts`, none of which needs redis, postgres or +// S3 — `@y/hub`, its S3 plugin and the AWS SDK are faked, `@y/y` is real. export default defineConfig({ test: { - include: ['__tests__/**/*.spec.mjs'], - // migration.js reads SOFT_MIGRATION and the LEGACY_S3_* variables at import + include: ['__tests__/**/*.spec.ts'], + // migration.ts reads SOFT_MIGRATION and the LEGACY_S3_* variables at import // time and builds (or refuses to build) its S3 client from them. The specs // that need a live client set the variables themselves before importing the // module; this is only the floor, so an unset environment cannot make the diff --git a/src/yhub-server/yarn.lock b/src/yhub-server/yarn.lock index 2876d8ec4..56fba974a 100644 --- a/src/yhub-server/yarn.lock +++ b/src/yhub-server/yarn.lock @@ -351,6 +351,90 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87" integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g== +"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": + version "4.10.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6" + integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.12.2": + version "4.12.2" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== + +"@eslint/config-array@^0.23.5": + version "0.23.5" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95" + integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA== + dependencies: + "@eslint/object-schema" "^3.0.5" + debug "^4.3.1" + minimatch "^10.2.4" + +"@eslint/config-helpers@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377" + integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw== + dependencies: + "@eslint/core" "^1.2.1" + +"@eslint/core@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce" + integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/js@10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583" + integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA== + +"@eslint/object-schema@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091" + integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw== + +"@eslint/plugin-kit@^0.7.2": + version "0.7.3" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz#cc7268cc36405b331ef92db1bc37971f21d66fe1" + integrity sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q== + dependencies: + "@eslint/core" "^1.2.1" + levn "^0.4.1" + +"@humanfs/core@^0.19.2": + version "0.19.2" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60" + integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA== + dependencies: + "@humanfs/types" "^0.15.0" + +"@humanfs/node@^0.16.6": + version "0.16.8" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed" + integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ== + dependencies: + "@humanfs/core" "^0.19.2" + "@humanfs/types" "^0.15.0" + "@humanwhocodes/retry" "^0.4.0" + +"@humanfs/types@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090" + integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + "@jridgewell/sourcemap-codec@^1.5.5": version "1.6.0" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz#f4c663e862f06dc98ca4d453862c46902789a18d" @@ -587,11 +671,124 @@ resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== -"@types/estree@1.0.9", "@types/estree@^1.0.0": +"@types/esrecurse@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" + integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== + +"@types/estree@1.0.9", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@^1.0.8": version "1.0.9" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/node@24.13.3": + version "24.13.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-24.13.3.tgz#49f18bd3c647866dcda51a0756c145e14590ce16" + integrity sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q== + dependencies: + undici-types "~7.18.0" + +"@typescript-eslint/eslint-plugin@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz#bf74cc392ebcaaf096bc8b4c4d7bbeb0677687b8" + integrity sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA== + dependencies: + "@eslint-community/regexpp" "^4.12.2" + "@typescript-eslint/scope-manager" "8.69.0" + "@typescript-eslint/type-utils" "8.69.0" + "@typescript-eslint/utils" "8.69.0" + "@typescript-eslint/visitor-keys" "8.69.0" + ignore "^7.0.5" + natural-compare "^1.4.0" + ts-api-utils "^2.5.0" + +"@typescript-eslint/parser@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.69.0.tgz#de3ead2b35e5c71580eda40820adb4fd14834ca1" + integrity sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw== + dependencies: + "@typescript-eslint/scope-manager" "8.69.0" + "@typescript-eslint/types" "8.69.0" + "@typescript-eslint/typescript-estree" "8.69.0" + "@typescript-eslint/visitor-keys" "8.69.0" + debug "^4.4.3" + +"@typescript-eslint/project-service@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.69.0.tgz#cf728554436a50e644a5214a89fe02cb1ffa9af8" + integrity sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.69.0" + "@typescript-eslint/types" "^8.69.0" + debug "^4.4.3" + +"@typescript-eslint/scope-manager@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz#13f3d1e25108e95a9ceb5a198806d1fa558f8c7a" + integrity sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ== + dependencies: + "@typescript-eslint/types" "8.69.0" + "@typescript-eslint/visitor-keys" "8.69.0" + +"@typescript-eslint/tsconfig-utils@8.69.0", "@typescript-eslint/tsconfig-utils@^8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz#d3b0ccc781ab252a90a0b3989b9d1eb85ab59469" + integrity sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ== + +"@typescript-eslint/type-utils@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz#7ce68d2ebcbedd8421806c27a7f360755017159f" + integrity sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA== + dependencies: + "@typescript-eslint/types" "8.69.0" + "@typescript-eslint/typescript-estree" "8.69.0" + "@typescript-eslint/utils" "8.69.0" + debug "^4.4.3" + ts-api-utils "^2.5.0" + +"@typescript-eslint/types@8.69.0", "@typescript-eslint/types@^8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.69.0.tgz#5d9ad3f707c2e4f70a2db540031104df3e63bcf5" + integrity sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA== + +"@typescript-eslint/typescript-estree@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz#efa915913ffe2049bbfd26092b95d1bc7c9c454f" + integrity sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w== + dependencies: + "@typescript-eslint/project-service" "8.69.0" + "@typescript-eslint/tsconfig-utils" "8.69.0" + "@typescript-eslint/types" "8.69.0" + "@typescript-eslint/visitor-keys" "8.69.0" + debug "^4.4.3" + minimatch "^10.2.2" + semver "^7.7.3" + tinyglobby "^0.2.15" + ts-api-utils "^2.5.0" + +"@typescript-eslint/utils@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.69.0.tgz#67ad9c00edf12fe2fbc0bf0a71b00822a8d02e97" + integrity sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + "@typescript-eslint/scope-manager" "8.69.0" + "@typescript-eslint/types" "8.69.0" + "@typescript-eslint/typescript-estree" "8.69.0" + +"@typescript-eslint/visitor-keys@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz#f659785dbb79733c40499f71a65439e2033966b5" + integrity sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w== + dependencies: + "@typescript-eslint/types" "8.69.0" + eslint-visitor-keys "^5.0.0" + "@vitest/expect@3.2.7": version "3.2.7" resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.7.tgz#70a34158383d008c3bf5d802e2643317f09df6d8" @@ -694,6 +891,26 @@ dependencies: lib0 "^1.0.0-rc.28" +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.16.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940" + integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ== + +ajv@^6.14.0: + version "6.15.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492" + integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + anymatch@~3.1.2: version "3.1.3" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" @@ -809,7 +1026,16 @@ cluster-key-slot@1.1.2: resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== -debug@^4, debug@^4.4.1: +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +debug@^4, debug@^4.3.1, debug@^4.3.2, debug@^4.4.1, debug@^4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -826,6 +1052,11 @@ deep-eql@^5.0.1: resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + es-module-lexer@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" @@ -863,6 +1094,100 @@ es-module-lexer@^1.7.0: "@esbuild/win32-ia32" "0.28.2" "@esbuild/win32-x64" "0.28.2" +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-config-prettier@10.1.8: + version "10.1.8" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97" + integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w== + +eslint-scope@^9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802" + integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ== + dependencies: + "@types/esrecurse" "^4.3.1" + "@types/estree" "^1.0.8" + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" + integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== + +eslint@10.9.0: + version "10.9.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.9.0.tgz#3d86068a06c6c78161a4062e69874d38f59d498b" + integrity sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.2" + "@eslint/config-array" "^0.23.5" + "@eslint/config-helpers" "^0.7.0" + "@eslint/core" "^1.2.1" + "@eslint/plugin-kit" "^0.7.2" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + ajv "^6.14.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^9.1.2" + eslint-visitor-keys "^5.0.1" + espree "^11.2.0" + esquery "^1.7.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + minimatch "^10.2.5" + natural-compare "^1.4.0" + optionator "^0.9.3" + +espree@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5" + integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== + dependencies: + acorn "^8.16.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^5.0.1" + +esquery@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + estree-walker@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" @@ -870,6 +1195,11 @@ estree-walker@^3.0.3: dependencies: "@types/estree" "^1.0.0" +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + eventemitter3@^5.0.1: version "5.0.4" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" @@ -880,6 +1210,21 @@ expect-type@^1.2.1: resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" integrity sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA== +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + fast-xml-builder@^1.2.0: version "1.3.1" resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz#ef05b353b45597483dfb09d450a062a2baec3a82" @@ -905,6 +1250,13 @@ fdir@^6.5.0: resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + fill-range@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" @@ -917,11 +1269,39 @@ filter-obj@^1.1.0: resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b" integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ== +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flatted@^3.2.9: + version "3.4.4" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.4.tgz#aeeca2a506303f0cee61c59e6c9f2a88d2f29fc6" + integrity sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q== + fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -939,6 +1319,21 @@ ignore-by-default@^1.0.1: resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +ignore@^7.0.5: + version "7.0.8" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.8.tgz#84d8466899958458ee30b4190c839ee1446cc88d" + integrity sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q== + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + inherits@^2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" @@ -961,7 +1356,7 @@ is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== -is-glob@^4.0.1, is-glob@~4.0.1: +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== @@ -978,6 +1373,11 @@ is-unsafe@^2.0.0: resolved "https://registry.yarnpkg.com/is-unsafe/-/is-unsafe-2.0.2.tgz#bb1ead17f1aa688f6433258b561e98b1a45a1afc" integrity sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ== +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + jose@6.2.8: version "6.2.8" resolved "https://registry.yarnpkg.com/jose/-/jose-6.2.8.tgz#39c1459fe5eac84eb39b1623b8077dcf9ca6c506" @@ -988,11 +1388,48 @@ js-tokens@^9.0.1: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + lib0@^1.0.0-rc.1, lib0@^1.0.0-rc.21, lib0@^1.0.0-rc.27, lib0@^1.0.0-rc.28: version "1.0.0-rc.30" resolved "https://registry.yarnpkg.com/lib0/-/lib0-1.0.0-rc.30.tgz#2c2aec2c91a99230891918e0a551472a6cc8052e" integrity sha512-WhIbHSbBMttOT3Ke/HDA5+kIf9KRDSd5L+aJMBkvJSxwqL7w7004/0X5fjXj8loBIm5PkdgSMRmlPjC2mIzuCw== +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + lodash@^4.17.21: version "4.18.1" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" @@ -1022,7 +1459,7 @@ mime-types@^2.1.35: dependencies: mime-db "1.52.0" -minimatch@^10.2.1: +minimatch@^10.2.1, minimatch@^10.2.2, minimatch@^10.2.4, minimatch@^10.2.5: version "10.2.6" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef" integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A== @@ -1058,6 +1495,11 @@ nanoid@^3.3.18: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + nodemon@3.1.14: version "3.1.14" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.14.tgz#8487ca379c515301d221ec007f27f24ecafa2b51" @@ -1084,11 +1526,47 @@ on-exit-leak-free@^2.1.0: resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8" integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + path-expression-matcher@^1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz#567c73c07197e9dcef24e90edcdc571056599168" integrity sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ== +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + pathe@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" @@ -1157,6 +1635,11 @@ postgres@^3.4.3: resolved "https://registry.yarnpkg.com/postgres/-/postgres-3.4.9.tgz#8e9a49cecb1b0e7d67a607f32a53ed54b3055271" integrity sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw== +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + process-warning@^5.0.0: version "5.1.0" resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-5.1.0.tgz#0ea094b18b610f594243efc585e20f2dbee555dc" @@ -1167,6 +1650,11 @@ pstree.remy@^1.1.8: resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + query-string@^7.1.3: version "7.1.3" resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.3.tgz#a1cf90e994abb113a325804a972d98276fe02328" @@ -1269,11 +1757,23 @@ sax@>=0.6.0: resolved "https://registry.yarnpkg.com/sax/-/sax-1.6.1.tgz#4c23cf608c0b693ab54b4b5888e92cfe977b9843" integrity sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q== -semver@^7.5.3: +semver@^7.5.3, semver@^7.7.3: version "7.8.5" resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + siginfo@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" @@ -1422,16 +1922,55 @@ touch@^3.1.0: resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.1.tgz#097a23d7b161476435e5c1344a95c0f75b4a5694" integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== +ts-api-utils@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1" + integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== + tslib@^2.6.2: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +typescript-eslint@8.69.0: + version "8.69.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.69.0.tgz#28a83f296d9c14001ea0691750c90cac8e94ba96" + integrity sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA== + dependencies: + "@typescript-eslint/eslint-plugin" "8.69.0" + "@typescript-eslint/parser" "8.69.0" + "@typescript-eslint/typescript-estree" "8.69.0" + "@typescript-eslint/utils" "8.69.0" + +typescript@6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21" + integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== + undefsafe@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== +undici-types@~7.18.0: + version "7.18.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" + integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + util-deprecate@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -1495,6 +2034,13 @@ vitest@3.2.7: vite-node "3.2.4" why-is-node-running "^2.3.0" +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + why-is-node-running@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" @@ -1503,6 +2049,11 @@ why-is-node-running@^2.3.0: siginfo "^2.0.0" stackback "0.0.2" +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + xml-naming@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/xml-naming/-/xml-naming-0.3.0.tgz#46c1e18bfe2858479982dd2accf34d16e749eda2" @@ -1520,3 +2071,8 @@ xmlbuilder@~11.0.0: version "11.0.1" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==