diff --git a/src/yhub-server/README.md b/src/yhub-server/README.md index 710f8c0fd..2b5a0025e 100644 --- a/src/yhub-server/README.md +++ b/src/yhub-server/README.md @@ -100,8 +100,8 @@ 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. -`permissions.test.js` asks them the same questions yhub's gates ask; run it with -`npm test`. +`__tests__/permissions.test.js` asks them the same questions yhub's gates ask; +run it with `npm test` (see "Tests" below). Masks are positional `crud` strings where `-` denies, so `'-r--'` is read-only. @@ -380,6 +380,32 @@ made *versioned*, so what is exercised is what a deployment runs rather than a simpler case. Watch it with `mc ls --versions --recursive impress/yhub-storage` from an `mc` container on the stack's network. +## Tests + +`npm test` runs two suites, neither 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** (`npm run 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`. + +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 `npm ci --omit=dev`, so `vitest` is absent +there; the pytest-driven integration suite in `.github/workflows/impress.yml` is +what exercises a real collaboration server. + ## Container image The `Dockerfile` has two final stages, like the other services of this diff --git a/src/yhub-server/__tests__/_helpers.mjs b/src/yhub-server/__tests__/_helpers.mjs new file mode 100644 index 000000000..563e9d2c3 --- /dev/null +++ b/src/yhub-server/__tests__/_helpers.mjs @@ -0,0 +1,125 @@ +// Shared fixtures and fakes for the vitest specs. Named `.mjs` (not `.js`) so +// neither the Dockerfile's `COPY *.js` nor `node --test` ever picks it up, and +// `*.spec.mjs` so the same is true of the spec files themselves. + +import { vi } from 'vitest'; + +import * as Y from '@y/y'; + +// --- Yjs fixtures --------------------------------------------------------- + +// A raw Yjs update (what the legacy S3 object base64-decodes to) for a text +// document holding `text`. Deterministic: one client, one insert. +export const makeUpdate = (text = 'hello world') => { + const doc = new Y.Doc(); + doc.get('t', 'text').insert(0, text); + return Y.encodeStateAsUpdate(doc); +}; + +// A sequence of *full snapshots* of one lineage, the shape `{docid}/file`'s S3 +// versions have: each element is `encodeStateAsUpdate` after one more edit, so +// applying them in order to a gc:false doc yields fresh content ids per step. +export const makeVersionChain = (steps = ['a', 'bc', 'def']) => { + const doc = new Y.Doc(); + const text = doc.get('t', 'text'); + const updates = []; + for (const step of steps) { + text.insert(text.length, step); + updates.push(Y.encodeStateAsUpdate(doc)); + } + return updates; +}; + +export const toBase64 = (update) => Buffer.from(update).toString('base64'); + +// What `fetchLegacyDoc` reads back: a Body with `transformToString`. +export const s3Body = (update) => ({ + transformToString: async () => toBase64(update), +}); + +// A GetObject rejection the sdk would raise for an absent key/version. +export const s3NotFound = (name = 'NoSuchKey') => + Object.assign(new Error(name), { name }); + +// --- a minimal in-memory redis ----------------------------------------- + +// Enough of the client for the lock dance and the migrated set. `set` honors +// the `NX` condition migrate.js relies on; `eval` emulates the compare-and- +// delete lua script it releases the lock with. +export const fakeRedis = () => { + const kv = new Map(); + const sets = new Map(); + const setOf = (k) => sets.get(k) ?? sets.set(k, new Set()).get(k); + return { + kv, + sets, + sIsMember: vi.fn(async (k, m) => setOf(k).has(m)), + sAdd: vi.fn(async (k, m) => void setOf(k).add(m)), + set: vi.fn(async (k, v, opts) => { + if (opts?.condition === 'NX' && kv.has(k)) return null; + kv.set(k, v); + return 'OK'; + }), + exists: vi.fn(async (k) => (kv.has(k) ? 1 : 0)), + del: vi.fn(async (k) => void kv.delete(k)), + eval: vi.fn(async (_script, { keys, arguments: args }) => { + if (kv.get(keys[0]) === args[0]) { + kv.delete(keys[0]); + return 1; + } + return 0; + }), + }; +}; + +// --- a fake yhub instance --------------------------------------------- + +// The surface migration.js touches, all as vi.fns with harmless defaults. Pass +// overrides for the handful a given test drives; reach into `.stream.redis` +// (a fakeRedis) for the lock/set state. +export const makeYhub = (overrides = {}) => { + const redis = overrides.redis ?? fakeRedis(); + const yhub = { + stream: { + prefix: 'yhub', + redis, + // ydocExists: no messages on the stream by default + getMessages: vi.fn(async () => [{ messages: [] }]), + addMessage: vi.fn(async () => {}), + }, + persistence: { + // ydocExists: lastClock '0' means "yhub does not know this room" + retrieveDoc: vi.fn(async () => ({ lastClock: '0' })), + store: vi.fn(async () => {}), + }, + computePool: { + // fullMigrate merges the nongc snapshot into a gc one; hand a marker back + mergeUpdates: vi.fn(async (_gc, [update]) => update), + }, + }; + return deepAssign(yhub, overrides); +}; + +const deepAssign = (target, source) => { + for (const [key, value] of Object.entries(source)) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + typeof value !== 'function' && + target[key] + ) { + deepAssign(target[key], value); + } else { + target[key] = value; + } + } + return target; +}; + +// A stand-in for `@y/hub`'s pino logger: swallow the lines, keep the spies. +export const fakeLoggerModule = () => { + const record = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const child = () => ({ ...record, child }); + return { logger: { ...record, child } }; +}; diff --git a/src/yhub-server/__tests__/migration.spec.mjs b/src/yhub-server/__tests__/migration.spec.mjs new file mode 100644 index 000000000..2b8f2e359 --- /dev/null +++ b/src/yhub-server/__tests__/migration.spec.mjs @@ -0,0 +1,618 @@ +// migration.js — the legacy Django/S3 document store reader. +// +// The two entry points server.js 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 * as Y from '@y/y'; + +import { + makeUpdate, + makeVersionChain, + makeYhub, + fakeRedis, + s3Body, + s3NotFound, +} from './_helpers.mjs'; + +// migration.js 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'; +process.env.LEGACY_S3_ENDPOINT_URL = 'https://legacy.example.test'; +process.env.LEGACY_S3_ACCESS_KEY_ID = 'legacy-key'; +process.env.LEGACY_S3_SECRET_ACCESS_KEY = 'legacy-secret'; +process.env.LEGACY_S3_BUCKET_NAME = 'legacy-media'; +delete process.env.LEGACY_S3_SIGNATURE_VERSION; +delete process.env.LEGACY_S3_REGION_NAME; + +// One send() spy for the whole file; every S3Client the module builds routes +// here. `s3configs` captures each client's constructor config. +const { s3send, s3configs } = vi.hoisted(() => ({ + s3send: vi.fn(), + s3configs: [], +})); + +vi.mock('@aws-sdk/client-s3', () => ({ + S3Client: class { + constructor(config) { + s3configs.push(config); + } + send(command, options) { + return s3send(command, options); + } + }, + GetObjectCommand: class { + constructor(input) { + this.input = input; + this.kind = 'get'; + } + }, + ListObjectVersionsCommand: class { + constructor(input) { + this.input = input; + this.kind = 'list'; + } + }, +})); + +vi.mock('@y/hub', () => { + const rec = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const child = () => ({ ...rec, child }); + return { logger: { ...rec, child } }; +}); + +const load = () => import('../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'; + +// 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) => { + if (command.kind === 'get') { + const versionId = command.input.VersionId; + if (versionId != null && getByVersion) { + if (!(versionId in getByVersion)) throw s3NotFound('NoSuchVersion'); + const entry = getByVersion[versionId]; + if (entry instanceof Error) throw entry; + return { Body: entry }; + } + if (get) return get(command.input); + return { Body: s3Body(makeUpdate()) }; + } + if (command.kind === 'list') { + return typeof list === 'function' + ? list(command.input) + : (list ?? { Versions: [], IsTruncated: false }); + } + throw new Error(`unexpected S3 command: ${command.kind}`); + }); +}; + +// 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']) { + 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]); + } + } + } + return out; +}; + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + s3send.mockReset(); + s3configs.length = 0; +}); + +describe('module load', () => { + + it('refuses to boot when SOFT_MIGRATION is set without credentials', async () => { + vi.stubEnv('LEGACY_S3_ACCESS_KEY_ID', ''); + vi.resetModules(); + await expect(load()).rejects.toThrow(/requires LEGACY_S3_/); + }); + + it('refuses an endpoint URL that carries a path', async () => { + vi.stubEnv('LEGACY_S3_ENDPOINT_URL', 'https://legacy.example.test/media'); + vi.resetModules(); + await expect(load()).rejects.toThrow(/must not contain a path/); + }); + + it('refuses a SigV2 signature version', async () => { + vi.stubEnv('LEGACY_S3_SIGNATURE_VERSION', 's3'); + vi.resetModules(); + await expect(load()).rejects.toThrow( + /LEGACY_S3_SIGNATURE_VERSION must be one of/, + ); + }); + + it('accepts the v4 spelling of the signature version', async () => { + vi.stubEnv('LEGACY_S3_SIGNATURE_VERSION', 'v4'); + vi.resetModules(); + const mod = await load(); + expect(mod.SOFT_MIGRATION).toBe(true); + }); + + it('addresses a self-hosted endpoint path-style and defaults the region', async () => { + vi.resetModules(); + await load(); + const config = s3configs.at(-1); + expect(config.forcePathStyle).toBe(true); + expect(config.region).toBe('us-east-1'); + expect(config.authSchemePreference).toEqual(['aws.auth#sigv4']); + }); + + it('addresses an amazonaws.com endpoint virtual-host-style', async () => { + vi.stubEnv('LEGACY_S3_ENDPOINT_URL', 'https://s3.eu-west-3.amazonaws.com'); + vi.resetModules(); + await load(); + expect(s3configs.at(-1).forcePathStyle).toBe(false); + }); + + it('builds no S3 client when SOFT_MIGRATION is off', async () => { + vi.stubEnv('SOFT_MIGRATION', 'false'); + vi.resetModules(); + const mod = await load(); + expect(mod.SOFT_MIGRATION).toBe(false); + expect(s3configs).toHaveLength(0); + }); +}); + +describe('isPermanentFailure', () => { + it('is true only for an error marked permanent', async () => { + const { isPermanentFailure } = await load(); + expect(isPermanentFailure(Object.assign(new Error(), { permanent: true }))).toBe( + true, + ); + expect(isPermanentFailure(new Error('network'))).toBe(false); + expect(isPermanentFailure(undefined)).toBe(false); + }); +}); + +describe('maybeMigrate — the lazy seed', () => { + it('seeds an unknown room from the newest legacy object', async () => { + const update = makeUpdate('hello world'); + routeS3({ get: () => ({ Body: s3Body(update) }) }); + const { maybeMigrate } = await load(); + const yhub = makeYhub(); + + await maybeMigrate(yhub, docRef()); + + expect(yhub.stream.addMessage).toHaveBeenCalledTimes(1); + const [ref, message] = yhub.stream.addMessage.mock.calls[0]; + 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]; + expect(get.input).toEqual({ Bucket: 'legacy-media', Key: 'doc-1/file' }); + }); + + it('attributes the seed to system/s3 and stamps no timestamp', async () => { + routeS3({ get: () => ({ Body: s3Body(makeUpdate()) }) }); + const { maybeMigrate } = await load(); + const yhub = makeYhub(); + + await maybeMigrate(yhub, docRef()); + + const { contentmap } = yhub.stream.addMessage.mock.calls[0][1]; + const { inserts } = attrsOf(contentmap); + expect(inserts).toContainEqual(['insert', 'system']); + expect(inserts).toContainEqual(['insert:migration', 's3']); + expect(inserts.map(([name]) => name)).not.toContain('insertAt'); + }); + + it('does not seed a room yhub already persisted', async () => { + routeS3(); + const { maybeMigrate } = await load(); + const yhub = makeYhub({ + persistence: { retrieveDoc: vi.fn(async () => ({ lastClock: '7' })) }, + }); + + await maybeMigrate(yhub, docRef()); + + expect(yhub.stream.addMessage).not.toHaveBeenCalled(); + expect(s3send).not.toHaveBeenCalled(); + }); + + it('does not seed a room that already has an update on the stream', async () => { + routeS3(); + const { maybeMigrate } = await load(); + const yhub = makeYhub({ + stream: { + getMessages: vi.fn(async () => [ + { messages: [{ type: 'ydoc:update:v1' }] }, + ]), + }, + }); + + await maybeMigrate(yhub, docRef()); + + expect(yhub.stream.addMessage).not.toHaveBeenCalled(); + }); + + it('ignores non-content messages when probing the stream', async () => { + routeS3({ get: () => ({ Body: s3Body(makeUpdate()) }) }); + const { maybeMigrate } = await load(); + const yhub = makeYhub({ + stream: { + getMessages: vi.fn(async () => [ + { messages: [{ type: 'awareness:v1' }, { type: 'auth-check:v1' }] }, + ]), + }, + }); + + await maybeMigrate(yhub, docRef()); + + expect(yhub.stream.addMessage).toHaveBeenCalledTimes(1); + }); + + it.each(['NoSuchKey', 'NoSuchVersion', 'NotFound'])( + 'treats a %s from S3 as an empty room, not an error', + async (name) => { + routeS3({ get: () => { throw s3NotFound(name); } }); + const { maybeMigrate } = await load(); + const yhub = makeYhub(); + + await expect(maybeMigrate(yhub, docRef())).resolves.toBeUndefined(); + expect(yhub.stream.addMessage).not.toHaveBeenCalled(); + }, + ); + + it('rejects, permanently, a legacy object that is not a valid Yjs update', async () => { + routeS3({ get: () => ({ Body: s3Body(new Uint8Array([1, 2, 3, 4, 5, 6])) }) }); + const { maybeMigrate, isPermanentFailure } = await load(); + const yhub = makeYhub(); + + const err = await maybeMigrate(yhub, docRef()).catch((e) => e); + expect(isPermanentFailure(err)).toBe(true); + expect(yhub.stream.addMessage).not.toHaveBeenCalled(); + }); + + it('caches a permanent failure: a second access does not re-fetch S3', async () => { + routeS3({ get: () => ({ Body: s3Body(new Uint8Array([9, 9, 9, 9, 9, 9])) }) }); + const { maybeMigrate } = await load(); + const yhub = makeYhub(); + + await maybeMigrate(yhub, docRef()).catch(() => {}); + await maybeMigrate(yhub, docRef()).catch(() => {}); + + expect(s3send.mock.calls.filter(([c]) => c.kind === 'get')).toHaveLength(1); + }); + + it('caches the empty verdict: a second access does not re-fetch S3', async () => { + routeS3({ get: () => { throw s3NotFound('NoSuchKey'); } }); + const { maybeMigrate } = await load(); + const yhub = makeYhub(); + + await maybeMigrate(yhub, docRef()); + await maybeMigrate(yhub, docRef()); + + expect(s3send.mock.calls.filter(([c]) => c.kind === 'get')).toHaveLength(1); + }); + + it('reports a network error as retryable, not permanent', async () => { + routeS3({ get: () => { throw new Error('ECONNRESET'); } }); + const { maybeMigrate, isPermanentFailure } = await load(); + const yhub = makeYhub(); + + const err = await maybeMigrate(yhub, docRef()).catch((e) => e); + expect(isPermanentFailure(err)).toBe(false); + }); + + it('collapses concurrent first-connections to one S3 fetch and one seed', async () => { + routeS3({ get: () => ({ Body: s3Body(makeUpdate()) }) }); + const { maybeMigrate } = await load(); + const yhub = makeYhub(); + + await Promise.all([ + maybeMigrate(yhub, docRef()), + maybeMigrate(yhub, docRef()), + maybeMigrate(yhub, docRef()), + ]); + + expect(s3send.mock.calls.filter(([c]) => c.kind === 'get')).toHaveLength(1); + expect(yhub.stream.addMessage).toHaveBeenCalledTimes(1); + }); + + it('releases its own migrate lock with a compare-and-delete', async () => { + routeS3({ get: () => ({ Body: s3Body(makeUpdate()) }) }); + const { maybeMigrate } = await load(); + const redis = fakeRedis(); + const yhub = makeYhub({ redis }); + + await maybeMigrate(yhub, docRef()); + + expect(redis.eval).toHaveBeenCalledTimes(1); + expect(redis.kv.has(LOCK_KEY)).toBe(false); + }); + + it('seeds itself when another seeder held the lock but left the room empty', async () => { + routeS3({ get: () => ({ Body: s3Body(makeUpdate()) }) }); + const { maybeMigrate } = await load(); + const redis = fakeRedis(); + redis.kv.set(LOCK_KEY, 'another-replica'); + // the other seeder's lock has already gone by the time we re-probe + redis.exists = vi.fn(async () => 0); + const yhub = makeYhub({ redis }); + + await maybeMigrate(yhub, docRef()); + + expect(yhub.stream.addMessage).toHaveBeenCalledTimes(1); + // acquired nothing, so it must not touch the other replica's lock + expect(redis.eval).not.toHaveBeenCalled(); + }); + + it('skips the S3 round-trip for a fully migrated room', async () => { + routeS3(); + const { maybeMigrate } = await load(); + const redis = fakeRedis(); + await redis.sAdd(MIGRATED_SET, 'doc-1'); + const yhub = makeYhub({ redis }); + + await maybeMigrate(yhub, docRef()); + + expect(s3send).not.toHaveBeenCalled(); + expect(yhub.stream.addMessage).not.toHaveBeenCalled(); + }); +}); + +describe('fullMigrate — the version-history backfill', () => { + const listNewestFirst = (entries) => ({ + Versions: entries.map(({ versionId, ms }) => ({ + Key: 'doc-1/file', + VersionId: versionId, + LastModified: new Date(ms), + })), + IsTruncated: false, + }); + + it('reports an empty status when there is no legacy object', async () => { + routeS3({ list: { Versions: [], IsTruncated: false } }); + const { fullMigrate } = await load(); + const yhub = makeYhub(); + + const result = await fullMigrate(yhub, docRef()); + + expect(result).toMatchObject({ status: 'empty', versions: 0 }); + expect(yhub.persistence.store).not.toHaveBeenCalled(); + }); + + it('replays every version oldest-first into one clock-0 row', async () => { + const [v1, v2] = makeVersionChain(['a', 'bc']); + routeS3({ + list: listNewestFirst([ + { versionId: 'ver-2', ms: 2000 }, + { versionId: 'ver-1', ms: 1000 }, + ]), + getByVersion: { 'ver-1': s3Body(v1), 'ver-2': s3Body(v2) }, + }); + const { fullMigrate } = await load(); + const redis = fakeRedis(); + const yhub = makeYhub({ redis }); + + const result = await fullMigrate(yhub, docRef()); + + expect(result).toMatchObject({ + status: 'ok', + versions: 2, + applied: 2, + skipped: 0, + dropped: 0, + }); + + // fetched in ascending-timestamp order, not the order S3 listed them + const fetchedVersionIds = s3send.mock.calls + .filter(([c]) => c.kind === 'get') + .map(([c]) => c.input.VersionId); + expect(fetchedVersionIds).toEqual(['ver-1', 'ver-2']); + + expect(yhub.persistence.store).toHaveBeenCalledTimes(1); + const [ref, row] = yhub.persistence.store.mock.calls[0]; + expect(ref).toEqual(docRef()); + expect(row.lastClock).toBe('0'); + expect(row.contentmap).toBeInstanceOf(Uint8Array); + expect(row.contentids).toBeInstanceOf(Uint8Array); + + expect(redis.sets.get(MIGRATED_SET)).toEqual(new Set(['doc-1'])); + }); + + it("stamps each version's content with that version's S3 timestamp", async () => { + const [v1, v2] = makeVersionChain(['a', 'bc']); + routeS3({ + list: listNewestFirst([ + { versionId: 'ver-2', ms: 2000 }, + { versionId: 'ver-1', ms: 1000 }, + ]), + getByVersion: { 'ver-1': s3Body(v1), 'ver-2': s3Body(v2) }, + }); + const { fullMigrate } = await load(); + const yhub = makeYhub(); + + await fullMigrate(yhub, docRef()); + + const { inserts } = attrsOf(yhub.persistence.store.mock.calls[0][1].contentmap); + expect(inserts).toContainEqual(['insert', 'system']); + expect(inserts).toContainEqual(['insertAt', 1000]); + expect(inserts).toContainEqual(['insertAt', 2000]); + }); + + it('leaves a document already in the migrated set untouched', async () => { + routeS3(); + const { fullMigrate } = await load(); + const redis = fakeRedis(); + await redis.sAdd(MIGRATED_SET, 'doc-1'); + const yhub = makeYhub({ redis }); + + const result = await fullMigrate(yhub, docRef()); + + expect(result).toEqual({ status: 'already' }); + expect(s3send).not.toHaveBeenCalled(); + }); + + it('force replays a document that is already in the set', async () => { + routeS3({ + list: listNewestFirst([{ versionId: 'ver-1', ms: 1000 }]), + getByVersion: { 'ver-1': s3Body(makeUpdate()) }, + }); + const { fullMigrate } = await load(); + const redis = fakeRedis(); + await redis.sAdd(MIGRATED_SET, 'doc-1'); + const yhub = makeYhub({ redis }); + + const result = await fullMigrate(yhub, docRef(), { force: true }); + + expect(result.status).toBe('ok'); + expect(yhub.persistence.store).toHaveBeenCalledTimes(1); + }); + + it('skips a corrupt version and keeps going', async () => { + const [, v2] = makeVersionChain(['a', 'bc']); + routeS3({ + list: listNewestFirst([ + { versionId: 'ver-2', ms: 2000 }, + { versionId: 'ver-1', ms: 1000 }, + ]), + getByVersion: { + 'ver-1': s3Body(new Uint8Array([1, 2, 3, 4, 5])), + 'ver-2': s3Body(v2), + }, + }); + const { fullMigrate } = await load(); + const yhub = makeYhub(); + + const result = await fullMigrate(yhub, docRef()); + + expect(result).toMatchObject({ status: 'ok', applied: 1, skipped: 1 }); + }); + + it('reports "nothing" and does not remember a document with no readable version', async () => { + routeS3({ + list: listNewestFirst([ + { versionId: 'ver-2', ms: 2000 }, + { versionId: 'ver-1', ms: 1000 }, + ]), + getByVersion: { + 'ver-1': s3Body(new Uint8Array([1, 2, 3])), + 'ver-2': s3Body(new Uint8Array([4, 5, 6])), + }, + }); + const { fullMigrate } = await load(); + const redis = fakeRedis(); + const yhub = makeYhub({ redis }); + + const result = await fullMigrate(yhub, docRef()); + + expect(result).toMatchObject({ status: 'nothing', applied: 0, skipped: 2 }); + expect(yhub.persistence.store).not.toHaveBeenCalled(); + expect(redis.sets.get(MIGRATED_SET)?.has('doc-1')).toBeFalsy(); + }); + + it('silently skips a version that vanished between listing and read', async () => { + const [, v2] = makeVersionChain(['a', 'bc']); + routeS3({ + list: listNewestFirst([ + { versionId: 'ver-2', ms: 2000 }, + { versionId: 'ver-1', ms: 1000 }, + ]), + getByVersion: { + 'ver-1': s3NotFound('NoSuchVersion'), + 'ver-2': s3Body(v2), + }, + }); + const { fullMigrate } = await load(); + const yhub = makeYhub(); + + const result = await fullMigrate(yhub, docRef()); + + expect(result).toMatchObject({ status: 'ok', applied: 1, skipped: 0 }); + }); + + it('follows the version listing across pages', async () => { + const [v1, v2, v3] = makeVersionChain(['a', 'bc', 'def']); + const pages = [ + { + Versions: [ + { Key: 'doc-1/file', VersionId: 'ver-3', LastModified: new Date(3000) }, + ], + IsTruncated: true, + NextKeyMarker: 'key-marker', + NextVersionIdMarker: 'version-marker', + }, + { + Versions: [ + { Key: 'doc-1/file', VersionId: 'ver-2', LastModified: new Date(2000) }, + { Key: 'doc-1/file', VersionId: 'ver-1', LastModified: new Date(1000) }, + ], + IsTruncated: false, + }, + ]; + let call = 0; + routeS3({ + list: (input) => { + const page = pages[call++]; + if (call === 2) { + expect(input.KeyMarker).toBe('key-marker'); + expect(input.VersionIdMarker).toBe('version-marker'); + } + return page; + }, + getByVersion: { + 'ver-1': s3Body(v1), + 'ver-2': s3Body(v2), + 'ver-3': s3Body(v3), + }, + }); + const { fullMigrate } = await load(); + const yhub = makeYhub(); + + const result = await fullMigrate(yhub, docRef()); + + expect(call).toBe(2); + expect(result).toMatchObject({ status: 'ok', versions: 3, applied: 3 }); + }); + + it('drops the oldest versions past the 500 cap', async () => { + const update = makeUpdate('constant'); + const entries = Array.from({ length: 501 }, (_, i) => ({ + versionId: `ver-${i}`, + ms: 1000 + i, + })); + routeS3({ + list: listNewestFirst(entries), + getByVersion: Object.fromEntries( + entries.map(({ versionId }) => [versionId, s3Body(update)]), + ), + }); + const { fullMigrate } = await load(); + const yhub = makeYhub(); + + const result = await fullMigrate(yhub, docRef()); + + expect(result).toMatchObject({ versions: 500, dropped: 1 }); + }); + + it('rejects when the version listing itself fails', async () => { + routeS3({ list: () => { throw new Error('S3 is down'); } }); + const { fullMigrate } = await load(); + const yhub = makeYhub(); + + await expect(fullMigrate(yhub, docRef())).rejects.toThrow('S3 is down'); + const { logger } = await import('@y/hub'); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/src/yhub-server/__tests__/permissions.spec.mjs b/src/yhub-server/__tests__/permissions.spec.mjs new file mode 100644 index 000000000..eec0672e3 --- /dev/null +++ b/src/yhub-server/__tests__/permissions.spec.mjs @@ -0,0 +1,273 @@ +// permissions.js — 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 +// differently. + +import { describe, expect, it } from 'vitest'; + +import { + createDocumentPermissions, + hasPermissions, + normalizePermissions, +} from '@y/hub/permissions'; + +import { + adminDocumentPermissions, + browserDocumentPermissions, + publicGlobalPermissions, + resolveHistoryFrom, +} from '../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 grants = (permissions, required) => + hasPermissions(permissions, createDocumentPermissions(required)); + +describe('presence', () => { + it('lets a reader receive presence but never broadcast it', () => { + expect(grants(reader, { awareness: '-r--' })).toBe(true); + expect(grants(reader, { awareness: '--u-' })).toBe(false); + }); + + it('lets an editor broadcast presence', () => { + expect(grants(editor, { awareness: '--u-' })).toBe(true); + }); +}); + +describe('the document and its transports', () => { + it('lets a reader read but not write, on either transport', () => { + expect(grants(reader, { ydoc: '-r--' })).toBe(true); + expect(grants(reader, { ydoc: '--u-' })).toBe(false); + expect(grants(reader, { endpoint: { ws: '-r--' } })).toBe(true); + expect(grants(reader, { endpoint: { ws: '--u-' } })).toBe(false); + expect(grants(reader, { endpoint: { ydoc: '--u-' } })).toBe(false); + }); + + it('lets an editor write over the socket and PATCH over http', () => { + expect(grants(editor, { ydoc: '--u-' })).toBe(true); + expect(grants(editor, { endpoint: { ws: '--u-' } })).toBe(true); + expect(grants(editor, { endpoint: { ydoc: '--u-' } })).toBe(true); + }); + + it('never lets a browser DELETE the document', () => { + expect(grants(editor, { endpoint: { ydoc: '---d' } })).toBe(false); + expect(grants(editor, { delete: ['soft'] })).toBe(false); + }); + + it('withholds create — populating initial content is the admin token', () => { + expect(grants(editor, { ydoc: 'c---' })).toBe(false); + }); +}); + +describe('the history a user may read', () => { + it('starts the ray where the user got access and no earlier', () => { + for (const who of [reader, editor]) { + expect(grants(who, { history: { from: ACCESS_SINCE } })).toBe(true); + expect(grants(who, { history: { from: ACCESS_SINCE - 1 } })).toBe(false); + expect(grants(who, { history: { from: 0 } })).toBe(false); + } + }); + + it('opens the timeline read-only for reader and editor alike', () => { + for (const who of [reader, editor]) { + for (const name of ['activity', 'changeset']) { + expect(grants(who, { endpoint: { [name]: '-r--' } })).toBe(true); + expect(grants(who, { endpoint: { [name]: '--u-' } })).toBe(false); + } + } + }); + + 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); + } + }); + + it('never grants prune', () => { + for (const who of [reader, editor]) { + expect(grants(who, { history: { from: ACCESS_SINCE, prune: true } })).toBe( + false, + ); + expect(grants(who, { endpoint: { prune: 'c---' } })).toBe(false); + } + }); +}); + +describe('rollback', () => { + it('lets an editor undo a window inside its own ray', () => { + expect( + grants(editor, { history: { from: ACCESS_SINCE, rollback: true } }), + ).toBe(true); + expect(grants(editor, { endpoint: { rollback: 'c---' } })).toBe(true); + }); + + it('refuses an editor a window wider than its ray, or unbounded', () => { + expect(grants(editor, { history: { from: 0, rollback: true } })).toBe(false); + expect( + grants(editor, { history: { from: ACCESS_SINCE - 1, rollback: true } }), + ).toBe(false); + }); + + it('never lets a reader roll back anything', () => { + expect( + grants(reader, { history: { from: ACCESS_SINCE, rollback: true } }), + ).toBe(false); + expect(grants(reader, { endpoint: { rollback: 'c---' } })).toBe(false); + }); + + it('is a dead grant without the write it rides on', () => { + const readerWithRollback = normalizePermissions({ + ...browserDocumentPermissions(false, ACCESS_SINCE), + history: { from: ACCESS_SINCE, rollback: true }, + }); + expect(readerWithRollback.history.rollback).toBe(false); + }); + + it('is withheld from the admin token', () => { + expect(grants(admin, { history: { from: 0, rollback: true } })).toBe(false); + }); +}); + +describe('a reader who holds only the link', () => { + it('gets no history and cannot reach the timeline', () => { + for (const who of [linkReader, linkEditor]) { + expect(who.history).toBe(false); + expect(grants(who, { history: { from: ACCESS_SINCE } })).toBe(false); + for (const name of ['activity', 'changeset']) { + expect(grants(who, { endpoint: { [name]: '-r--' } })).toBe(false); + } + } + }); + + it('still reads and syncs the document like anyone else', () => { + expect(grants(linkReader, { ydoc: '-r--' })).toBe(true); + expect(grants(linkEditor, { ydoc: '--u-' })).toBe(true); + expect(grants(linkEditor, { endpoint: { ws: '--u-' } })).toBe(true); + }); +}); + +describe('everything the browser must not reach', () => { + it.each([ + 'prune', + 'create-ydoc', + 'migrate', + 'reset-connections', + 'restore-ydoc', + 'reset-ydoc', + 'some-endpoint-added-later', + ])('refuses %s to an editor — there is no "*" fallback', (name) => { + expect(grants(editor, { endpoint: { [name]: '-r--' } })).toBe(false); + expect(grants(editor, { endpoint: { [name]: 'c---' } })).toBe(false); + }); +}); + +describe('the admin token', () => { + it('reaches every endpoint, named or not', () => { + expect( + grants(admin, { endpoint: { 'create-ydoc': 'crud', migrate: 'crud' } }), + ).toBe(true); + }); + + it('reads and writes the document with its full history', () => { + expect(grants(admin, { ydoc: '-ru-' })).toBe(true); + expect(grants(admin, { history: { from: 0 } })).toBe(true); + }); + + it('soft-deletes but never hard-deletes over REST, and is not granted prune', () => { + expect(grants(admin, { delete: ['soft'] })).toBe(true); + expect(grants(admin, { delete: ['hard'] })).toBe(false); + expect(grants(admin, { history: { from: 0, prune: true } })).toBe(false); + }); +}); + +describe('the public global routes', () => { + const globalPerms = normalizePermissions(publicGlobalPermissions); + const globalGrants = (required) => + hasPermissions(globalPerms, { type: 'permissions:global:v1', ...required }); + + it.each(['ping', 'ready', 'jwks'])('serves %s to anyone, read only', (name) => { + expect(globalGrants({ endpoint: { [name]: '-r--' } })).toBe(true); + expect(globalGrants({ endpoint: { [name]: '--u-' } })).toBe(false); + }); + + it('refuses a global endpoint it does not name', () => { + expect(globalGrants({ endpoint: { anything: '-r--' } })).toBe(false); + }); +}); + +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 fetch an access the caller has no history to bound', async () => { + expect(await resolveHistoryFrom({ versions_list: false }, never)).toBe(null); + expect(await resolveHistoryFrom({}, never)).toBe(null); + expect(await resolveHistoryFrom(undefined, never)).toBe(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', + })); + expect(from).toBe(Date.parse('2023-11-14T22:13:20Z')); + }); + + it('grants no history when the backend refuses the access', async () => { + for (const status of [401, 403, 404]) { + expect( + await resolveHistoryFrom({ versions_list: true }, async () => { + throw httpError(status); + }), + ).toBe(null); + } + }); + + it('rethrows when the backend does not answer at all', async () => { + for (const status of [500, 502, 503]) { + await expect( + resolveHistoryFrom({ versions_list: true }, async () => { + throw httpError(status); + }), + ).rejects.toMatchObject({ status }); + } + await expect( + resolveHistoryFrom({ versions_list: true }, async () => { + throw new TypeError('fetch failed'); + }), + ).rejects.toBeInstanceOf(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']) { + expect( + await resolveHistoryFrom({ versions_list: true }, async () => ({ + created_at, + })), + ).toBe(null); + } + expect( + await resolveHistoryFrom({ versions_list: true }, async () => undefined), + ).toBe(null); + }); + + it('refuses the epoch, which would unlock a gc=false socket', async () => { + expect( + await resolveHistoryFrom({ versions_list: true }, async () => ({ + created_at: '1970-01-01T00:00:00Z', + })), + ).toBe(null); + }); +}); diff --git a/src/yhub-server/permissions.test.js b/src/yhub-server/__tests__/permissions.test.js similarity index 96% rename from src/yhub-server/permissions.test.js rename to src/yhub-server/__tests__/permissions.test.js index 3d23ebe18..3cd16bdf5 100644 --- a/src/yhub-server/permissions.test.js +++ b/src/yhub-server/__tests__/permissions.test.js @@ -1,24 +1,22 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -// Reached by path rather than by name on purpose. `@y/hub`'s export map exposes -// only the package index, which pulls in uws, redis and postgres — and the two -// functions this test needs to run yhub's real pipeline (normalize the plugin's -// answer, then ask it the gate's question) are not on it. Importing the module -// directly keeps the test to plain objects, and if yhub ever moves the file the -// failure is loud rather than silently vacuous. +// `@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 './node_modules/@y/hub/src/permissions.js'; +} from '@y/hub/permissions'; import { adminDocumentPermissions, browserDocumentPermissions, publicGlobalPermissions, resolveHistoryFrom, -} from './permissions.js'; +} from '../permissions.js'; /** * These tables are Docs' entire access policy, and yhub reads them literally — diff --git a/src/yhub-server/__tests__/server.spec.mjs b/src/yhub-server/__tests__/server.spec.mjs new file mode 100644 index 000000000..3ca782ed0 --- /dev/null +++ b/src/yhub-server/__tests__/server.spec.mjs @@ -0,0 +1,242 @@ +// server.js — 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 +// args passed to `S3PersistenceV1` are captured. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { createYHub, s3PluginArgs } = vi.hoisted(() => ({ + createYHub: vi.fn(async (config) => { + createYHub.lastConfig = config; + return { stream: { taskDebounce: 0, minMessageLifetime: 0 } }; + }), + s3PluginArgs: [], +})); + +vi.mock('@y/hub', () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: () => logger, + }; + return { + createYHub, + logger, + apiError: (status, message) => Object.assign(new Error(message), { status }), + checkPermissions: vi.fn(), + createApiEndpoint: (name, opts) => ({ name, opts }), + createAuthPlugin: (plugin) => plugin, + createAuthorize: (handlers) => handlers, + createDocumentPermissions: (x) => x, + }; +}); + +vi.mock('@y/hub/plugins/s3', () => ({ + S3PersistenceV1: class { + constructor(args) { + this.args = args; + s3PluginArgs.push(args); + } + }, +})); + +vi.mock('../migration.js', () => ({ + SOFT_MIGRATION: false, + fullMigrate: vi.fn(), + isPermanentFailure: vi.fn(() => false), + maybeMigrate: vi.fn(), + migrationLog: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +// A clean, minimal environment for every test: only what a test names is +// stubbed on top of this. +const BASE_ENV = { + REDIS: 'redis://localhost:6379', + POSTGRES: 'postgres://localhost:5432/yhub', + COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000', + SOFT_MIGRATION: 'false', +}; + +const boot = () => import('../server.js'); + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + createYHub.mockClear(); + createYHub.lastConfig = undefined; + s3PluginArgs.length = 0; + for (const [key, value] of Object.entries(BASE_ENV)) vi.stubEnv(key, value); + // vars a previous test may have set that must not leak in as defaults + for (const key of [ + 'YHUB_ROLE', + 'YHUB_TASK_CONCURRENCY', + 'YHUB_TASK_DEBOUNCE_MS', + 'YHUB_MIN_MESSAGE_LIFETIME_MS', + 'YHUB_S3_PERSISTENCE', + 'YHUB_S3_ENDPOINT_URL', + 'YHUB_S3_ACCESS_KEY_ID', + 'YHUB_S3_SECRET_ACCESS_KEY', + 'YHUB_S3_BUCKET_NAME', + 'YHUB_S3_REGION_NAME', + 'PORT', + ]) { + vi.stubEnv(key, ''); + } +}); + +describe('a valid, minimal environment', () => { + it('starts one yhub with both halves and the collaboration prefix', async () => { + await boot(); + + expect(createYHub).toHaveBeenCalledTimes(1); + const config = createYHub.lastConfig; + expect(config.redis.prefix).toBe('yhub'); + expect(config.postgres).toBe('postgres://localhost:5432/yhub'); + expect(config.server.apiPrefix).toBe('collaboration'); + expect(config.server.port).toBe(3002); + expect(config.server.cors).toEqual({ + origin: ['http://localhost:3000'], + credentials: true, + }); + expect(config.worker).not.toBeNull(); + expect(config.persistence).toEqual([]); + }); + + it('passes the stream tuning through with Docs’ defaults', async () => { + await boot(); + const { redis } = createYHub.lastConfig; + expect(redis.taskDebounce).toBe(10000); + expect(redis.minMessageLifetime).toBe(60000); + }); + + it('splits the origin allowlist on commas', async () => { + vi.stubEnv( + 'COLLABORATION_SERVER_ORIGIN', + 'https://a.example,https://b.example', + ); + await boot(); + expect(createYHub.lastConfig.server.cors.origin).toEqual([ + 'https://a.example', + 'https://b.example', + ]); + }); + + it('honours PORT', async () => { + vi.stubEnv('PORT', '4000'); + await boot(); + expect(createYHub.lastConfig.server.port).toBe(4000); + }); +}); + +describe('YHUB_ROLE', () => { + it('refuses an unknown role at boot', async () => { + vi.stubEnv('YHUB_ROLE', 'bogus'); + await expect(boot()).rejects.toThrow(/YHUB_ROLE must be one of/); + }); + + it('a server role binds the port and claims no task', async () => { + vi.stubEnv('YHUB_ROLE', 'server'); + await boot(); + expect(createYHub.lastConfig.server).not.toBeNull(); + expect(createYHub.lastConfig.worker).toBeNull(); + }); + + it('a worker role claims tasks and binds no port', async () => { + vi.stubEnv('YHUB_ROLE', 'worker'); + await boot(); + expect(createYHub.lastConfig.server).toBeNull(); + expect(createYHub.lastConfig.worker).not.toBeNull(); + }); +}); + +describe('the numeric tuning knobs', () => { + it('refuses a non-integer concurrency', async () => { + vi.stubEnv('YHUB_TASK_CONCURRENCY', 'abc'); + await expect(boot()).rejects.toThrow( + /YHUB_TASK_CONCURRENCY must be an integer >= 1/, + ); + }); + + it('refuses a concurrency below one', async () => { + vi.stubEnv('YHUB_TASK_CONCURRENCY', '0'); + await expect(boot()).rejects.toThrow(/YHUB_TASK_CONCURRENCY/); + }); + + it('accepts a debounce of zero but not of minus one', async () => { + vi.stubEnv('YHUB_TASK_DEBOUNCE_MS', '0'); + await expect(boot()).resolves.toBeDefined(); + + vi.resetModules(); + vi.stubEnv('YHUB_TASK_DEBOUNCE_MS', '-1'); + await expect(boot()).rejects.toThrow(/YHUB_TASK_DEBOUNCE_MS/); + }); + + it('treats a blank variable as the default', async () => { + vi.stubEnv('YHUB_TASK_CONCURRENCY', ''); + await boot(); + expect(createYHub.lastConfig.worker.taskConcurrency).toBe(5); + }); +}); + +describe('the S3 persistence plugin', () => { + const S3_ENV = { + YHUB_S3_ENDPOINT_URL: 'https://s3.example.test', + YHUB_S3_ACCESS_KEY_ID: 'key', + YHUB_S3_SECRET_ACCESS_KEY: 'secret', + YHUB_S3_BUCKET_NAME: 'yhub-blobs', + }; + const stubS3Env = () => { + for (const [k, v] of Object.entries(S3_ENV)) vi.stubEnv(k, v); + }; + + it('loads no plugin when no bucket is configured', async () => { + await boot(); + expect(s3PluginArgs).toHaveLength(0); + }); + + it('refuses a half-configured bucket, naming what is missing', async () => { + vi.stubEnv('YHUB_S3_ENDPOINT_URL', 'https://s3.example.test'); + await expect(boot()).rejects.toThrow( + /partly configured, missing YHUB_S3_ACCESS_KEY_ID/, + ); + }); + + it('refuses YHUB_S3_PERSISTENCE=true without the bucket settings', async () => { + vi.stubEnv('YHUB_S3_PERSISTENCE', 'true'); + await expect(boot()).rejects.toThrow(/YHUB_S3_PERSISTENCE=true requires/); + }); + + it('attaches the plugin read-only when the toggle is off', async () => { + stubS3Env(); + await boot(); + expect(s3PluginArgs).toHaveLength(1); + expect(s3PluginArgs[0]).toMatchObject({ + bucket: 'yhub-blobs', + branches: [], + deleteVersions: true, + }); + }); + + it('offloads every branch when the toggle is on', async () => { + stubS3Env(); + vi.stubEnv('YHUB_S3_PERSISTENCE', 'true'); + await boot(); + expect(s3PluginArgs[0].branches).toBe(true); + }); + + it('refuses an endpoint URL that carries a path', async () => { + stubS3Env(); + vi.stubEnv('YHUB_S3_ENDPOINT_URL', 'https://s3.example.test/bucket'); + await expect(boot()).rejects.toThrow(/must not contain a path/); + }); + + it('refuses a non-http(s) endpoint scheme', async () => { + stubS3Env(); + vi.stubEnv('YHUB_S3_ENDPOINT_URL', 'ftp://s3.example.test'); + await expect(boot()).rejects.toThrow(/must be http:\/\/ or https:\/\//); + }); +}); diff --git a/src/yhub-server/package-lock.json b/src/yhub-server/package-lock.json index 805584e44..83c2d3680 100644 --- a/src/yhub-server/package-lock.json +++ b/src/yhub-server/package-lock.json @@ -12,7 +12,8 @@ "jose": "6.2.8" }, "devDependencies": { - "nodemon": "3.1.14" + "nodemon": "3.1.14", + "vitest": "3.2.7" }, "engines": { "node": ">=22" @@ -326,6 +327,444 @@ "node": ">=18.0.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@nodable/entities": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", @@ -416,6 +855,331 @@ "@redis/client": "^5.12.1" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@smithy/core": { "version": "3.33.0", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.0.tgz", @@ -497,6 +1261,136 @@ "node": ">=18.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@y-crdt/yn": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@y-crdt/yn/-/yn-0.1.4.tgz", @@ -594,6 +1488,15 @@ ], "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -688,6 +1591,40 @@ "node": ">=8.0.0" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -749,12 +1686,86 @@ "node": ">=0.10" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-xml-builder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", @@ -943,6 +1954,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + }, "node_modules/lib0": { "version": "1.0.0-rc.27", "resolved": "https://registry.npmjs.org/lib0/-/lib0-1.0.0-rc.27.tgz", @@ -967,6 +1984,21 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -1035,6 +2067,24 @@ "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -1098,6 +2148,27 @@ "node": ">=14.0.0" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", @@ -1148,6 +2219,34 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postgres": { "version": "3.4.9", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", @@ -1260,6 +2359,51 @@ "node": ">= 18.19.0" } }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1311,6 +2455,12 @@ "node": ">=10" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -1333,6 +2483,15 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split-on-first": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", @@ -1351,6 +2510,18 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, "node_modules/stream-chain": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", @@ -1384,6 +2555,18 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/strnum": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", @@ -1439,6 +2622,90 @@ "readable-stream": "3" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1487,6 +2754,231 @@ "resolved": "git+ssh://git@github.com/uNetworking/uWebSockets.js.git#fcfc622a4286909593b7f390056d89e0ca3b56b9", "license": "Apache-2.0" }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/xml-naming": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", diff --git a/src/yhub-server/package.json b/src/yhub-server/package.json index 2458ae50f..a9aa71b78 100644 --- a/src/yhub-server/package.json +++ b/src/yhub-server/package.json @@ -6,7 +6,8 @@ "start": "node server.js", "dev": "nodemon --delay 1 server.js", "init-db": "node node_modules/@y/hub/bin/init-db.js", - "test": "node --test" + "test": "node --test __tests__/permissions.test.js && vitest run", + "test:watch": "vitest" }, "dependencies": { "@aws-sdk/client-s3": "3.1110.0", @@ -15,7 +16,8 @@ "jose": "6.2.8" }, "devDependencies": { - "nodemon": "3.1.14" + "nodemon": "3.1.14", + "vitest": "3.2.7" }, "engines": { "node": ">=22" diff --git a/src/yhub-server/vitest.config.mts b/src/yhub-server/vitest.config.mts new file mode 100644 index 000000000..8d702623b --- /dev/null +++ b/src/yhub-server/vitest.config.mts @@ -0,0 +1,22 @@ +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. +export default defineConfig({ + test: { + include: ['__tests__/**/*.spec.mjs'], + // migration.js 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 + // module throw on load in the specs that mock it instead. + env: { + SOFT_MIGRATION: 'false', + }, + // each spec file resets modules and re-imports with its own environment + isolate: true, + clearMocks: true, + }, +});