mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-11 12:17:55 +02:00
🏷️(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:
@@ -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
@@ -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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
dist/
|
||||
+30
-14
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 } };
|
||||
};
|
||||
@@ -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 } };
|
||||
};
|
||||
+47
-25
@@ -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');
|
||||
+30
-20
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
+17
-15
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"watch": ["src"],
|
||||
"ext": "ts",
|
||||
"delay": 1000,
|
||||
"exec": "yarn build && yarn start"
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
@@ -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];
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "__tests__"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user