🏷️(yhub) migrate yhub-server to typescript

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.
This commit is contained in:
Anthony LC
2026-09-11 10:53:39 +02:00
parent 7b09edf3c5
commit 4d20941bda
24 changed files with 1369 additions and 841 deletions
+7 -3
View File
@@ -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
+4 -3
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
dist/
+30 -14
View File
@@ -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"]
+47 -45
View File
@@ -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.
-125
View File
@@ -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 } };
};
+155
View File
@@ -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<ArrayBuffer> => {
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<ArrayBuffer>[] => {
const doc = new Y.Doc();
const text = doc.get('t', 'text');
const updates: Uint8Array<ArrayBuffer>[] = [];
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<string, string>();
const sets = new Map<string, Set<string>>();
const setOf = (k: string): Set<string> => {
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<string | null> => {
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<string, unknown>;
// 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<typeof fakeRedis>) ?? 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 } };
};
@@ -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<string, unknown>;
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');
@@ -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<typeof normalizePermissions>[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<typeof createDocumentPermissions>[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<typeof createGlobalPermissions>[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');
@@ -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,
);
});
});
@@ -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<typeof vi.fn> & { 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();
-10
View File
@@ -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;
+33
View File
@@ -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,
);
+6
View File
@@ -0,0 +1,6 @@
{
"watch": ["src"],
"ext": "ts",
"delay": 1000,
"exec": "yarn build && yarn start"
}
+12 -3
View File
@@ -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": {
+17
View File
@@ -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;
}
@@ -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<string, string> = { 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<Uint8Array<ArrayBuffer> | 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<boolean> => {
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<Verdict, number> = {
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<string, CachedVerdict>();
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<void>
const inflightMigrations = new Map<string, Promise<void>>();
let activeSeeds = 0;
const migrate = async (yhub, docRef) => {
const migrate = async (yhub: YHub, docRef: DocRef): Promise<Verdict> => {
// 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<void> => {
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<FullMigrateResult> => {
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,
@@ -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<AccessPayload | null | undefined>,
): Promise<number | null> => {
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;
@@ -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<string> => {
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 <T = unknown>(
path: string,
{ cookie, origin }: { cookie?: string; origin?: string },
): Promise<T> => {
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<T>;
};
// 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<void> => {
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<AppAuthInfo>({
/**
* 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<BackendUser>('/api/v1.0/users/me/', {
cookie,
origin,
});
@@ -389,7 +442,8 @@ const auth = createAuthPlugin({
// to the targeted reset-connections recheck (users: [<uuid>]) 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<AppAuthInfo>({
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<BackendDocument>(
`/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<boolean> => {
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<void> => {
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<unknown>,
): 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<typeof status, string> = {
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<void> => {
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
+11
View File
@@ -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<YHub['recheckAuth']>[0];
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"noEmit": false
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "__tests__"]
}
+24
View File
@@ -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"]
}
+4 -6
View File
@@ -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
+561 -5
View File
@@ -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==