diff --git a/.devcontainer-lock.json b/.devcontainer-lock.json new file mode 100644 index 0000000..219acb6 --- /dev/null +++ b/.devcontainer-lock.json @@ -0,0 +1,3 @@ +{ + "features": {} +} diff --git a/.devcontainer.json b/.devcontainer.json index af9fd3c..80561bc 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -1,34 +1,39 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: // https://github.com/microsoft/vscode-dev-containers/tree/v0.177.0/containers/typescript-node { - "name": "Open Home Foundation - API", - "image": "mcr.microsoft.com/devcontainers/typescript-node:24-bookworm", - "forwardPorts": [ - 443 - ], - "portsAttributes": { - "443": { - "label": "HTTPS server" - } - }, - "customizations": { - "vscode": { - "settings": { - "files.eol": "\n", - "editor.tabSize": 2, - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[javascript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "files.trimTrailingWhitespace": true - } - } - }, - "postCreateCommand": "pnpm install", - "remoteUser": "node" + "name": "Open Home Foundation - API", + "image": "mcr.microsoft.com/devcontainers/typescript-node:24-bookworm", + "forwardPorts": [3000], + "portsAttributes": { + "3000": { + "label": "web-api (HTTP)" + } + }, + "customizations": { + "vscode": { + "settings": { + "files.eol": "\n", + "editor.tabSize": 2, + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "files.trimTrailingWhitespace": true + }, + // The settings above name Prettier as the formatter and the project lints + // with ESLint, so both extensions have to actually be present. + "extensions": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] + } + }, + // mise runs this project's tasks, so the container needs it before setup. + "onCreateCommand": "./script/install-mise", + // Trust and provision the toolchain in .mise.toml, then run the project's own + // setup task rather than duplicating what it does here. + "postCreateCommand": "mise trust && mise install && mise run setup", + "remoteUser": "node" } diff --git a/.github/renovate.json5 b/.github/renovate.json5 index ae216c3..4e54ed1 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -1,9 +1,7 @@ { - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended" - ], + $schema: 'https://docs.renovatebot.com/renovate-schema.json', + extends: ['config:recommended'], // Wait at least 7 days after a release before opening an update PR, // giving the ecosystem time to surface regressions or supply-chain issues. - "minimumReleaseAge": "7 days" + minimumReleaseAge: '7 days', } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9638274..c228ee1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,47 @@ permissions: contents: read jobs: + test: + name: Quality + runs-on: ubuntu-latest + env: + # pnpm's minimumReleaseAge re-validates every lockfile entry's publish + # date even for frozen installs. That cooldown is a resolution-time guard + # for adopting new versions; CI merely reproduces an already-gated, + # PR-reviewed lockfile, so it should not be re-gated here. + PNPM_CONFIG_MINIMUM_RELEASE_AGE: 0 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + + # pnpm is pinned via the packageManager field, so corepack resolves the + # exact version the lockfile was written with. + - name: Enable corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Formatting + run: pnpm run format:check + + - name: Lint + run: pnpm run lint + + - name: Typecheck + run: pnpm run typecheck + + - name: Unit tests + run: pnpm test + + - name: End-to-end tests + run: pnpm test:e2e + docker_build: name: Docker Build runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc78ecd..46ae01c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,12 +68,12 @@ jobs: context: . push: true platforms: linux/amd64,linux/arm64 - tags: "ghcr.io/${{ steps.owner.outputs.owner }}/web-api:${{ steps.version.outputs.version }}" + tags: 'ghcr.io/${{ steps.owner.outputs.owner }}/web-api:${{ steps.version.outputs.version }}' - name: Attest build provenance uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: - subject-name: "ghcr.io/${{ steps.owner.outputs.owner }}/web-api" + subject-name: 'ghcr.io/${{ steps.owner.outputs.owner }}/web-api' subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: true diff --git a/.gitignore b/.gitignore index dd6e803..7f5495e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,10 @@ node_modules/ +# pnpm needs its store on the same filesystem as node_modules to hardlink, so +# inside the devcontainer (where the workspace is a bind mount and $HOME is +# not) it falls back to a project-local store. Machine-local package cache. +.pnpm-store/ dist/ *.log .DS_Store +.env +coverage/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..5ee7abd --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm exec lint-staged diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..e88ac84 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,69 @@ +# Task registry for this project. See: +# https://standards.openhomefoundation.org/standards/task-conventions/ +# +# Lifecycle tasks delegate to script/ so CI and local development run the exact +# same code paths. Run `mise tasks` to list everything. + +[tools] +# Matches the Dockerfile (node:24-alpine) and CI's setup-node. +node = "24" +# pnpm is deliberately not managed here: package.json's `packageManager` field +# pins it and corepack provisions that exact version, so a second pin in mise +# could disagree with the lockfile. + +[tasks.setup] +description = "First-time project setup: dependencies and local .env" +run = "./script/setup" + +[tasks.update] +description = "Sync the checkout after pulling changes" +run = "./script/update" + +[tasks.dev] +description = "Start the development server in watch mode" +run = "./script/server" + +[tasks.test] +description = "Run the test suite (unit + e2e)" +run = "./script/test" + +[tasks.ci] +description = "Run the full CI suite: format, lint, typecheck, test, build" +run = "./script/cibuild" + +# Convenience tasks. These call package scripts directly; the standard only +# requires the lifecycle tasks above to go through script/. + +[tasks.lint] +description = "Lint with ESLint" +run = "pnpm run lint" + +[tasks."lint:fix"] +description = "Lint and apply fixes" +run = "pnpm run lint:fix" + +[tasks.format] +description = "Format with Prettier" +run = "pnpm run format" + +[tasks."format:check"] +description = "Check formatting without writing" +run = "pnpm run format:check" + +[tasks.typecheck] +description = "Type-check with tsc" +run = "pnpm run typecheck" + +[tasks.build] +description = "Compile to dist/" +run = "pnpm run build" + +[tasks.check] +description = "Format check, lint, typecheck and tests" +run = "pnpm run check" + +[tasks.console] +description = "TypeScript REPL with the project's dependencies loaded" +# The app has no src/repl.ts, so this is a plain ts-node REPL rather than a +# NestJS application REPL. +run = "pnpm exec ts-node" diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..eb7abbe --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +dist/ +coverage/ +pnpm-lock.yaml +CHANGELOG.md + +# Developer-local tooling config, not project source. +.vscode/ +.claude/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..a20502b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} diff --git a/Dockerfile b/Dockerfile index 1e64fbb..3bb5f21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,9 +3,14 @@ FROM node:24-alpine AS builder WORKDIR /app RUN corepack enable +# HUSKY=0 disables the `prepare` hook: git hooks are a developer-machine +# concern, and .husky/ is not part of the build context. +ENV HUSKY=0 COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile -COPY tsconfig.json nest-cli.json ./ +# tsconfig.build.json is what keeps *.spec.ts out of dist; without it nest build +# falls back to tsconfig.json and compiles the test suite into the image. +COPY tsconfig.json tsconfig.build.json nest-cli.json ./ COPY src ./src RUN pnpm run build @@ -17,7 +22,10 @@ RUN corepack enable # version.json is produced by the release workflow; the trailing glob keeps a # plain local `docker build` working when the file is absent. COPY package.json pnpm-lock.yaml pnpm-workspace.yaml version.json* ./ -RUN pnpm install --prod --frozen-lockfile && \ +# --ignore-scripts: husky is a devDependency, so the `prepare` hook that installs +# git hooks for developers cannot run here — and a production image has no +# business running dependency install scripts anyway. +RUN pnpm install --prod --frozen-lockfile --ignore-scripts && \ chown -R node:node /app COPY --from=builder --chown=node:node /app/dist ./dist USER node diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b3135f --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +# web-api + +Public web API for [openhomefoundation.org](https://www.openhomefoundation.org). + +It currently serves the livestream status of the Open Home Foundation's YouTube +channels — Home Assistant, ESPHome, Open Home Foundation and Music Assistant — +so the project websites can show upcoming, live and recently-ended streams. + +Built to the [OHF engineering standards](https://standards.openhomefoundation.org): +NestJS on Node LTS, TypeScript in strict mode, pnpm, and Mise for task running. + +## Quick start + +```bash +mise run setup # install dependencies and create .env from example.env +mise run dev # start the server in watch mode on http://localhost:3000 +``` + +Without Mise, `./script/setup` and `./script/server` do the same thing. + +Then fill in `YOUTUBE_API_KEY` in `.env` — see [Configuration](#configuration). +Without it the endpoints stay reachable but every channel reports `none`. + +## API + +Interactive documentation is generated from the code and served at +[`/docs`](http://localhost:3000/docs), with the OpenAPI document itself at +`/docs-json` (and `/docs-yaml`). + +| Method | Path | Description | +| ------ | ------------------- | ------------------------------------------------- | +| `GET` | `/livestream` | Livestream status for every configured channel | +| `GET` | `/livestream/:slug` | Status for one channel; `404` for an unknown slug | +| `GET` | `/__heartbeat__` | Application health probe | +| `GET` | `/__lbheartbeat__` | Load-balancer probe | +| `GET` | `/__version__` | Running build's version and commit | + +A livestream entry looks like this: + +```json +{ + "channel": "home-assistant", + "channelName": "Home Assistant", + "status": "upcoming", + "title": "Home Assistant 2026.8 Release Party", + "url": "https://www.youtube.com/watch?v=Pu7JLCxZmaI", + "startTime": "2026-08-05T19:00:00Z", + "updatedAt": "2026-07-28T12:38:57.580Z" +} +``` + +`status` is one of `live`, `upcoming`, `past` (ended within the last 24 hours) or +`none`. `title`, `url` and `startTime` are present only when there is a stream to +describe. `updatedAt` changes only when the reported state changes, so it is safe +to use for caching and change detection. + +Every response carries the security headers [helmet](https://helmetjs.github.io) +applies by default, including a `Content-Security-Policy`, `nosniff`, and HSTS. +The defaults are used unchanged; `test/security.e2e-spec.ts` asserts them and +checks that the policy still fits what the Swagger UI at `/docs` needs. + +## Configuration + +Configuration is environment variables only. `example.env` documents every one; +copy it to `.env` for local development (`.env` is gitignored and must never be +committed). In production these are set on the container. + +| Variable | Required | Purpose | +| --------------------- | -------- | ------------------------------------------------ | +| `YOUTUBE_API_KEY` | yes | YouTube Data API v3 key, used to classify videos | +| `LIVESTREAM_CHANNELS` | yes | Channels to track, as `handle:slug` pairs | +| `CORS_ORIGINS` | no | Sites allowed to read the API from a browser | +| `PORT` | no | Listen port, defaults to `3000` | + +`LIVESTREAM_CHANNELS` is a comma-separated list of `handle:slug` pairs: + +``` +LIVESTREAM_CHANNELS=home_assistant:home-assistant,esphomeio:esphome +``` + +- `handle` is the YouTube handle used to find the channel, with or without `@`. +- `slug` is the path this API serves the channel under (`/livestream/`) and + the `channel` field in the response. It is pinned in configuration rather than + derived from the channel's YouTube name, so renaming a channel on YouTube + cannot silently change our public URLs. + +Display names are read from each channel's feed at runtime, so they are not +configured. Adding or removing a project is a configuration change, not a code +change. Malformed configuration fails startup rather than silently tracking +nothing. + +`CORS_ORIGINS` is a comma-separated list of the origins allowed to read the API +from a browser — the sites that consume it are deployed separately, so this is +configuration too: + +``` +CORS_ORIGINS=https://openhomefoundation.org,https://*.openhomefoundation.org +``` + +Each entry is a scheme and host (with a port if it is not the default) and +nothing else, since that is all a browser's `Origin` header carries. A leading +`*.` label covers every subdomain of a domain at any depth — `https://*.esphome.io` +allows `www.esphome.io` and `a.b.esphome.io`, but not the bare `esphome.io`, so +list the domain itself as well if it serves pages. It will not match a lookalike +such as `evil-esphome.io`. Use `*` on its own to allow any origin. + +Left unset, no cross-origin headers are sent: the +API still answers every request, but a browser will not hand the response to a +page on another site. As with the channel list, a malformed entry fails startup +rather than quietly dropping a site that was meant to be allowed. + +## How it works + +``` +RSS feed (free) ──▶ discovery sweep ──┐ + every 5 min ├──▶ tracked videos ──▶ channel status +videos.list (quota) ─▶ reconcile poll ──┘ + every 10 s +``` + +- **Discovery** reads each channel's RSS feed, which costs no API quota, and + classifies the videos it lists with a `videos.list` lookup. It fingerprints the + feed and skips that lookup entirely when nothing has changed, so steady-state + discovery is free. +- **Reconciliation** re-queries only the videos that are live or start within 15 + minutes, which is what catches a stream going live or ending. A tick with + nothing active issues no requests at all. +- YouTube's push notifications (WebSub) are deliberately **not** used: the hub + notifies on uploads and metadata edits, not on a broadcast going live, so it + cannot deliver the signal this service is about. + +Architecturally the feature is one Nest module (`src/livestream`) with a +controller over an in-memory state map; there is no database. State is rebuilt +from YouTube on every boot. + +## Development + +Tasks are defined in `.mise.toml` and delegate to `script/`, so local runs and CI +execute the same code paths. Run `mise tasks` to list them all. + +| Task | Does | +| ----------------- | --------------------------------------------------- | +| `mise run setup` | Install dependencies, create `.env` if missing | +| `mise run update` | Sync dependencies after pulling | +| `mise run dev` | Start the server in watch mode | +| `mise run test` | Unit and e2e tests | +| `mise run ci` | The full gate: format, lint, typecheck, test, build | +| `mise run check` | Same gate without the container build | + +Tests are Jest, with Supertest for the HTTP layer. Unit specs live beside the +code as `*.spec.ts`; end-to-end specs live in `test/` as `*.e2e-spec.ts` and boot +a real Nest application with YouTube stubbed at the `fetch` boundary. No test +touches the network. + +```bash +pnpm test # unit +pnpm test:e2e # end-to-end +pnpm run test:cov # coverage +``` + +## Contributing + +- **Commits** follow [Conventional Commits](https://www.conventionalcommits.org), + which drives releases through Release Please. Commits must be signed. +- **Before pushing**, run `mise run ci`. A Husky pre-commit hook runs ESLint and + Prettier over staged files, and CI re-checks formatting, lint, types and both + test suites. +- **Dependencies** are updated by Renovate. Nothing published within the last + seven days is adopted, which is enforced by both Renovate and pnpm as a + supply-chain measure — so a manual `pnpm add` of a brand-new release will be + refused. +- **Tests are expected** with behaviour changes. If a test fails after your + change, treat it as a finding about the change rather than something to edit + away. + +## Deployment + +The service ships as a container built by `Dockerfile` (multi-stage, running as a +non-root user, with a `HEALTHCHECK` against `/__heartbeat__`). Pushes to `main` +build and publish the image with a signed build-provenance attestation, and the +release workflow rolls the new version out through Terraform Cloud. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..ff366ad --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,58 @@ +// @ts-check +import eslint from '@eslint/js'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['dist/**', 'coverage/**', 'node_modules/**'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + eslintPluginPrettierRecommended, + { + languageOptions: { + globals: { ...globals.node, ...globals.jest }, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + // no-explicit-any is left at the recommended 'error': the shapes we read + // from YouTube and from Nest's framework seams are modelled instead. The + // unsafe-* rules stay off because the values arriving at those seams — + // res.json(), app.getHttpServer(), jest mock calls — are typed `any` by + // their own declarations, so reading them is unavoidably "unsafe". + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + // Nest lifecycle hooks and Express handlers are called by the framework. + '@typescript-eslint/no-misused-promises': [ + 'error', + { checksVoidReturn: false }, + ], + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, + }, + { + // This config file is not part of the TypeScript project, so type-aware + // rules have no program to consult. + files: ['**/*.mjs'], + extends: [tseslint.configs.disableTypeChecked], + }, + { + // Test doubles are untyped by nature: jest mock call arrays are any[][], and + // a fetch stub must be async to return a promise even when it only throws. + files: ['**/*.spec.ts', '**/*.e2e-spec.ts'], + rules: { + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/require-await': 'off', + }, + }, +); diff --git a/example.env b/example.env new file mode 100644 index 0000000..28f0bc4 --- /dev/null +++ b/example.env @@ -0,0 +1,25 @@ +# YouTube Data API v3 key (https://console.cloud.google.com/apis/credentials). +# Required to classify videos found in each channel's RSS feed; without it the +# livestream endpoints stay reachable but every channel reports "none". +YOUTUBE_API_KEY= + +# Channels to track: a comma-separated list of handle:slug pairs. +# - handle: the YouTube handle used to find the channel, with or without "@". +# - slug: the path this API serves the channel under (/livestream/) and +# the "channel" field in the response. Pinned here rather than derived +# from the channel's YouTube name so renaming the channel on YouTube +# cannot silently change our public URLs. +# Display names are read from each channel's feed at runtime, so they are not +# configured here. Adding or removing a project is a config change, not a code +# change; malformed or missing config fails startup rather than tracking nothing. +LIVESTREAM_CHANNELS=home_assistant:home-assistant,esphomeio:esphome,OpenHomeFndn:open-home-foundation,musicassistantio:music-assistant + +# Origins allowed to read this API from a browser: a comma-separated list of +# scheme-and-host entries, e.g. https://esphome.io,https://*.esphome.io. +# A port is part of an origin (http://localhost:8123); a path is not. A leading +# "*." label covers every subdomain of that domain but not the domain itself, so +# list both when the domain also serves pages. Use "*" alone to allow any origin. +# Left unset, no cross-origin headers are sent: the API still answers, but +# browsers will not let a page on another site read the response. A malformed +# entry fails startup rather than silently dropping a site. +CORS_ORIGINS= diff --git a/package.json b/package.json index b0493c8..dd32fa9 100644 --- a/package.json +++ b/package.json @@ -6,26 +6,79 @@ "build": "nest build", "start": "nest start", "start:dev": "nest start --watch", - "start:prod": "node dist/main" + "start:prod": "node dist/main", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:e2e": "jest --config ./test/jest-e2e.json", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "typecheck": "tsc --noEmit -p tsconfig.json", + "check": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm test && pnpm run test:e2e", + "prepare": "husky" + }, + "lint-staged": { + "*.{ts,mjs,js}": [ + "eslint --fix", + "prettier --write" + ], + "*.{json,json5,md,yaml,yml}": "prettier --write" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": ".", + "testRegex": "src/.*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "src/**/*.(t|j)s" + ], + "coverageDirectory": "coverage", + "testEnvironment": "node" }, "dependencies": { "@nestjs/common": "^11.1.19", + "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.1.19", "@nestjs/platform-express": "^11.1.19", "@nestjs/platform-socket.io": "^11.1.19", + "@nestjs/swagger": "^11.4.6", "@nestjs/websockets": "^11.1.19", + "helmet": "^8.3.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "socket.io": "^4.7.5" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@nestjs/cli": "^11.0.21", "@nestjs/schematics": "^11.1.0", + "@nestjs/testing": "^11.1.28", + "@types/jest": "^30.0.0", "@types/node": "^24.12.2", + "@types/supertest": "^7.2.1", + "eslint": "^10.7.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "globals": "^17.7.0", + "husky": "^9.1.7", + "jest": "^30.4.2", + "lint-staged": "^17.1.0", + "prettier": "^3.9.6", "socket.io-client": "^4.8.3", + "supertest": "^7.2.2", + "ts-jest": "^29.4.11", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "typescript-eslint": "^8.65.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3353afd..0fa6344 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@nestjs/common': specifier: ^11.1.19 version: 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/config': + specifier: ^4.0.4 + version: 4.0.4(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.1.19 version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -20,9 +23,15 @@ importers: '@nestjs/platform-socket.io': specifier: ^11.1.19 version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/websockets@11.1.28)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/swagger': + specifier: ^11.4.6 + version: 11.4.6(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2) '@nestjs/websockets': specifier: ^11.1.19 version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@nestjs/platform-socket.io@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + helmet: + specifier: ^8.3.0 + version: 8.3.0 reflect-metadata: specifier: ^0.2.0 version: 0.2.2 @@ -33,21 +42,63 @@ importers: specifier: ^4.7.5 version: 4.8.3(supports-color@8.1.1) devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.7.0(supports-color@8.1.1)) '@nestjs/cli': specifier: ^11.0.21 - version: 11.0.24(@types/node@24.13.3) + version: 11.0.24(@types/node@24.13.3)(prettier@3.9.6)(uglify-js@3.19.3) '@nestjs/schematics': specifier: ^11.1.0 - version: 11.1.0(chokidar@4.0.3)(typescript@5.9.3) + version: 11.1.0(chokidar@4.0.3)(prettier@3.9.6)(typescript@5.9.3) + '@nestjs/testing': + specifier: ^11.1.28 + version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28) + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 '@types/node': specifier: ^24.12.2 version: 24.13.3 + '@types/supertest': + specifier: ^7.2.1 + version: 7.2.1 + eslint: + specifier: ^10.7.0 + version: 10.7.0(supports-color@8.1.1) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.7.0(supports-color@8.1.1)) + eslint-plugin-prettier: + specifier: ^5.5.6 + version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.7.0(supports-color@8.1.1)))(eslint@10.7.0(supports-color@8.1.1))(prettier@3.9.6) + globals: + specifier: ^17.7.0 + version: 17.7.0 + husky: + specifier: ^9.1.7 + version: 9.1.7 + jest: + specifier: ^30.4.2 + version: 30.4.2(@types/node@24.13.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)) + lint-staged: + specifier: ^17.1.0 + version: 17.1.0 + prettier: + specifier: ^3.9.6 + version: 3.9.6 socket.io-client: specifier: ^4.8.3 version: 4.8.3(supports-color@8.1.1) + supertest: + specifier: ^7.2.2 + version: 7.2.2(supports-color@8.1.1) + ts-jest: + specifier: ^29.4.11 + version: 29.4.11(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@30.4.1(supports-color@8.1.1))(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(jest-util@30.4.1)(jest@30.4.2(@types/node@24.13.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 - version: 9.6.2(typescript@5.9.3)(webpack@5.106.2) + version: 9.6.2(typescript@5.9.3)(webpack@5.106.2(uglify-js@3.19.3)) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@24.13.3)(typescript@5.9.3) @@ -57,6 +108,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + typescript-eslint: + specifier: ^8.65.0 + version: 8.65.0(eslint@10.7.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) packages: @@ -95,10 +149,167 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} @@ -110,6 +321,74 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -253,9 +532,106 @@ packages: '@types/node': optional: true + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/console@30.4.1': + resolution: {integrity: sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/core@30.4.2': + resolution: {integrity: sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/diff-sequences@30.4.0': + resolution: {integrity: sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@30.4.1': + resolution: {integrity: sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.4.1': + resolution: {integrity: sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect@30.4.1': + resolution: {integrity: sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/fake-timers@30.4.1': + resolution: {integrity: sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@30.4.1': + resolution: {integrity: sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.4.0': + resolution: {integrity: sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/reporters@30.4.1': + resolution: {integrity: sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@30.4.1': + resolution: {integrity: sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/snapshot-utils@30.4.1': + resolution: {integrity: sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@30.0.1': + resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-result@30.4.1': + resolution: {integrity: sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-sequencer@30.4.1': + resolution: {integrity: sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/transform@30.4.1': + resolution: {integrity: sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/types@30.4.1': + resolution: {integrity: sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -276,6 +652,15 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@nestjs/cli@11.0.24': resolution: {integrity: sha512-aIHxQLSYtXShifA3zwWIeznEsZnNa3Iz2QRykFj+sl9IcbERBHr5nH87FRgywM+He3NxoF5WazHfR8FsmVeWxw==} engines: {node: '>= 20.11'} @@ -302,6 +687,12 @@ packages: class-validator: optional: true + '@nestjs/config@4.0.4': + resolution: {integrity: sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + '@nestjs/core@11.1.28': resolution: {integrity: sha512-06m63xIRj8+l8uOeh/8LnYupGubkyu4f+bPKIadaSui6vK9KpXgoz7HveT1yOVLcEt0M0oCOEW5EuEXZkEmBBQ==} engines: {node: '>= 20'} @@ -320,6 +711,19 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/platform-express@11.1.28': resolution: {integrity: sha512-hU+9Sz4m+onHrR5AmelI59QKmY/Re546bPnygnpqqeQdHDiJpBgjWbL4t6Jr73CBpS60cpyng7WzjgphNB9iwA==} peerDependencies: @@ -342,6 +746,36 @@ packages: prettier: optional: true + '@nestjs/swagger@11.4.6': + resolution: {integrity: sha512-Le136h2WC7HGsd70+WyK1qrm+Zq7kFxBLkYC1JgAVqNRCt8kNh7bMF7Qkn65D5j2t/aks0+VbWmUVlYIwPrs3A==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/testing@11.1.28': + resolution: {integrity: sha512-B+VgRxeLaH7jkOMgAyUP3N3rpFlisQ7JRxixRbgHvG6a0VgKbbkNSofKExexCgKmQQak80undb3+2kE1lUBmRQ==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets@11.1.28': resolution: {integrity: sha512-jeyclAURCJTN8S8lctDhfLdiJeDKjZmYWWLav653Fb9hl9c+zx5jPhavI8Xk5++R8u+lX9qzaRxtsjEoxTtjyw==} peerDependencies: @@ -354,6 +788,33 @@ packages: '@nestjs/platform-socket.io': optional: true + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.3.6': + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@sinclair/typebox@0.34.52': + resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@15.4.0': + resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==} + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -376,6 +837,24 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} @@ -385,18 +864,233 @@ packages: '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@30.0.0': + resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@7.2.1': + resolution: {integrity: sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@typescript-eslint/eslint-plugin@8.65.0': + resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.65.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.65.0': + resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.65.0': + resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.65.0': + resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.65.0': + resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.65.0': + resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.65.0': + resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.65.0': + resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.65.0': + resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.65.0': + resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} + cpu: [x64] + os: [win32] + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -462,6 +1156,11 @@ packages: peerDependencies: acorn: ^8.14.0 + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.5: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} @@ -510,30 +1209,84 @@ packages: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} array-timsort@1.0.3: resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + babel-jest@30.4.1: + resolution: {integrity: sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-0 + + babel-plugin-istanbul@7.0.1: + resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} + engines: {node: '>=12'} + + babel-plugin-jest-hoist@30.4.0: + resolution: {integrity: sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@30.4.0: + resolution: {integrity: sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -563,6 +1316,9 @@ packages: brace-expansion@1.1.16: resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + brace-expansion@5.0.7: resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} @@ -572,6 +1328,13 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -598,6 +1361,14 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + caniuse-lite@1.0.30001800: resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==} @@ -605,6 +1376,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} @@ -616,6 +1391,13 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -632,10 +1414,21 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -643,6 +1436,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -654,6 +1451,9 @@ packages: resolution: {integrity: sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==} engines: {node: '>= 6'} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -673,6 +1473,9 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -681,6 +1484,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -697,6 +1503,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -706,6 +1516,17 @@ packages: supports-color: optional: true + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -713,27 +1534,60 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + diff@4.0.4: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.4.1: + resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} electron-to-chromium@1.5.384: resolution: {integrity: sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==} + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -749,10 +1603,6 @@ packages: resolution: {integrity: sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==} engines: {node: '>=10.2.0'} - enhanced-resolve@5.24.1: - resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==} - engines: {node: '>=10.13.0'} - enhanced-resolve@5.24.3: resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} engines: {node: '>=10.13.0'} @@ -775,6 +1625,10 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -782,15 +1636,73 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-prettier@5.5.6: + resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.7.0: + resolution: {integrity: sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -803,6 +1715,10 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -811,6 +1727,18 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit-x@0.2.2: + resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} + engines: {node: '>= 0.8.0'} + + expect@30.4.1: + resolution: {integrity: sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -818,18 +1746,37 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.3: - resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} - fast-uri@3.1.4: resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + file-type@21.3.4: resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} engines: {node: '>=20'} @@ -838,6 +1785,25 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fork-ts-checker-webpack-plugin@9.1.0: resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} engines: {node: '>=14.21.3'} @@ -845,6 +1811,14 @@ packages: typescript: '>3.6.0' webpack: ^5.11.0 + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -860,24 +1834,65 @@ packages: fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@17.7.0: + resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -885,6 +1900,11 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -893,14 +1913,34 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + helmet@8.3.0: + resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} + engines: {node: '>=18.0.0'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -908,10 +1948,31 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -922,10 +1983,22 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -933,25 +2006,199 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterare@1.2.1: resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} engines: {node: '>=6'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-changed-files@30.4.1: + resolution: {integrity: sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-circus@30.4.2: + resolution: {integrity: sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-cli@30.4.2: + resolution: {integrity: sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@30.4.2: + resolution: {integrity: sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@types/node': '*' + esbuild-register: '>=3.4.0' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + + jest-diff@30.4.1: + resolution: {integrity: sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@30.4.0: + resolution: {integrity: sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-each@30.4.1: + resolution: {integrity: sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-environment-node@30.4.1: + resolution: {integrity: sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-haste-map@30.4.1: + resolution: {integrity: sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-leak-detector@30.4.1: + resolution: {integrity: sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-matcher-utils@30.4.1: + resolution: {integrity: sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@30.4.1: + resolution: {integrity: sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-mock@30.4.1: + resolution: {integrity: sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@30.4.0: + resolution: {integrity: sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve-dependencies@30.4.2: + resolution: {integrity: sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve@30.4.1: + resolution: {integrity: sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runner@30.4.2: + resolution: {integrity: sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runtime@30.4.2: + resolution: {integrity: sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-snapshot@30.4.1: + resolution: {integrity: sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-util@30.4.1: + resolution: {integrity: sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-validate@30.4.1: + resolution: {integrity: sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-watcher@30.4.1: + resolution: {integrity: sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + jest-worker@30.4.1: + resolution: {integrity: sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest@30.4.2: + resolution: {integrity: sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + hasBin: true + js-yaml@4.3.0: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true + js-yaml@5.2.1: + resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -961,6 +2208,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -972,9 +2222,25 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lint-staged@17.1.0: + resolution: {integrity: sha512-d7UQRu/9ZPgfu4+hu/k0wny5GEaIxo+2jb2LJqQDkE7cHRTm1HGqNUDq5UOwsGPpjpaNAFmgAsYo3TR+i9cSJw==} + engines: {node: '>=22.22.1'} + hasBin: true + load-esm@1.0.3: resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} engines: {node: '>=13.2.0'} @@ -983,6 +2249,17 @@ packages: resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} engines: {node: '>=6.11.5'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -990,16 +2267,29 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1023,6 +2313,10 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -1039,6 +2333,11 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -1050,6 +2349,10 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -1068,6 +2371,14 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -1085,10 +2396,21 @@ packages: node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-releases@2.0.50: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1112,10 +2434,37 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + ora@5.4.1: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1128,6 +2477,22 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -1142,14 +2507,47 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + engines: {node: '>=6.0.0'} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@30.4.1: + resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -1158,6 +2556,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -1170,6 +2571,12 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.7: + resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -1181,14 +2588,26 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + restore-cursor@3.1.0: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} @@ -1217,6 +2636,10 @@ packages: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -1233,6 +2656,14 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -1256,6 +2687,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + socket.io-adapter@2.5.8: resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} @@ -1271,6 +2706,9 @@ packages: resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} engines: {node: '>=10.2.0'} + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -1286,7 +2724,14 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} - statuses@2.0.2: + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -1294,10 +2739,22 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -1305,14 +2762,38 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + strtok3@10.3.5: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1321,10 +2802,17 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + swagger-ui-dist@5.32.8: + resolution: {integrity: sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==} + symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} + synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} + engines: {node: ^14.18.0 || >=16.0.0} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -1377,6 +2865,21 @@ packages: engines: {node: '>=10'} hasBin: true + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -1385,6 +2888,39 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-jest@29.4.11: + resolution: {integrity: sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <7' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + ts-loader@9.6.2: resolution: {integrity: sha512-R4iuczmtgxvtuI556s+hTZ6/7Ee03VCAk/l/M8LY1OAsUgB7YydsCxkgq9D9pKRaD7GJqUi2u8fp9zZP/ufjKA==} engines: {node: '>=12.0.0'} @@ -1421,6 +2957,22 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -1432,11 +2984,23 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript-eslint@8.65.0: + resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -1456,6 +3020,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -1471,10 +3038,17 @@ packages: v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + watchpack@2.5.2: resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} engines: {node: '>=10.13.0'} @@ -1500,13 +3074,37 @@ packages: webpack-cli: optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -1523,14 +3121,34 @@ packages: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + yoctocolors-cjs@2.1.3: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} @@ -1597,8 +3215,189 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7(supports-color@8.1.1)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@8.1.1) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7(supports-color@8.1.1)': + dependencies: + '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7(supports-color@8.1.1)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@0.2.3': {} + '@borewit/text-codec@0.2.2': {} '@colors/colors@1.5.0': @@ -1608,6 +3407,72 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.7.0(supports-color@8.1.1))': + dependencies: + eslint: 10.7.0(supports-color@8.1.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5(supports-color@8.1.1)': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3(supports-color@8.1.1) + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.7.0(supports-color@8.1.1))': + optionalDependencies: + eslint: 10.7.0(supports-color@8.1.1) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@inquirer/ansi@1.0.2': {} '@inquirer/checkbox@4.3.2(@types/node@24.13.3)': @@ -1748,69 +3613,280 @@ snapshots: optionalDependencies: '@types/node': 24.13.3 - '@jridgewell/gen-mapping@0.3.13': + '@isaacs/cliui@8.0.2': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 - '@jridgewell/resolve-uri@3.1.2': {} + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.15.0 + resolve-from: 5.0.0 - '@jridgewell/source-map@0.3.11': + '@istanbuljs/schema@0.1.6': {} + + '@jest/console@30.4.1': dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 + '@jest/types': 30.4.1 + '@types/node': 24.13.3 + chalk: 4.1.2 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + slash: 3.0.0 + + '@jest/core@30.4.2(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3))': + dependencies: + '@jest/console': 30.4.1 + '@jest/pattern': 30.4.0 + '@jest/reporters': 30.4.1(supports-color@8.1.1) + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1(supports-color@8.1.1) + '@jest/types': 30.4.1 + '@types/node': 24.13.3 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.4.0 + exit-x: 0.2.2 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-changed-files: 30.4.1 + jest-config: 30.4.2(@types/node@24.13.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)) + jest-haste-map: 30.4.1 + jest-message-util: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-resolve-dependencies: 30.4.2(supports-color@8.1.1) + jest-runner: 30.4.2(supports-color@8.1.1) + jest-runtime: 30.4.2(supports-color@8.1.1) + jest-snapshot: 30.4.1(supports-color@8.1.1) + jest-util: 30.4.1 + jest-validate: 30.4.1 + jest-watcher: 30.4.1 + pretty-format: 30.4.1 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jest/diff-sequences@30.4.0': {} - '@jridgewell/trace-mapping@0.3.31': + '@jest/environment@30.4.1': dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jest/fake-timers': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 24.13.3 + jest-mock: 30.4.1 - '@jridgewell/trace-mapping@0.3.9': + '@jest/expect-utils@30.4.1': dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jest/get-type': 30.1.0 - '@lukeed/csprng@1.1.0': {} + '@jest/expect@30.4.1(supports-color@8.1.1)': + dependencies: + expect: 30.4.1 + jest-snapshot: 30.4.1(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color - '@nestjs/cli@11.0.24(@types/node@24.13.3)': + '@jest/fake-timers@30.4.1': dependencies: - '@angular-devkit/core': 19.2.27(chokidar@4.0.3) - '@angular-devkit/schematics': 19.2.27(chokidar@4.0.3) - '@angular-devkit/schematics-cli': 19.2.27(@types/node@24.13.3)(chokidar@4.0.3) - '@inquirer/prompts': 7.10.1(@types/node@24.13.3) - '@nestjs/schematics': 11.1.0(chokidar@4.0.3)(typescript@5.9.3) - ansis: 4.2.0 - chokidar: 4.0.3 - cli-table3: 0.6.5 - commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.2) - glob: 13.0.6 - node-emoji: 1.11.0 - ora: 5.4.1 - tsconfig-paths: 4.2.0 - tsconfig-paths-webpack-plugin: 4.2.0 - typescript: 5.9.3 - webpack: 5.106.2 - webpack-node-externals: 3.0.0 + '@jest/types': 30.4.1 + '@sinonjs/fake-timers': 15.4.0 + '@types/node': 24.13.3 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-util: 30.4.1 + + '@jest/get-type@30.1.0': {} + + '@jest/globals@30.4.1(supports-color@8.1.1)': + dependencies: + '@jest/environment': 30.4.1 + '@jest/expect': 30.4.1(supports-color@8.1.1) + '@jest/types': 30.4.1 + jest-mock: 30.4.1 transitivePeerDependencies: - - '@minify-html/node' - - '@swc/css' - - '@swc/html' - - '@types/node' - - clean-css - - cssnano - - csso - - esbuild - - html-minifier-terser - - lightningcss - - postcss - - prettier - - uglify-js - - webpack-cli + - supports-color - '@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)': + '@jest/pattern@30.4.0': + dependencies: + '@types/node': 24.13.3 + jest-regex-util: 30.4.0 + + '@jest/reporters@30.4.1(supports-color@8.1.1)': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1(supports-color@8.1.1) + '@jest/types': 30.4.1 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 24.13.3 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit-x: 0.2.2 + glob: 10.5.0 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3(supports-color@8.1.1) + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6(supports-color@8.1.1) + istanbul-reports: 3.2.0 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + jest-worker: 30.4.1 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@30.4.1': + dependencies: + '@sinclair/typebox': 0.34.52 + + '@jest/snapshot-utils@30.4.1': + dependencies: + '@jest/types': 30.4.1 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 + + '@jest/source-map@30.0.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@30.4.1': + dependencies: + '@jest/console': 30.4.1 + '@jest/types': 30.4.1 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@30.4.1': + dependencies: + '@jest/test-result': 30.4.1 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + slash: 3.0.0 + + '@jest/transform@30.4.1(supports-color@8.1.1)': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@jest/types': 30.4.1 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 7.0.1(supports-color@8.1.1) + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-regex-util: 30.4.0 + jest-util: 30.4.1 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + + '@jest/types@30.4.1': + dependencies: + '@jest/pattern': 30.4.0 + '@jest/schemas': 30.4.1 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 24.13.3 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lukeed/csprng@1.1.0': {} + + '@microsoft/tsdoc@0.16.0': {} + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@nestjs/cli@11.0.24(@types/node@24.13.3)(prettier@3.9.6)(uglify-js@3.19.3)': + dependencies: + '@angular-devkit/core': 19.2.27(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.27(chokidar@4.0.3) + '@angular-devkit/schematics-cli': 19.2.27(@types/node@24.13.3)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@24.13.3) + '@nestjs/schematics': 11.1.0(chokidar@4.0.3)(prettier@3.9.6)(typescript@5.9.3) + ansis: 4.2.0 + chokidar: 4.0.3 + cli-table3: 0.6.5 + commander: 4.1.1 + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.2(uglify-js@3.19.3)) + glob: 13.0.6 + node-emoji: 1.11.0 + ora: 5.4.1 + tsconfig-paths: 4.2.0 + tsconfig-paths-webpack-plugin: 4.2.0 + typescript: 5.9.3 + webpack: 5.106.2(uglify-js@3.19.3) + webpack-node-externals: 3.0.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/html' + - '@types/node' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - prettier + - uglify-js + - webpack-cli + + '@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)': dependencies: file-type: 21.3.4(supports-color@8.1.1) iterare: 1.2.1 @@ -1822,6 +3898,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/config@4.0.4(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + dotenv: 17.4.1 + dotenv-expand: 12.0.3 + lodash: 4.18.1 + rxjs: 7.8.2 + '@nestjs/core@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) @@ -1836,6 +3920,11 @@ snapshots: '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(supports-color@8.1.1) '@nestjs/websockets': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@nestjs/platform-socket.io@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + reflect-metadata: 0.2.2 + '@nestjs/platform-express@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(supports-color@8.1.1)': dependencies: '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) @@ -1860,7 +3949,7 @@ snapshots: - supports-color - utf-8-validate - '@nestjs/schematics@11.1.0(chokidar@4.0.3)(typescript@5.9.3)': + '@nestjs/schematics@11.1.0(chokidar@4.0.3)(prettier@3.9.6)(typescript@5.9.3)': dependencies: '@angular-devkit/core': 19.2.24(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) @@ -1868,9 +3957,31 @@ snapshots: jsonc-parser: 3.3.1 pluralize: 8.0.0 typescript: 5.9.3 + optionalDependencies: + prettier: 3.9.6 transitivePeerDependencies: - chokidar + '@nestjs/swagger@11.4.6(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2) + js-yaml: 5.2.1 + lodash: 4.18.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.8 + + '@nestjs/testing@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28)': + dependencies: + '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(supports-color@8.1.1) + '@nestjs/websockets@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@nestjs/platform-socket.io@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) @@ -1883,6 +3994,29 @@ snapshots: optionalDependencies: '@nestjs/platform-socket.io': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/websockets@11.1.28)(rxjs@7.8.2)(supports-color@8.1.1) + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.3.6': {} + + '@scarf/scarf@1.4.0': {} + + '@sinclair/typebox@0.34.52': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@15.4.0': + dependencies: + '@sinonjs/commons': 3.0.1 + '@socket.io/component-emitter@3.1.2': {} '@tokenizer/inflate@0.4.1(supports-color@8.1.1)': @@ -1902,6 +4036,34 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/cookiejar@2.1.5': {} + '@types/cors@2.8.19': dependencies: '@types/node': 24.13.3 @@ -1916,18 +4078,220 @@ snapshots: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.9': {} + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@30.0.0': + dependencies: + expect: 30.4.1 + pretty-format: 30.4.1 + '@types/json-schema@7.0.15': {} + '@types/methods@1.1.4': {} + '@types/node@24.13.3': dependencies: undici-types: 7.18.2 + '@types/stack-utils@2.0.3': {} + + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 24.13.3 + form-data: 4.0.6 + + '@types/supertest@7.2.1': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + '@types/ws@8.18.1': dependencies: '@types/node': 24.13.3 + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@10.7.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.65.0(eslint@10.7.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/type-utils': 8.65.0(eslint@10.7.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.7.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 + eslint: 10.7.0(supports-color@8.1.1) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.65.0(eslint@10.7.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3(supports-color@8.1.1) + eslint: 10.7.0(supports-color@8.1.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.65.0(supports-color@8.1.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + debug: 4.4.3(supports-color@8.1.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.65.0(eslint@10.7.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.7.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + debug: 4.4.3(supports-color@8.1.1) + eslint: 10.7.0(supports-color@8.1.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.65.0': {} + + '@typescript-eslint/typescript-estree@8.65.0(supports-color@8.1.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.65.0(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3(supports-color@8.1.1) + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.65.0(eslint@10.7.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(supports-color@8.1.1)) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@8.1.1)(typescript@5.9.3) + eslint: 10.7.0(supports-color@8.1.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + eslint-visitor-keys: 5.0.1 + + '@ungap/structured-clone@1.3.3': {} + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + optional: true + + '@unrs/resolver-binding-android-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + optional: true + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -2022,6 +4386,10 @@ snapshots: dependencies: acorn: 8.17.0 + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + acorn-walk@8.3.5: dependencies: acorn: 8.17.0 @@ -2062,27 +4430,102 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.3 + fast-uri: 3.1.4 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 ansi-colors@4.1.3: {} + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + ansis@4.2.0: {} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + append-field@1.0.0: {} arg@4.1.3: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} - array-timsort@1.0.3: {} + array-timsort@1.0.3: {} + + asap@2.0.6: {} + + asynckit@0.4.0: {} + + babel-jest@30.4.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1): + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@jest/transform': 30.4.1(supports-color@8.1.1) + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 7.0.1(supports-color@8.1.1) + babel-preset-jest: 30.4.0(@babel/core@7.29.7(supports-color@8.1.1)) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@7.0.1(supports-color@8.1.1): + dependencies: + '@babel/helper-plugin-utils': 7.29.7 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 6.0.3(supports-color@8.1.1) + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@30.4.0: + dependencies: + '@types/babel__core': 7.20.5 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7(supports-color@8.1.1)): + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7(supports-color@8.1.1)) + + babel-preset-jest@30.4.0(@babel/core@7.29.7(supports-color@8.1.1)): + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + babel-plugin-jest-hoist: 30.4.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7(supports-color@8.1.1)) balanced-match@1.0.2: {} @@ -2119,6 +4562,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -2131,6 +4578,14 @@ snapshots: node-releases: 2.0.50 update-browserslist-db: 1.2.3(browserslist@4.28.4) + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + buffer-from@1.1.2: {} buffer@5.7.1: @@ -2156,6 +4611,10 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + caniuse-lite@1.0.30001800: {} chalk@4.1.2: @@ -2163,6 +4622,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + char-regex@1.0.2: {} + chardet@2.2.0: {} chokidar@4.0.3: @@ -2171,6 +4632,10 @@ snapshots: chrome-trace-event@1.0.4: {} + ci-info@4.4.0: {} + + cjs-module-lexer@2.2.0: {} + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -2185,14 +4650,28 @@ snapshots: cli-width@4.1.0: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clone@1.0.4: {} + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@2.20.3: {} commander@4.1.1: {} @@ -2202,6 +4681,8 @@ snapshots: array-timsort: 1.0.3 esprima: 4.0.1 + component-emitter@1.3.1: {} + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -2217,10 +4698,14 @@ snapshots: content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} + cookiejar@2.1.4: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -2237,34 +4722,67 @@ snapshots: create-require@1.1.1: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 optionalDependencies: supports-color: 8.1.1 + dedent@1.7.2: {} + + deep-is@0.1.4: {} + deepmerge@4.3.1: {} defaults@1.0.4: dependencies: clone: 1.0.4 + delayed-stream@1.0.0: {} + depd@2.0.0: {} + detect-newline@3.1.0: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + diff@4.0.4: {} + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dotenv@17.4.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} electron-to-chromium@1.5.384: {} + emittery@0.13.1: {} + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} engine.io-client@6.6.6(supports-color@8.1.1): @@ -2298,11 +4816,6 @@ snapshots: - supports-color - utf-8-validate - enhanced-resolve@5.24.1: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.3 - enhanced-resolve@5.24.3: dependencies: graceful-fs: 4.2.11 @@ -2322,17 +4835,98 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + escalade@3.2.0: {} escape-html@1.0.3: {} + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@10.7.0(supports-color@8.1.1)): + dependencies: + eslint: 10.7.0(supports-color@8.1.1) + + eslint-plugin-prettier@5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.7.0(supports-color@8.1.1)))(eslint@10.7.0(supports-color@8.1.1))(prettier@3.9.6): + dependencies: + eslint: 10.7.0(supports-color@8.1.1) + prettier: 3.9.6 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.13 + optionalDependencies: + '@types/eslint': 9.6.1 + eslint-config-prettier: 10.1.8(eslint@10.7.0(supports-color@8.1.1)) + eslint-scope@5.1.1: dependencies: esrecurse: 4.3.0 estraverse: 4.3.0 + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.7.0(supports-color@8.1.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(supports-color@8.1.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5(supports-color@8.1.1) + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@8.1.1) + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + esprima@4.0.1: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 @@ -2341,10 +4935,35 @@ snapshots: estraverse@5.3.0: {} + esutils@2.0.3: {} + etag@1.8.1: {} events@3.3.0: {} + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit-x@0.2.2: {} + + expect@30.4.1: + dependencies: + '@jest/expect-utils': 30.4.1 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-util: 30.4.1 + express@5.2.1(supports-color@8.1.1): dependencies: accepts: 2.0.0 @@ -2380,14 +4999,28 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-diff@1.3.0: {} + fast-json-stable-stringify@2.1.0: {} - fast-safe-stringify@2.1.1: {} + fast-levenshtein@2.0.6: {} - fast-uri@3.1.3: {} + fast-safe-stringify@2.1.1: {} fast-uri@3.1.4: {} + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + file-type@21.3.4(supports-color@8.1.1): dependencies: '@tokenizer/inflate': 0.4.1(supports-color@8.1.1) @@ -2408,7 +5041,29 @@ snapshots: transitivePeerDependencies: - supports-color - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.2): + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.2(uglify-js@3.19.3)): dependencies: '@babel/code-frame': 7.29.7 chalk: 4.1.2 @@ -2423,7 +5078,21 @@ snapshots: semver: 7.8.5 tapable: 2.3.3 typescript: 5.9.3 - webpack: 5.106.2 + webpack: 5.106.2(uglify-js@3.19.3) + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 forwarded@0.2.0: {} @@ -2437,8 +5106,17 @@ snapshots: fs-monkey@1.1.0: {} + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2452,65 +5130,470 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 + get-package-type@0.1.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-stream@6.0.1: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + glob-to-regexp@0.4.1: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@13.0.6: dependencies: minimatch: 10.2.5 minipass: 7.1.3 path-scurry: 2.0.2 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@17.7.0: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-flag@4.0.0: {} has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.4: dependencies: - function-bind: 1.1.2 + function-bind: 1.1.2 + + helmet@8.3.0: {} + + html-escaper@2.0.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + human-signals@2.1.0: {} + + husky@9.1.7: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-promise@4.0.0: {} + + is-stream@2.0.1: {} + + is-unicode-supported@0.1.0: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@6.0.3(supports-color@8.1.1): + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/parser': 7.29.7 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6(supports-color@8.1.1): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterare@1.2.1: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-changed-files@30.4.1: + dependencies: + execa: 5.1.1 + jest-util: 30.4.1 + p-limit: 3.1.0 + + jest-circus@30.4.2(supports-color@8.1.1): + dependencies: + '@jest/environment': 30.4.1 + '@jest/expect': 30.4.1(supports-color@8.1.1) + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 24.13.3 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 30.4.1 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-runtime: 30.4.2(supports-color@8.1.1) + jest-snapshot: 30.4.1(supports-color@8.1.1) + jest-util: 30.4.1 + p-limit: 3.1.0 + pretty-format: 30.4.1 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@30.4.2(@types/node@24.13.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.4.2(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)) + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.4.2(@types/node@24.13.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)) + jest-util: 30.4.1 + jest-validate: 30.4.1 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jest-config@30.4.2(@types/node@24.13.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.4.0 + '@jest/test-sequencer': 30.4.1 + '@jest/types': 30.4.1 + babel-jest: 30.4.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.4.2(supports-color@8.1.1) + jest-docblock: 30.4.0 + jest-environment-node: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-runner: 30.4.2(supports-color@8.1.1) + jest-util: 30.4.1 + jest-validate: 30.4.1 + parse-json: 5.2.0 + pretty-format: 30.4.1 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 24.13.3 + ts-node: 10.9.2(@types/node@24.13.3)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@30.4.1: + dependencies: + '@jest/diff-sequences': 30.4.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.4.1 + + jest-docblock@30.4.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.4.1 + chalk: 4.1.2 + jest-util: 30.4.1 + pretty-format: 30.4.1 + + jest-environment-node@30.4.1: + dependencies: + '@jest/environment': 30.4.1 + '@jest/fake-timers': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 24.13.3 + jest-mock: 30.4.1 + jest-util: 30.4.1 + jest-validate: 30.4.1 + + jest-haste-map@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 24.13.3 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.4.0 + jest-util: 30.4.1 + jest-worker: 30.4.1 + picomatch: 4.0.5 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 - http-errors@2.0.1: + jest-leak-detector@30.4.1: dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 + '@jest/get-type': 30.1.0 + pretty-format: 30.4.1 - iconv-lite@0.7.3: + jest-matcher-utils@30.4.1: dependencies: - safer-buffer: 2.1.2 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.4.1 + pretty-format: 30.4.1 - ieee754@1.2.1: {} + jest-message-util@30.4.1: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 30.4.1 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-util: 30.4.1 + picomatch: 4.0.4 + pretty-format: 30.4.1 + slash: 3.0.0 + stack-utils: 2.0.6 - import-fresh@3.3.1: + jest-mock@30.4.1: dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 + '@jest/types': 30.4.1 + '@types/node': 24.13.3 + jest-util: 30.4.1 - inherits@2.0.4: {} + jest-pnp-resolver@1.2.3(jest-resolve@30.4.1): + optionalDependencies: + jest-resolve: 30.4.1 - ipaddr.js@1.9.1: {} + jest-regex-util@30.4.0: {} - is-arrayish@0.2.1: {} + jest-resolve-dependencies@30.4.2(supports-color@8.1.1): + dependencies: + jest-regex-util: 30.4.0 + jest-snapshot: 30.4.1(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color - is-fullwidth-code-point@3.0.0: {} + jest-resolve@30.4.1: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-pnp-resolver: 1.2.3(jest-resolve@30.4.1) + jest-util: 30.4.1 + jest-validate: 30.4.1 + slash: 3.0.0 + unrs-resolver: 1.12.2 + + jest-runner@30.4.2(supports-color@8.1.1): + dependencies: + '@jest/console': 30.4.1 + '@jest/environment': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1(supports-color@8.1.1) + '@jest/types': 30.4.1 + '@types/node': 24.13.3 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.4.0 + jest-environment-node: 30.4.1 + jest-haste-map: 30.4.1 + jest-leak-detector: 30.4.1 + jest-message-util: 30.4.1 + jest-resolve: 30.4.1 + jest-runtime: 30.4.2(supports-color@8.1.1) + jest-util: 30.4.1 + jest-watcher: 30.4.1 + jest-worker: 30.4.1 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color - is-interactive@1.0.0: {} + jest-runtime@30.4.2(supports-color@8.1.1): + dependencies: + '@jest/environment': 30.4.1 + '@jest/fake-timers': 30.4.1 + '@jest/globals': 30.4.1(supports-color@8.1.1) + '@jest/source-map': 30.0.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1(supports-color@8.1.1) + '@jest/types': 30.4.1 + '@types/node': 24.13.3 + chalk: 4.1.2 + cjs-module-lexer: 2.2.0 + collect-v8-coverage: 1.0.3 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-snapshot: 30.4.1(supports-color@8.1.1) + jest-util: 30.4.1 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color - is-promise@4.0.0: {} + jest-snapshot@30.4.1(supports-color@8.1.1): + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/generator': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/types': 7.29.7 + '@jest/expect-utils': 30.4.1 + '@jest/get-type': 30.1.0 + '@jest/snapshot-utils': 30.4.1 + '@jest/transform': 30.4.1(supports-color@8.1.1) + '@jest/types': 30.4.1 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7(supports-color@8.1.1)) + chalk: 4.1.2 + expect: 30.4.1 + graceful-fs: 4.2.11 + jest-diff: 30.4.1 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + pretty-format: 30.4.1 + semver: 7.8.5 + synckit: 0.11.13 + transitivePeerDependencies: + - supports-color - is-unicode-supported@0.1.0: {} + jest-util@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 24.13.3 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.5 - iterare@1.2.1: {} + jest-validate@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.4.1 + camelcase: 6.3.0 + chalk: 4.1.2 + leven: 3.1.0 + pretty-format: 30.4.1 + + jest-watcher@30.4.1: + dependencies: + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 24.13.3 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.4.1 + string-length: 4.0.2 jest-worker@27.5.1: dependencies: @@ -2518,18 +5601,54 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jest-worker@30.4.1: + dependencies: + '@types/node': 24.13.3 + '@ungap/structured-clone': 1.3.3 + jest-util: 30.4.1 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@30.4.2(@types/node@24.13.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.4.2(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)) + '@jest/types': 30.4.1 + import-local: 3.2.0 + jest-cli: 30.4.2(@types/node@24.13.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + js-tokens@4.0.0: {} + js-yaml@3.15.0: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.3.0: dependencies: argparse: 2.0.1 + js-yaml@5.2.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + json-parse-even-better-errors@2.3.1: {} json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} jsonc-parser@3.3.1: {} @@ -2540,12 +5659,41 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lines-and-columns@1.2.4: {} + lint-staged@17.1.0: + dependencies: + picomatch: 4.0.5 + string-argv: 0.3.2 + tinyexec: 1.2.4 + optionalDependencies: + yaml: 2.9.0 + load-esm@1.0.3: {} loader-runner@4.3.2: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.memoize@4.1.2: {} + lodash@4.18.1: {} log-symbols@4.1.0: @@ -2553,14 +5701,28 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + lru-cache@10.4.3: {} + lru-cache@11.5.2: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + make-error@1.3.6: {} + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + math-intrinsics@1.1.0: {} media-typer@0.3.0: {} @@ -2575,6 +5737,8 @@ snapshots: merge-stream@2.0.0: {} + methods@1.1.2: {} + mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -2587,6 +5751,8 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@2.6.0: {} + mimic-fn@2.1.0: {} minimatch@10.2.5: @@ -2597,6 +5763,10 @@ snapshots: dependencies: brace-expansion: 1.1.16 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + minimist@1.2.8: {} minipass@7.1.3: {} @@ -2612,6 +5782,10 @@ snapshots: mute-stream@2.0.0: {} + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + negotiator@0.6.3: {} negotiator@1.0.0: {} @@ -2624,8 +5798,16 @@ snapshots: dependencies: lodash: 4.18.1 + node-int64@0.4.0: {} + node-releases@2.0.50: {} + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -2644,6 +5826,15 @@ snapshots: dependencies: mimic-fn: 2.1.0 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + ora@5.4.1: dependencies: bl: 4.1.0 @@ -2656,6 +5847,26 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -2669,6 +5880,17 @@ snapshots: parseurl@1.3.3: {} + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.5.2 @@ -2680,10 +5902,35 @@ snapshots: picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.4: {} + picomatch@4.0.5: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + pluralize@8.0.0: {} + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + + prettier@3.9.6: {} + + pretty-format@30.4.1: + dependencies: + '@jest/schemas': 30.4.1 + ansi-styles: 5.2.0 + react-is-18: react-is@18.3.1 + react-is-19: react-is@19.2.7 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -2691,6 +5938,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@7.0.1: {} + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -2705,6 +5954,10 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + react-is@18.3.1: {} + + react-is@19.2.7: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -2715,10 +5968,18 @@ snapshots: reflect-metadata@0.2.2: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + resolve-from@4.0.0: {} + resolve-from@5.0.0: {} + restore-cursor@3.1.0: dependencies: onetime: 5.1.2 @@ -2759,6 +6020,8 @@ snapshots: ajv-formats: 2.1.1(ajv@8.20.0) ajv-keywords: 5.1.0(ajv@8.20.0) + semver@6.3.1: {} + semver@7.8.5: {} send@1.2.1(supports-color@8.1.1): @@ -2788,6 +6051,12 @@ snapshots: setprototypeof@1.2.0: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -2820,6 +6089,8 @@ snapshots: signal-exit@4.1.0: {} + slash@3.0.0: {} + socket.io-adapter@2.5.8(supports-color@8.1.1): dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -2861,6 +6132,11 @@ snapshots: - supports-color - utf-8-validate + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 @@ -2872,16 +6148,35 @@ snapshots: source-map@0.7.6: {} + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + statuses@2.0.2: {} streamsearch@1.1.0: {} + string-argv@0.3.2: {} + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -2890,12 +6185,44 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@3.1.1: {} + strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 + superagent@10.3.0(supports-color@8.1.1): + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3(supports-color@8.1.1) + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.3 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2(supports-color@8.1.1): + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -2904,17 +6231,27 @@ snapshots: dependencies: has-flag: 4.0.0 + swagger-ui-dist@5.32.8: + dependencies: + '@scarf/scarf': 1.4.0 + symbol-observable@4.0.0: {} + synckit@0.11.13: + dependencies: + '@pkgr/core': 0.3.6 + tapable@2.3.3: {} - terser-webpack-plugin@5.6.1(webpack@5.106.2): + terser-webpack-plugin@5.6.1(uglify-js@3.19.3)(webpack@5.106.2(uglify-js@3.19.3)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.48.0 - webpack: 5.106.2 + webpack: 5.106.2(uglify-js@3.19.3) + optionalDependencies: + uglify-js: 3.19.3 terser@5.48.0: dependencies: @@ -2923,6 +6260,21 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tmpl@1.0.5: {} + toidentifier@1.0.1: {} token-types@6.1.2: @@ -2931,13 +6283,37 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 - ts-loader@9.6.2(typescript@5.9.3)(webpack@5.106.2): + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-jest@29.4.11(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@30.4.1(supports-color@8.1.1))(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(jest-util@30.4.1)(jest@30.4.2(@types/node@24.13.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.9 + jest: 30.4.2(@types/node@24.13.3)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.8.5 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@jest/transform': 30.4.1(supports-color@8.1.1) + '@jest/types': 30.4.1 + babel-jest: 30.4.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + jest-util: 30.4.1 + + ts-loader@9.6.2(typescript@5.9.3)(webpack@5.106.2(uglify-js@3.19.3)): dependencies: chalk: 4.1.2 picomatch: 4.0.4 source-map: 0.7.6 typescript: 5.9.3 - webpack: 5.106.2 + webpack: 5.106.2(uglify-js@3.19.3) ts-node@10.9.2(@types/node@24.13.3)(typescript@5.9.3): dependencies: @@ -2972,6 +6348,16 @@ snapshots: tslib@2.8.1: {} + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -2985,8 +6371,22 @@ snapshots: typedarray@0.0.6: {} + typescript-eslint@8.65.0(eslint@10.7.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@10.7.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.65.0(eslint@10.7.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.65.0(supports-color@8.1.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.7.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + eslint: 10.7.0(supports-color@8.1.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} + uglify-js@3.19.3: + optional: true + uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 @@ -2999,6 +6399,33 @@ snapshots: unpipe@1.0.0: {} + unrs-resolver@1.12.2: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + update-browserslist-db@1.2.3(browserslist@4.28.4): dependencies: browserslist: 4.28.4 @@ -3013,8 +6440,18 @@ snapshots: v8-compile-cache-lib@3.0.1: {} + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + vary@1.1.2: {} + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + watchpack@2.5.2: dependencies: graceful-fs: 4.2.11 @@ -3027,7 +6464,7 @@ snapshots: webpack-sources@3.5.0: {} - webpack@5.106.2: + webpack@5.106.2(uglify-js@3.19.3): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.9 @@ -3039,7 +6476,7 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.17.0) browserslist: 4.28.4 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.24.1 + enhanced-resolve: 5.24.3 es-module-lexer: 2.3.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -3050,7 +6487,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 - terser-webpack-plugin: 5.6.1(webpack@5.106.2) + terser-webpack-plugin: 5.6.1(uglify-js@3.19.3)(webpack@5.106.2(uglify-js@3.19.3)) watchpack: 2.5.2 webpack-sources: 3.5.0 transitivePeerDependencies: @@ -3067,20 +6504,64 @@ snapshots: - postcss - uglify-js + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + ws@8.21.0: {} xmlhttprequest-ssl@2.1.2: {} + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml@2.9.0: + optional: true + yargs-parser@21.1.1: {} + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yn@3.1.1: {} + yocto-queue@0.1.0: {} + yoctocolors-cjs@2.1.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b403d25..4be6d7f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,8 +2,16 @@ # (ERR_PNPM_IGNORED_BUILDS) on any that haven't been explicitly reviewed. # @nestjs/core's only postinstall is an Open Collective funding banner # (`opencollective || exit 0`), so we acknowledge it and skip it. +# unrs-resolver (transitive, via jest-resolve) runs napi-postinstall to link a +# native binding. The prebuilt platform binary arrives as an optional +# dependency, so resolution works without running the script. +# @scarf/scarf (transitive, via @nestjs/swagger) reports installation analytics +# to a third party on postinstall. Nothing depends on it running, so it is +# skipped rather than allowed to phone home from developer machines and CI. allowBuilds: - "@nestjs/core": false + '@nestjs/core': false + '@scarf/scarf': false + unrs-resolver: false # Refuse to install any package version published less than 7 days ago # (10080 minutes), mitigating supply-chain attacks that rely on freshly diff --git a/script/README.md b/script/README.md new file mode 100644 index 0000000..7c9bfac --- /dev/null +++ b/script/README.md @@ -0,0 +1,22 @@ +# script/ + +Lifecycle scripts following the +[OHF task conventions](https://standards.openhomefoundation.org/standards/task-conventions/). +Each is invokable directly or through its mise task; prefer the mise task, which +also provisions the pinned Node version. + +| Script | Task | Purpose | +| ---------------- | ----------------- | ----------------------------------------------------------- | +| `script/setup` | `mise run setup` | First-time setup: install dependencies, create `.env` | +| `script/update` | `mise run update` | Sync after pulling: reinstall dependencies, flag new config | +| `script/server` | `mise run dev` | Start the development server in watch mode | +| `script/test` | `mise run test` | Unit tests, then end-to-end tests | +| `script/cibuild` | `mise run ci` | Format check, lint, typecheck, tests, build | + +All scripts are bash, idempotent, and resolve the repository root themselves, so +they work from any directory. + +This project has no database and no code generation, so `setup` and `update` +have no migration or codegen steps. Configuration is entirely environment +variables: `setup` copies `example.env` to `.env` only when `.env` is absent and +never reads or overwrites an existing one. diff --git a/script/cibuild b/script/cibuild new file mode 100755 index 0000000..32d8afc --- /dev/null +++ b/script/cibuild @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Full CI pipeline: the same gates .github/workflows/ci.yml runs, plus the +# format/lint/typecheck checks enforced by review and the release image build. +set -euo pipefail +cd "$(dirname "$0")/.." + +# pnpm's minimumReleaseAge re-validates every lockfile entry's publish date even +# for frozen installs. That cooldown is a resolution-time guard for adopting new +# versions; a CI run merely reproduces an already-gated, PR-reviewed lockfile, +# so it should not be re-gated here. Mirrors the env var set in CI. +export PNPM_CONFIG_MINIMUM_RELEASE_AGE=0 + +echo "==> Enabling corepack" +if ! corepack enable >/dev/null 2>&1; then + echo "!! 'corepack enable' failed; it needs write access to the Node installation." >&2 + exit 1 +fi + +echo "==> Installing dependencies (frozen lockfile)" +pnpm install --frozen-lockfile + +echo "==> Format check" +pnpm run format:check + +echo "==> Lint" +pnpm run lint + +echo "==> Typecheck" +pnpm run typecheck + +echo "==> Unit tests" +pnpm test + +echo "==> End-to-end tests" +pnpm run test:e2e + +echo "==> Build" +pnpm run build + +echo "==> cibuild passed." diff --git a/script/install-mise b/script/install-mise new file mode 100755 index 0000000..4827e6b --- /dev/null +++ b/script/install-mise @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# +# Install mise, the task runner this project's tasks are defined for. +# +# For environments that do not ship it — the devcontainer, and any CI job that +# wants to run tasks through mise rather than pnpm directly. Developers on their +# own machines will usually already have it (https://mise.jdx.dev). +# +# Uses mise's GPG-signed APT repository rather than piping a remote installer +# into a shell, so the download is verified by apt against a pinned key. +set -euo pipefail + +cd "$(dirname "$0")/.." + +if command -v mise >/dev/null 2>&1; then + echo "==> mise already installed: $(mise --version)" + exit 0 +fi + +if ! command -v apt-get >/dev/null 2>&1; then + echo "==> No apt-get here; install mise for your platform: https://mise.jdx.dev/getting-started.html" >&2 + exit 1 +fi + +SUDO="" +if [ "$(id -u)" -ne 0 ]; then + SUDO="sudo" +fi + +KEYRING=/etc/apt/keyrings/mise-archive-keyring.gpg + +echo "==> Adding the mise APT repository" +$SUDO install -dm 755 /etc/apt/keyrings +curl -fsSL https://mise.jdx.dev/gpg-key.pub | + gpg --dearmor | + $SUDO tee "$KEYRING" >/dev/null +echo "deb [signed-by=$KEYRING arch=$(dpkg --print-architecture)] https://mise.jdx.dev/deb stable main" | + $SUDO tee /etc/apt/sources.list.d/mise.list >/dev/null + +echo "==> Installing mise" +$SUDO apt-get update -qq +$SUDO apt-get install -y --no-install-recommends mise + +echo "==> Installed $(mise --version)" diff --git a/script/server b/script/server new file mode 100755 index 0000000..9f9d886 --- /dev/null +++ b/script/server @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Start the development server in watch mode. +set -euo pipefail +cd "$(dirname "$0")/.." + +if [ ! -d node_modules ]; then + echo "!! node_modules is missing. Run script/setup first." >&2 + exit 1 +fi + +if [ ! -f .env ]; then + echo "!! No .env found; the server will start but every channel reports \"none\"." >&2 + echo " Run script/setup to create one from example.env." >&2 +fi + +echo "==> Starting web-api on port ${PORT:-3000}" +exec pnpm run start:dev diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..10efbd8 --- /dev/null +++ b/script/setup @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# First-time project setup. Safe to re-run. +set -euo pipefail +cd "$(dirname "$0")/.." + +echo "==> Enabling corepack" +# pnpm's version is pinned by package.json's `packageManager` field; corepack +# resolves that exact version instead of whatever pnpm happens to be on PATH. +if ! corepack enable >/dev/null 2>&1; then + echo "!! 'corepack enable' failed; it needs write access to the Node installation." >&2 + echo " Enable corepack manually (or run through 'mise run setup'), then retry." >&2 + exit 1 +fi + +echo "==> Installing dependencies" +pnpm install + +if [ -f .env ]; then + echo "==> .env already exists, leaving it untouched" +else + echo "==> Creating .env from example.env" + cp example.env .env + echo " Set YOUTUBE_API_KEY in .env before starting the server." +fi + +# No database and no code generation in this project, so there is nothing to +# migrate or generate here. + +echo "==> Setup complete. Run 'mise run dev' to start the server." diff --git a/script/test b/script/test new file mode 100755 index 0000000..7e2eb95 --- /dev/null +++ b/script/test @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Run the test suite. Extra arguments are passed to the unit-test runner, e.g. +# script/test livestream --watch +set -euo pipefail +cd "$(dirname "$0")/.." + +if [ ! -d node_modules ]; then + echo "!! node_modules is missing. Run script/setup first." >&2 + exit 1 +fi + +if [ "$#" -gt 0 ]; then + echo "==> Unit tests (filtered: $*)" + exec pnpm test -- "$@" +fi + +echo "==> Unit tests" +pnpm test + +echo "==> End-to-end tests" +pnpm run test:e2e diff --git a/script/update b/script/update new file mode 100755 index 0000000..34ee2ca --- /dev/null +++ b/script/update @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Bring an existing checkout up to date after pulling changes. Safe to re-run. +set -euo pipefail +cd "$(dirname "$0")/.." + +echo "==> Enabling corepack" +if ! corepack enable >/dev/null 2>&1; then + echo "!! 'corepack enable' failed; it needs write access to the Node installation." >&2 + exit 1 +fi + +echo "==> Installing dependencies" +pnpm install + +# No database and no code generation in this project, so there are no +# migrations or codegen steps to run here. + +# Config is environment-variable driven, so a pull can introduce new settings. +# Compare variable *names* only: .env may hold a real API key. +if [ -f .env ]; then + env_names() { grep -oE '^[A-Za-z_][A-Za-z0-9_]*=' "$1" | tr -d '=' | sort -u; } + missing=$(comm -23 <(env_names example.env) <(env_names .env) || true) + if [ -n "$missing" ]; then + echo "==> example.env has variables your .env does not set:" + while IFS= read -r name; do echo " $name"; done <<<"$missing" + fi +else + echo "==> No .env found. Run script/setup to create one from example.env." +fi + +echo "==> Update complete." diff --git a/src/app.controller.ts b/src/app.controller.ts index adabdf9..04342cb 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,7 +1,11 @@ import { Controller, Get } from '@nestjs/common'; +import { ApiExcludeEndpoint } from '@nestjs/swagger'; @Controller() export class AppController { + // Scaffolding left over from the project template: not part of the public API, + // so it is kept out of the spec. + @ApiExcludeEndpoint() @Get('test') getHello(): string { return 'Hello World'; diff --git a/src/app.gateway.ts b/src/app.gateway.ts index 3da0a52..a2b1241 100644 --- a/src/app.gateway.ts +++ b/src/app.gateway.ts @@ -7,8 +7,9 @@ import { Server, Socket } from 'socket.io'; @WebSocketGateway({ cors: { origin: '*' } }) export class AppGateway implements OnGatewayConnection { + // Assigned by Nest after instantiation, so it cannot be initialised here. @WebSocketServer() - server: Server; + server!: Server; handleConnection(client: Socket) { client.emit('message', 'Hello World'); diff --git a/src/app.module.ts b/src/app.module.ts index 1f51aca..464ad93 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,11 +1,17 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppGateway } from './app.gateway'; import { HealthModule } from './health'; import { getVersionInfo } from './health/version'; +import { LivestreamModule } from './livestream'; @Module({ - imports: [HealthModule.register({ version: getVersionInfo() })], + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + HealthModule.register({ version: getVersionInfo() }), + LivestreamModule, + ], controllers: [AppController], providers: [AppGateway], }) diff --git a/src/cors.spec.ts b/src/cors.spec.ts new file mode 100644 index 0000000..ef9fb23 --- /dev/null +++ b/src/cors.spec.ts @@ -0,0 +1,187 @@ +import { + ANY_ORIGIN, + CORS_ORIGINS_ENV, + corsOriginMatchers, + parseCorsOrigins, +} from './cors'; + +/** Does this configuration allow `origin`, the way enableCors() decides it? */ +const allows = (raw: string, origin: string): boolean => + corsOriginMatchers(parseCorsOrigins(raw)).some((matcher) => + typeof matcher === 'string' ? matcher === origin : matcher.test(origin), + ); + +describe('parseCorsOrigins', () => { + describe('no configured origins', () => { + it.each([ + ['unset', undefined], + ['empty', ''], + ['blank', ' '], + ['commas only', ' , ,, '], + ])('reports no origins when the variable is %s', (_case, raw) => { + expect(parseCorsOrigins(raw)).toEqual([]); + }); + }); + + describe('a list of origins', () => { + it('keeps the configured origins, in order', () => { + expect( + parseCorsOrigins('https://www.home-assistant.io,https://esphome.io'), + ).toEqual(['https://www.home-assistant.io', 'https://esphome.io']); + }); + + it('ignores whitespace and a trailing comma', () => { + expect( + parseCorsOrigins(' https://esphome.io , https://music-assistant.io , '), + ).toEqual(['https://esphome.io', 'https://music-assistant.io']); + }); + + it('keeps a non-default port, which is part of the origin', () => { + expect(parseCorsOrigins('http://localhost:8123')).toEqual([ + 'http://localhost:8123', + ]); + }); + + it('drops a default port, which browsers do not send', () => { + expect(parseCorsOrigins('https://esphome.io:443')).toEqual([ + 'https://esphome.io', + ]); + }); + + it('normalises case, so an entry matches the header a browser sends', () => { + expect(parseCorsOrigins('HTTPS://ESPHome.IO')).toEqual([ + 'https://esphome.io', + ]); + }); + + it('accepts a trailing slash and normalises it away', () => { + expect(parseCorsOrigins('https://esphome.io/')).toEqual([ + 'https://esphome.io', + ]); + }); + + it('allows plain http, for local development', () => { + expect(parseCorsOrigins('http://localhost:3000')).toEqual([ + 'http://localhost:3000', + ]); + }); + }); + + describe(`"${ANY_ORIGIN}"`, () => { + it('allows any origin', () => { + expect(parseCorsOrigins(ANY_ORIGIN)).toEqual([ANY_ORIGIN]); + }); + + it('rejects mixing it with specific origins', () => { + expect(() => parseCorsOrigins('*,https://esphome.io')).toThrow( + /mixes "\*" with specific origins/, + ); + }); + }); + + describe('subdomain wildcards', () => { + it('keeps a wildcard entry as written', () => { + expect(parseCorsOrigins('https://*.esphome.io')).toEqual([ + 'https://*.esphome.io', + ]); + }); + + it('normalises a wildcard entry’s case', () => { + expect(parseCorsOrigins('HTTPS://*.ESPHome.IO')).toEqual([ + 'https://*.esphome.io', + ]); + }); + + it.each([ + 'https://www.esphome.io', + 'https://a.b.esphome.io', + 'https://WWW.ESPHOME.IO', + ])('allows %s', (origin) => { + expect(allows('https://*.esphome.io', origin)).toBe(true); + }); + + it.each([ + // The apex is not a subdomain of itself; list it separately to allow it. + ['the bare domain', 'https://esphome.io'], + ['a domain that merely ends the same way', 'https://evil-esphome.io'], + ['the domain as a prefix of another', 'https://esphome.io.evil.example'], + ['the same host over http', 'http://www.esphome.io'], + ['a port that was not configured', 'https://www.esphome.io:8443'], + ])('does not allow %s', (_case, origin) => { + expect(allows('https://*.esphome.io', origin)).toBe(false); + }); + + it('allows the domain itself when it is listed alongside', () => { + const raw = 'https://esphome.io,https://*.esphome.io'; + + expect(allows(raw, 'https://esphome.io')).toBe(true); + expect(allows(raw, 'https://www.esphome.io')).toBe(true); + }); + + it('leaves exact entries as plain strings', () => { + expect(corsOriginMatchers(['https://esphome.io'])).toEqual([ + 'https://esphome.io', + ]); + }); + + it('rejects a wildcard that is not the leading label', () => { + expect(() => parseCorsOrigins('https://sub.*.esphome.io')).toThrow( + /only use "\*" as its leading label/, + ); + expect(() => parseCorsOrigins('https://ev*l.esphome.io')).toThrow( + /only use "\*" as its leading label/, + ); + }); + + it('rejects a wildcard over a whole suffix', () => { + expect(() => parseCorsOrigins('https://*.io')).toThrow( + /must name a domain below the wildcard/, + ); + }); + }); + + describe('malformed configuration', () => { + it('rejects an entry that is not a URL', () => { + expect(() => parseCorsOrigins('esphome.io')).toThrow(/is not a URL/); + }); + + it('rejects a scheme that is not http or https', () => { + expect(() => parseCorsOrigins('ftp://esphome.io')).toThrow( + /must use http or https/, + ); + }); + + it('rejects an origin carrying a path', () => { + expect(() => parseCorsOrigins('https://esphome.io/docs')).toThrow( + /scheme and host only/, + ); + }); + + it('rejects an origin carrying a query or fragment', () => { + expect(() => parseCorsOrigins('https://esphome.io?a=1')).toThrow( + /scheme and host only/, + ); + expect(() => parseCorsOrigins('https://esphome.io#top')).toThrow( + /scheme and host only/, + ); + }); + + it('rejects credentials in an origin', () => { + expect(() => parseCorsOrigins('https://user:pw@esphome.io')).toThrow( + /must not carry credentials/, + ); + }); + + it('rejects the same origin listed twice, however it is written', () => { + expect(() => + parseCorsOrigins('https://esphome.io,https://ESPHome.io/'), + ).toThrow(/lists "https:\/\/esphome.io" twice/); + }); + + it('names the variable and the offending entry', () => { + expect(() => parseCorsOrigins('https://esphome.io,nope')).toThrow( + `${CORS_ORIGINS_ENV}[1] "nope"`, + ); + }); + }); +}); diff --git a/src/cors.ts b/src/cors.ts new file mode 100644 index 0000000..47db9fd --- /dev/null +++ b/src/cors.ts @@ -0,0 +1,153 @@ +/** Environment variable holding the origins allowed to read this API. */ +export const CORS_ORIGINS_ENV = 'CORS_ORIGINS'; + +/** The value that opts every origin in, rather than naming them. */ +export const ANY_ORIGIN = '*'; + +/** Leading label that stands for "any subdomain of what follows". */ +const WILDCARD_LABEL = '*.'; + +const FORMAT = + 'expected a comma-separated list of origins, e.g. ' + + 'https://www.home-assistant.io,https://*.esphome.io — or "*" for any origin'; + +/** One label of a host name, as a wildcard entry's subdomains are matched. */ +const LABEL = '[a-z0-9-]+'; + +const escapeRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Parse the allowed origins out of the CORS_ORIGINS environment variable. + * + * Returns the normalised origins, `["*"]` when any origin is allowed, or an + * empty list when the variable is unset or blank. An empty list means no + * cross-origin access: browsers on other sites cannot read the API, which is the + * safe default for a deployment that has not said who may. + * + * Malformed entries throw, so a typo fails startup rather than silently dropping + * a site that was meant to be allowed — a failure that would otherwise surface + * only in someone else's browser console. + */ +export function parseCorsOrigins(raw: string | undefined): string[] { + // A trailing or doubled comma is a typo, not an origin. + const entries = (raw ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + if (entries.length === 0) { + return []; + } + + if (entries.includes(ANY_ORIGIN)) { + // Allowing any origin makes the specific ones meaningless, so listing both + // means one of the two was not what the operator intended. + if (entries.length > 1) { + throw new Error( + `${CORS_ORIGINS_ENV} mixes "${ANY_ORIGIN}" with specific origins — list one or the other`, + ); + } + return [ANY_ORIGIN]; + } + + const origins = entries.map(toOrigin); + const seen = new Set(); + for (const origin of origins) { + // Hosts and schemes are case-insensitive, so two entries can normalise to + // the same origin without looking alike in the config. + if (seen.has(origin)) { + throw new Error(`${CORS_ORIGINS_ENV} lists "${origin}" twice`); + } + seen.add(origin); + } + return origins; +} + +/** + * Normalise one entry to the origin a browser would send: scheme, host and any + * non-default port, lowercased, with nothing else. A `*.` leading label is kept + * as written; corsOriginMatchers turns it into a pattern. + */ +function toOrigin(entry: string, index: number): string { + const at = `${CORS_ORIGINS_ENV}[${index}] "${entry}"`; + + let url: URL; + try { + url = new URL(entry); + } catch { + throw new Error(`${at} is not a URL — ${FORMAT}`); + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`${at} must use http or https — ${FORMAT}`); + } + if (url.username || url.password) { + throw new Error(`${at} must not carry credentials`); + } + // An Origin header is scheme, host and port only. A path here would never + // match, so it is a misunderstanding worth naming rather than trimming. + if (url.pathname !== '/' || url.search || url.hash) { + throw new Error( + `${at} must be a scheme and host only — an Origin header carries no path, query or fragment`, + ); + } + if (url.hostname.includes(ANY_ORIGIN)) { + checkWildcardHost(url.hostname, at); + } + + return url.origin; +} + +/** + * A wildcard may only stand in for the leading label, because that is the one + * thing subdomain matching can express: `https://*.esphome.io` covers + * `www.esphome.io` and `a.b.esphome.io`, but nothing else about the host varies. + */ +function checkWildcardHost(hostname: string, at: string): void { + if ( + !hostname.startsWith(WILDCARD_LABEL) || + hostname.slice(WILDCARD_LABEL.length).includes(ANY_ORIGIN) + ) { + throw new Error( + `${at} may only use "${ANY_ORIGIN}" as its leading label, e.g. https://${WILDCARD_LABEL}esphome.io`, + ); + } + + const labels = hostname.slice(WILDCARD_LABEL.length).split('.'); + // "*.io" would hand every .io site access, which is never what was meant. + if (labels.length < 2) { + throw new Error( + `${at} must name a domain below the wildcard, e.g. https://${WILDCARD_LABEL}esphome.io`, + ); + } + if (!labels.every((label) => new RegExp(`^${LABEL}$`).test(label))) { + throw new Error(`${at} has a host that is not a domain name`); + } +} + +/** + * Turn parsed entries into what `enableCors` matches an incoming Origin against: + * an exact origin stays a string, and a `*.` entry becomes an anchored pattern + * over its subdomains. + * + * Anchored at both ends, and requiring a literal dot before the domain, so + * `https://*.esphome.io` covers `www.esphome.io` and `a.b.esphome.io` but not + * the bare `esphome.io` (list it too if you want it), not `evil-esphome.io`, and + * not `esphome.io.evil.example`. + */ +export function corsOriginMatchers(origins: string[]): (string | RegExp)[] { + const marker = `//${WILDCARD_LABEL}`; + return origins.map((origin) => { + const at = origin.indexOf(marker); + if (at === -1) { + return origin; + } + const scheme = origin.slice(0, at); + const host = origin.slice(at + marker.length); + return new RegExp( + `^${escapeRegExp(scheme)}//(?:${LABEL}\\.)+${escapeRegExp(host)}$`, + // Browsers send a lowercased host, but a non-browser client need not. + 'i', + ); + }); +} diff --git a/src/health/health.constants.ts b/src/health/health.constants.ts index c827e30..281bc04 100644 --- a/src/health/health.constants.ts +++ b/src/health/health.constants.ts @@ -1,3 +1,7 @@ export const HEALTH_CONFIG = Symbol('HEALTH_CONFIG'); -export const HEALTH_PATHS = ['/__version__', '/__heartbeat__', '/__lbheartbeat__']; +export const HEALTH_PATHS = [ + '/__version__', + '/__heartbeat__', + '/__lbheartbeat__', +]; diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index a77f877..0f5b292 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -1,9 +1,11 @@ import { Controller, Get, Inject } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { HEALTH_CONFIG } from './health.constants'; import { HealthControllerConfigParams } from './health.module'; -import { Version } from './version'; +import { VersionResponse } from './version.response'; +@ApiTags('health') @Controller() export class HealthController { constructor( @@ -11,12 +13,32 @@ export class HealthController { ) {} @Get('__lbheartbeat__') - lbheartbeat(): Record { + @ApiOperation({ + summary: 'Liveness probe for the load balancer', + description: + 'Answers as soon as the process can serve traffic, without touching any ' + + 'dependency. Always an empty JSON object.', + }) + @ApiOkResponse({ + description: 'The process is up.', + schema: { type: 'object', example: {} }, + }) + lbheartbeat(): Record { return {}; } @Get('__heartbeat__') - async heartbeat(): Promise> { + @ApiOperation({ + summary: 'Health check, including any dependency detail the app reports', + description: + 'Empty unless the app was registered with an extraHealthData callback, ' + + 'in which case the payload is whatever that callback returns.', + }) + @ApiOkResponse({ + description: 'The app is healthy.', + schema: { type: 'object', additionalProperties: true, example: {} }, + }) + async heartbeat(): Promise> { if (this.config.extraHealthData) { return this.config.extraHealthData(); } @@ -24,7 +46,12 @@ export class HealthController { } @Get('__version__') - versionData(): Version { + @ApiOperation({ summary: 'Report the running build' }) + @ApiOkResponse({ + description: 'Version and commit of the running build.', + type: VersionResponse, + }) + versionData(): VersionResponse { return this.config.version; } } diff --git a/src/health/health.module.ts b/src/health/health.module.ts index 0027201..0d0b02f 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -1,4 +1,9 @@ -import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common'; +import { + DynamicModule, + FactoryProvider, + Module, + ModuleMetadata, +} from '@nestjs/common'; import { HEALTH_CONFIG } from './health.constants'; import { HealthController } from './health.controller'; @@ -6,15 +11,20 @@ import { Version } from './version'; export interface HealthControllerConfigParams { version: Version; - extraHealthData?: () => Promise>; + extraHealthData?: () => Promise>; } -export interface HealthModuleAsyncParams - extends Pick { - useFactory: ( - ...args: any[] - ) => HealthControllerConfigParams | Promise; - inject?: any[]; +export interface HealthModuleAsyncParams extends Pick< + ModuleMetadata, + 'imports' | 'providers' +> { + // Borrowed from Nest's own provider types rather than restated: the factory + // and its injected tokens are handed straight to a FactoryProvider below, so + // anything Nest accepts there has to be accepted here. + useFactory: FactoryProvider< + HealthControllerConfigParams | Promise + >['useFactory']; + inject?: FactoryProvider['inject']; } @Module({ diff --git a/src/health/version.response.ts b/src/health/version.response.ts new file mode 100644 index 0000000..d47d517 --- /dev/null +++ b/src/health/version.response.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { Version } from './version'; + +/** + * The documented shape of `GET /__version__`. `Version` is an interface, which + * leaves no runtime metadata for `@nestjs/swagger`, so the schema lives here. + */ +export class VersionResponse implements Version { + @ApiProperty({ + description: "The running build's version.", + example: '0.1.0', + }) + version!: string; + + @ApiProperty({ + description: + 'Git commit the build was made from, or "dev" for a local run.', + example: '9f1c0d2a1b3c4d5e6f708192a3b4c5d6e7f80912', + }) + hash!: string; +} diff --git a/src/health/version.ts b/src/health/version.ts index bf0131f..873d87b 100644 --- a/src/health/version.ts +++ b/src/health/version.ts @@ -21,7 +21,8 @@ export function getVersionInfo(cwd: string = process.cwd()): Version { return { version: fromFile.version, hash: fromFile.hash }; } - const version = readJsonField(join(cwd, 'package.json'), 'version') ?? 'unknown'; + const version = + readJsonField(join(cwd, 'package.json'), 'version') ?? 'unknown'; return { version, hash: process.env.GIT_COMMIT ?? 'dev' }; } diff --git a/src/livestream/index.ts b/src/livestream/index.ts new file mode 100644 index 0000000..b7b514c --- /dev/null +++ b/src/livestream/index.ts @@ -0,0 +1,4 @@ +export * from './livestream.channels'; +export * from './livestream.module'; +export * from './livestream.response'; +export * from './livestream.service'; diff --git a/src/livestream/livestream.channels.spec.ts b/src/livestream/livestream.channels.spec.ts new file mode 100644 index 0000000..9685bf7 --- /dev/null +++ b/src/livestream/livestream.channels.spec.ts @@ -0,0 +1,339 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + Channel, + LIVESTREAM_CHANNELS, + feedUrl, + parseChannels, +} from './livestream.channels'; + +const FORMAT = 'expected a comma-separated list of handle:slug pairs'; + +describe('parseChannels', () => { + describe('valid configuration', () => { + it('parses a single handle:slug pair', () => { + expect(parseChannels('esphomeio:esphome')).toEqual([ + { slug: 'esphome', handle: 'esphomeio' }, + ]); + }); + + it('parses several pairs and preserves their configured order', () => { + const channels = parseChannels( + 'esphomeio:esphome,home_assistant:home-assistant,MusicAssistant:music-assistant', + ); + + expect(channels).toEqual([ + { slug: 'esphome', handle: 'esphomeio' }, + { slug: 'home-assistant', handle: 'home_assistant' }, + { slug: 'music-assistant', handle: 'MusicAssistant' }, + ]); + }); + + it('trims whitespace around entries, handles and slugs', () => { + expect( + parseChannels( + ' home_assistant : home-assistant ,\n\tesphomeio\t:\tesphome\n', + ), + ).toEqual([ + { slug: 'home-assistant', handle: 'home_assistant' }, + { slug: 'esphome', handle: 'esphomeio' }, + ]); + }); + + it.each([ + ['a single leading "@"', '@ESPHome:esphome'], + ['repeated leading "@"', '@@ESPHome:esphome'], + ['a leading "@" inside padding', ' @ESPHome : esphome '], + ])('strips %s from a handle', (_description, raw) => { + expect(parseChannels(raw)).toEqual([ + { slug: 'esphome', handle: 'ESPHome' }, + ]); + }); + + it.each([ + ['a dot', 'esp.home'], + ['an underscore', 'home_assistant'], + ['a hyphen', 'home-assistant'], + ['digits', 'esp32home'], + ['mixed case', 'OpenHomeFndn'], + ])('accepts a handle containing %s', (_description, handle) => { + const [channel] = parseChannels(`${handle}:some-slug`); + + expect(channel.handle).toBe(handle); + }); + + it.each([ + ['a single word', 'esphome'], + ['several hyphen-separated words', 'open-home-foundation'], + ['digits only', '123'], + ['letters and digits', 'esp32-home'], + ])('accepts a slug that is %s', (_description, slug) => { + const [channel] = parseChannels(`esphomeio:${slug}`); + + expect(channel.slug).toBe(slug); + }); + + it('tolerates a trailing comma', () => { + expect(parseChannels('esphomeio:esphome,')).toEqual([ + { slug: 'esphome', handle: 'esphomeio' }, + ]); + }); + + it('tolerates a doubled comma between entries', () => { + expect( + parseChannels('esphomeio:esphome,,home_assistant:home-assistant'), + ).toEqual([ + { slug: 'esphome', handle: 'esphomeio' }, + { slug: 'home-assistant', handle: 'home_assistant' }, + ]); + }); + + it('returns objects with exactly the slug and handle keys, normalized', () => { + const [channel] = parseChannels(' @Home_Assistant : home-assistant '); + + expect(Object.keys(channel).sort()).toEqual(['handle', 'slug']); + expect(channel).toEqual({ + slug: 'home-assistant', + handle: 'Home_Assistant', + }); + }); + }); + + describe('invalid configuration', () => { + it.each<[string, string | undefined, string]>([ + [ + 'the variable is unset', + undefined, + `LIVESTREAM_CHANNELS is not set: ${FORMAT}`, + ], + // Set-but-blank is reported distinctly from unset, so an operator who did + // set the variable is not sent looking for a missing one. Blank, + // whitespace and comma-only values are the same mistake, so they share + // one message. + [ + 'the variable is set to an empty string', + '', + `LIVESTREAM_CHANNELS is set but lists no channels: ${FORMAT}`, + ], + [ + 'the variable holds only whitespace', + ' \n\t ', + 'LIVESTREAM_CHANNELS is set but lists no channels', + ], + [ + 'the value holds only commas', + ',,,', + `LIVESTREAM_CHANNELS is set but lists no channels: ${FORMAT}`, + ], + ])('throws when %s', (_description, raw, message) => { + expect(() => parseChannels(raw)).toThrow(message); + }); + + it.each([ + ['there is no ":" at all', 'home-assistant'], + ['an entry has two colons', 'esphomeio:esphome:extra'], + ['an entry has three colons', 'a:b:c:d'], + ['an entry is a bare handle among valid pairs', 'esphomeio'], + ])('throws when %s', (_description, raw) => { + expect(() => parseChannels(raw)).toThrow( + `LIVESTREAM_CHANNELS[0] "${raw}" must be one handle and one slug separated by ":" — ${FORMAT}`, + ); + }); + + it.each([ + ['a full channel URL', 'https://youtube.com/@esphome:esphome'], + ['a URL with no path', 'https://youtube.com:esphome'], + ['an http URL', 'http://youtube.com/@esphome:esphome'], + ])('names the real mistake when an entry is %s', (_description, raw) => { + // A URL carries its own colon, so it must not be reported as a colon-count + // problem — that would send the operator after the wrong thing. + expect(() => parseChannels(raw)).toThrow( + `LIVESTREAM_CHANNELS[0] "${raw}" looks like a URL — use the bare handle and slug`, + ); + }); + + it.each([ + ['an empty handle before the ":"', ':home-assistant'], + ['a whitespace-only handle before the ":"', ' :home-assistant'], + // The entry is trimmed before it is quoted back, so the padding is gone. + ])('throws on %s', (_description, raw) => { + expect(() => parseChannels(raw)).toThrow( + `LIVESTREAM_CHANNELS[0] "${raw.trim()}" has no handle before the ":"`, + ); + }); + + it.each([ + ['only "@"', '@:home-assistant'], + ['only "@" characters', '@@@:home-assistant'], + ])( + 'distinguishes a handle that is %s from a missing one', + (_description, raw) => { + expect(() => parseChannels(raw)).toThrow( + `LIVESTREAM_CHANNELS[0] "${raw}" handle must name a channel, not just "@"`, + ); + }, + ); + + it.each([ + ['a single dot', '.:home-assistant'], + ['dots only', '...:home-assistant'], + ['hyphens only', '--:home-assistant'], + ['an underscore only', '_:home-assistant'], + ])('rejects a handle of punctuation alone (%s)', (_description, raw) => { + expect(() => parseChannels(raw)).toThrow('must be a bare handle'); + }); + + it.each([ + ['an empty slug after the ":"', 'esphomeio:'], + ['a whitespace-only slug after the ":"', 'esphomeio: '], + ])('throws on %s', (_description, raw) => { + expect(() => parseChannels(raw)).toThrow( + `LIVESTREAM_CHANNELS[0] "${raw.trimEnd()}" has no slug after the ":"`, + ); + }); + + it.each([ + ['a schemeless channel URL', 'youtube.com/@esphome', 'esphome'], + ['an inner "@"', 'esp@home', 'esphome'], + ['a space', 'home assistant', 'home-assistant'], + ['a slash', 'esphome/videos', 'esphome'], + ['a plus sign', 'esp+home', 'esphome'], + ])( + 'throws when a handle is %s rather than a bare handle', + (_description, handle, slug) => { + expect(() => parseChannels(`${handle}:${slug}`)).toThrow( + `LIVESTREAM_CHANNELS[0] "${handle}:${slug}" handle "${handle}" must be a bare handle — letters, digits, dots, underscores and hyphens only, not a URL`, + ); + }, + ); + + it.each([ + ['uppercase letters', 'Home-Assistant'], + ['an underscore', 'home_assistant'], + ['an inner space', 'home assistant'], + ['a leading hyphen', '-home-assistant'], + ['a trailing hyphen', 'home-assistant-'], + ['a double hyphen', 'home--assistant'], + ['a dot', 'home.assistant'], + ['a slash', 'home/assistant'], + ['only a hyphen', '-'], + ['non-ASCII letters', 'hogar-inteligente\u0301'], + ])( + 'throws when a slug contains %s because the slug appears in the API path', + (_description, slug) => { + expect(() => parseChannels(`home_assistant:${slug}`)).toThrow( + `LIVESTREAM_CHANNELS[0] "home_assistant:${slug}" slug "${slug}" must be lowercase letters, digits and single hyphens — it appears in the API path`, + ); + }, + ); + + it('throws when two entries declare the same slug, naming the slug', () => { + expect(() => + parseChannels('esphomeio:esphome,esphome_backup:esphome'), + ).toThrow('LIVESTREAM_CHANNELS has a duplicate slug "esphome"'); + }); + + it('throws when two entries point at the same handle, naming the handle', () => { + // Otherwise one channel is polled twice and served under two slugs. + expect(() => + parseChannels('esphomeio:esphome,esphomeio:esphome-mirror'), + ).toThrow( + 'LIVESTREAM_CHANNELS has two channels pointing at the same handle "esphomeio"', + ); + }); + + it('treats handles differing only in case as the same channel', () => { + // YouTube handles are case-insensitive. + expect(() => + parseChannels('ESPHomeIO:esphome,esphomeio:esphome-mirror'), + ).toThrow( + 'LIVESTREAM_CHANNELS has two channels pointing at the same handle "esphomeio"', + ); + }); + + it('identifies the offending entry by its index within the list', () => { + expect(() => + parseChannels('esphomeio:esphome,home_assistant:Home_Assistant'), + ).toThrow( + 'LIVESTREAM_CHANNELS[1] "home_assistant:Home_Assistant" slug "Home_Assistant"', + ); + }); + + it('numbers entries after a doubled comma by their surviving position', () => { + // The empty segment is dropped before indexing, so the third written + // entry is reported as index 1. + expect(() => parseChannels('esphomeio:esphome,,nope')).toThrow( + 'LIVESTREAM_CHANNELS[1] "nope"', + ); + }); + + it('fails on the first invalid entry rather than collecting every problem', () => { + expect(() => parseChannels('esphomeio:BAD,also bad:worse slug')).toThrow( + 'LIVESTREAM_CHANNELS[0] "esphomeio:BAD" slug "BAD"', + ); + }); + }); +}); + +describe('feedUrl', () => { + it('builds the canonical YouTube RSS feed URL for a channel ID', () => { + expect(feedUrl('UCbBg8TgHNMV1Z6qYrqbUlXA')).toBe( + 'https://www.youtube.com/feeds/videos.xml?channel_id=UCbBg8TgHNMV1Z6qYrqbUlXA', + ); + }); + + it('places the channel ID in the only query parameter, channel_id', () => { + const url = new URL(feedUrl('UC_test-123')); + + expect(url.origin + url.pathname).toBe( + 'https://www.youtube.com/feeds/videos.xml', + ); + expect(url.searchParams.get('channel_id')).toBe('UC_test-123'); + expect([...url.searchParams.keys()]).toEqual(['channel_id']); + }); +}); + +describe('LIVESTREAM_CHANNELS', () => { + it('is a symbol DI token rather than a channel list', () => { + expect(typeof LIVESTREAM_CHANNELS).toBe('symbol'); + expect(LIVESTREAM_CHANNELS.toString()).toContain('LIVESTREAM_CHANNELS'); + }); +}); + +describe('the LIVESTREAM_CHANNELS value shipped in example.env', () => { + // Read the committed example rather than duplicating the value here, so the + // documented default and this test cannot drift apart. + const readEnvExample = (key: string): string | undefined => { + const file = readFileSync( + join(__dirname, '..', '..', 'example.env'), + 'utf8', + ); + const line = file + .split('\n') + .map((candidate) => candidate.trim()) + .find((candidate) => candidate.startsWith(`${key}=`)); + if (line === undefined) return undefined; + + const value = line.slice(line.indexOf('=') + 1).trim(); + // .env files often wrap the value in quotes; strip one matching pair. + return /^'.*'$/.test(value) || /^".*"$/.test(value) + ? value.slice(1, -1) + : value; + }; + + let channels: Channel[]; + + beforeAll(() => { + channels = parseChannels(readEnvExample('LIVESTREAM_CHANNELS')); + }); + + it('parses into the four Open Home Foundation projects', () => { + expect([...channels].sort((a, b) => a.slug.localeCompare(b.slug))).toEqual([ + { slug: 'esphome', handle: 'esphomeio' }, + { slug: 'home-assistant', handle: 'home_assistant' }, + { slug: 'music-assistant', handle: 'musicassistantio' }, + { slug: 'open-home-foundation', handle: 'OpenHomeFndn' }, + ]); + }); +}); diff --git a/src/livestream/livestream.channels.ts b/src/livestream/livestream.channels.ts new file mode 100644 index 0000000..7f5ad13 --- /dev/null +++ b/src/livestream/livestream.channels.ts @@ -0,0 +1,130 @@ +export interface Channel { + /** URL-safe identifier used in the API path, e.g. "home-assistant". */ + slug: string; + /** YouTube handle without the leading "@". */ + handle: string; +} + +/** DI token for the parsed, validated channel list. */ +export const LIVESTREAM_CHANNELS = Symbol('LIVESTREAM_CHANNELS'); + +/** Slugs appear in the API path, so keep them unambiguous. */ +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * What a YouTube handle may contain: letters, digits, dots, underscores and + * hyphens, and at least one alphanumeric so punctuation alone ("--", "...") is + * rejected. Enforced so a malformed handle fails the deploy rather than every + * channels.list request at runtime. + */ +const HANDLE_PATTERN = /^(?=.*[A-Za-z0-9])[A-Za-z0-9._-]+$/; + +const ENV_VAR = 'LIVESTREAM_CHANNELS'; +const FORMAT = 'expected a comma-separated list of handle:slug pairs'; + +/** + * Canonical YouTube channel RSS feed URL — the discovery source for livestream + * state. Fetching it costs no YouTube Data API quota, unlike the videos.list + * lookup that classifies the videos it returns. + */ +export const feedUrl = (channelId: string): string => + `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`; + +/** + * Parse the tracked channels out of the LIVESTREAM_CHANNELS environment + * variable, e.g. `home_assistant:home-assistant,esphomeio:esphome`. Adding or + * removing a project is a config change, not a code change. + * + * Each entry pairs the YouTube handle used to find the channel with the slug + * this API serves it under. The slug is pinned here rather than derived from + * the channel's YouTube name so that renaming the channel cannot silently + * change our public URLs. Display names are read from the channel feed at + * runtime, so they are deliberately not configured. + * + * Throws on missing or malformed config rather than quietly tracking nothing, + * so a bad deploy fails loudly instead of serving an empty channel list. + */ +export function parseChannels(raw: string | undefined): Channel[] { + if (raw === undefined || raw === null) { + throw new Error(`${ENV_VAR} is not set: ${FORMAT}`); + } + + // A trailing or doubled comma is a typo, not a channel. + const entries = raw + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + // Reported distinctly from unset, so an operator who did set the variable is + // not sent looking for a missing one. Blank and comma-only values are the + // same mistake, so they share this message. + if (entries.length === 0) { + throw new Error(`${ENV_VAR} is set but lists no channels: ${FORMAT}`); + } + + const channels = entries.map(toChannel); + const slugs = new Set(); + const handles = new Set(); + for (const { slug, handle } of channels) { + if (slugs.has(slug)) { + throw new Error(`${ENV_VAR} has a duplicate slug "${slug}"`); + } + slugs.add(slug); + // YouTube handles are case-insensitive, so two entries differing only in + // case would poll one channel twice and serve it under two slugs. + const handleKey = handle.toLowerCase(); + if (handles.has(handleKey)) { + throw new Error( + `${ENV_VAR} has two channels pointing at the same handle "${handle}"`, + ); + } + handles.add(handleKey); + } + return channels; +} + +function toChannel(entry: string, index: number): Channel { + const at = `${ENV_VAR}[${index}] "${entry}"`; + + // A pasted channel URL carries its own colon, so name the real mistake rather + // than blaming the colon count. + if (entry.includes('://')) { + throw new Error( + `${at} looks like a URL — use the bare handle and slug, e.g. esphomeio:esphome`, + ); + } + + const parts = entry.split(':'); + if (parts.length !== 2) { + throw new Error( + `${at} must be one handle and one slug separated by ":" — ${FORMAT}`, + ); + } + + const declaredHandle = parts[0].trim(); + if (!declaredHandle) { + throw new Error(`${at} has no handle before the ":"`); + } + // Accept a leading "@" for convenience; channels.list wants it bare. Checked + // after the emptiness test so "@" alone gets its own message. + const handle = declaredHandle.replace(/^@+/, ''); + if (!handle) { + throw new Error(`${at} handle must name a channel, not just "@"`); + } + if (!HANDLE_PATTERN.test(handle)) { + throw new Error( + `${at} handle "${handle}" must be a bare handle — letters, digits, dots, underscores and hyphens only, not a URL`, + ); + } + + const slug = parts[1].trim(); + if (!slug) { + throw new Error(`${at} has no slug after the ":"`); + } + if (!SLUG_PATTERN.test(slug)) { + throw new Error( + `${at} slug "${slug}" must be lowercase letters, digits and single hyphens — it appears in the API path`, + ); + } + + return { slug, handle }; +} diff --git a/src/livestream/livestream.controller.spec.ts b/src/livestream/livestream.controller.spec.ts new file mode 100644 index 0000000..09475c3 --- /dev/null +++ b/src/livestream/livestream.controller.spec.ts @@ -0,0 +1,129 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { LivestreamController } from './livestream.controller'; +import { LivestreamInfo, LivestreamService } from './livestream.service'; + +const info = (overrides: Partial = {}): LivestreamInfo => ({ + channel: 'home-assistant', + channelName: 'Home Assistant', + status: 'none', + updatedAt: '2026-07-27T12:00:00.000Z', + ...overrides, +}); + +describe('LivestreamController', () => { + let controller: LivestreamController; + let service: { getAll: jest.Mock; getStatus: jest.Mock }; + + beforeEach(async () => { + service = { getAll: jest.fn(), getStatus: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [LivestreamController], + providers: [{ provide: LivestreamService, useValue: service }], + }).compile(); + + controller = module.get(LivestreamController); + }); + + describe('getAll', () => { + it('returns the full list of channel statuses from the service', () => { + const all = [ + info(), + info({ channel: 'esphome', channelName: 'ESPHome', status: 'live' }), + ]; + service.getAll.mockReturnValue(all); + + expect(controller.getAll()).toEqual(all); + }); + + it('asks the service exactly once and passes no arguments', () => { + service.getAll.mockReturnValue([]); + + controller.getAll(); + + expect(service.getAll).toHaveBeenCalledTimes(1); + expect(service.getAll).toHaveBeenCalledWith(); + }); + + it('hands back the service array untouched, without copying or reshaping it', () => { + const all = [info()]; + service.getAll.mockReturnValue(all); + + const result = controller.getAll(); + + expect(result).toBe(all); + expect(result[0]).toBe(all[0]); + }); + + it('returns an empty list when the service has no channels to report', () => { + service.getAll.mockReturnValue([]); + + expect(controller.getAll()).toEqual([]); + }); + }); + + describe('getStatus', () => { + it('returns the status the service reports for the requested channel', () => { + const status = info({ + status: 'live', + title: 'Release Party', + url: 'https://www.youtube.com/watch?v=abc123', + }); + service.getStatus.mockReturnValue(status); + + expect(controller.getStatus('home-assistant')).toEqual(status); + }); + + it('forwards the slug to the service verbatim', () => { + service.getStatus.mockReturnValue(info()); + + controller.getStatus('open-home-foundation'); + + expect(service.getStatus).toHaveBeenCalledTimes(1); + expect(service.getStatus).toHaveBeenCalledWith('open-home-foundation'); + }); + + it('does not sanitise or normalise the slug before delegating', () => { + service.getStatus.mockReturnValue(info()); + + // Slug validation belongs to the service, which owns the channel list. + controller.getStatus(' Home-Assistant '); + + expect(service.getStatus).toHaveBeenCalledWith(' Home-Assistant '); + }); + + it('hands back the service object untouched, without copying or reshaping it', () => { + const status = info({ + status: 'upcoming', + startTime: '2026-08-01T17:00:00.000Z', + }); + service.getStatus.mockReturnValue(status); + + const result = controller.getStatus('home-assistant'); + + expect(result).toBe(status); + expect(Object.keys(result)).toEqual(Object.keys(status)); + }); + + it('propagates the NotFoundException raised for an unknown slug', () => { + service.getStatus.mockImplementation(() => { + throw new NotFoundException('Unknown channel "nope"'); + }); + + expect(() => controller.getStatus('nope')).toThrow(NotFoundException); + expect(() => controller.getStatus('nope')).toThrow( + 'Unknown channel "nope"', + ); + }); + + it('propagates non-HTTP service errors unchanged', () => { + service.getStatus.mockImplementation(() => { + throw new Error('boom'); + }); + + expect(() => controller.getStatus('home-assistant')).toThrow('boom'); + }); + }); +}); diff --git a/src/livestream/livestream.controller.ts b/src/livestream/livestream.controller.ts new file mode 100644 index 0000000..fa9c08a --- /dev/null +++ b/src/livestream/livestream.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; + +import { LivestreamInfoResponse } from './livestream.response'; +import { LivestreamService } from './livestream.service'; + +@ApiTags('livestream') +@Controller('livestream') +export class LivestreamController { + constructor(private readonly livestream: LivestreamService) {} + + @Get() + @ApiOperation({ + summary: 'List the livestream status of every tracked channel', + description: + 'One entry per configured channel, in configuration order. Channels with ' + + 'nothing to report are still listed, with status "none".', + }) + @ApiOkResponse({ + description: 'The current status of every tracked channel.', + type: LivestreamInfoResponse, + isArray: true, + }) + getAll(): LivestreamInfoResponse[] { + return this.livestream.getAll(); + } + + @Get(':slug') + @ApiOperation({ + summary: "Get one channel's livestream status", + description: + "Reports the channel's live stream, else its soonest upcoming one, else " + + 'the most recent one that ended within the last 24 hours.', + }) + @ApiParam({ + name: 'slug', + description: + 'Slug of a configured channel, as served in the "channel" field.', + example: 'home-assistant', + }) + @ApiOkResponse({ + description: "The channel's current status.", + type: LivestreamInfoResponse, + }) + @ApiNotFoundResponse({ + description: 'No channel is configured with that slug.', + }) + getStatus(@Param('slug') slug: string): LivestreamInfoResponse { + return this.livestream.getStatus(slug); + } +} diff --git a/src/livestream/livestream.module.ts b/src/livestream/livestream.module.ts new file mode 100644 index 0000000..ed4280a --- /dev/null +++ b/src/livestream/livestream.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { LIVESTREAM_CHANNELS, parseChannels } from './livestream.channels'; +import { LivestreamController } from './livestream.controller'; +import { LivestreamService } from './livestream.service'; + +@Module({ + controllers: [LivestreamController], + providers: [ + { + provide: LIVESTREAM_CHANNELS, + inject: [ConfigService], + // Parsed once at startup, so malformed config fails the boot rather than + // every request. + useFactory: (config: ConfigService) => + parseChannels(config.get('LIVESTREAM_CHANNELS')), + }, + LivestreamService, + ], +}) +export class LivestreamModule {} diff --git a/src/livestream/livestream.response.ts b/src/livestream/livestream.response.ts new file mode 100644 index 0000000..d0c044e --- /dev/null +++ b/src/livestream/livestream.response.ts @@ -0,0 +1,92 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { LivestreamInfo, LivestreamStatus } from './livestream.service'; + +/** + * Every member of the status union, with what it means. Typed as a Record so a + * new LivestreamStatus fails to compile until it is documented here. + */ +const STATUS_DESCRIPTIONS: Record = { + live: 'a stream is on air now', + upcoming: 'a stream is scheduled', + past: 'a stream ended within the last 24 hours', + none: 'nothing to report for this channel', +}; + +export const LIVESTREAM_STATUSES = Object.keys( + STATUS_DESCRIPTIONS, +) as LivestreamStatus[]; + +const STATUS_DESCRIPTION = `The channel's reportable state: ${Object.entries( + STATUS_DESCRIPTIONS, +) + .map(([status, meaning]) => `\`${status}\` — ${meaning}`) + .join('; ')}.`; + +/** + * The documented shape of a channel's livestream status. + * + * `LivestreamInfo` is an interface, and interfaces leave no runtime metadata for + * `@nestjs/swagger` to read, so the served schema lives here instead. The + * service keeps returning plain `LivestreamInfo` objects; the controller only + * declares this class as its response type, which is what puts the schema in the + * spec. Nothing is instantiated or copied at runtime. + * + * Drift is a compile error in both directions: `implements` requires every + * documented field to exist on `LivestreamInfo`, and the controller assigning a + * `LivestreamInfo` to this type requires every field here to be one the service + * actually serves. + */ +export class LivestreamInfoResponse implements LivestreamInfo { + @ApiProperty({ + description: 'Channel slug, e.g. "home-assistant".', + example: 'home-assistant', + }) + channel!: string; + + @ApiProperty({ + description: 'Human-friendly channel name, as YouTube reports it.', + example: 'Home Assistant', + }) + channelName!: string; + + @ApiProperty({ + description: STATUS_DESCRIPTION, + enum: LIVESTREAM_STATUSES, + example: 'live', + }) + status!: LivestreamStatus; + + @ApiProperty({ + description: 'Title of the reported stream. Absent when status is "none".', + example: 'Home Assistant 2026.8 Release Party', + required: false, + }) + title?: string; + + @ApiProperty({ + description: + 'Watch URL of the reported stream. Absent when status is "none".', + example: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + required: false, + }) + url?: string; + + @ApiProperty({ + description: + 'ISO 8601 scheduled start time; present for "upcoming" streams and ' + + 'retained for "live" ones that were scheduled ahead of time.', + example: '2026-08-01T17:00:00.000Z', + format: 'date-time', + required: false, + }) + startTime?: string; + + @ApiProperty({ + description: + "ISO 8601 timestamp of when this channel's reported state last changed.", + example: '2026-07-27T12:00:00.000Z', + format: 'date-time', + }) + updatedAt!: string; +} diff --git a/src/livestream/livestream.service.spec.ts b/src/livestream/livestream.service.spec.ts new file mode 100644 index 0000000..6d131c5 --- /dev/null +++ b/src/livestream/livestream.service.spec.ts @@ -0,0 +1,1036 @@ +import { Logger, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { Channel, feedUrl } from './livestream.channels'; +import { LivestreamService } from './livestream.service'; + +const TICK_MS = 10_000; +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +/** The service's discovery (feed re-scan) interval. */ +const DISCOVERY_MS = 5 * MINUTE_MS; + +/** + * The channel list is configuration now, injected into the service, so these + * tests own their own fixture instead of depending on whatever is deployed. + * Display names are deliberately absent: they come from the feed, not config. + */ +const CHANNELS: Channel[] = [ + { slug: 'home-assistant', handle: 'HomeAssistant' }, + { slug: 'esphome', handle: 'ESPHome' }, + { slug: 'music-assistant', handle: 'MusicAssistant' }, + { slug: 'open-home-foundation', handle: 'OpenHomeFoundation' }, +]; + +/** + * The channel-level each fake feed serves — the only source of the + * display names the service reports. + */ +const FEED_TITLES: Record<string, string> = { + 'home-assistant': 'Home Assistant', + esphome: 'ESPHome', + 'music-assistant': 'Music Assistant', + 'open-home-foundation': 'Open Home Foundation', +}; + +const CHANNEL_ID_PREFIX = 'UC-'; +const channelIdOf = (slug: string): string => `${CHANNEL_ID_PREFIX}${slug}`; + +const slugByHandle = new Map(CHANNELS.map((c) => [c.handle, c.slug])); +const slugByChannelId = new Map( + CHANNELS.map((c) => [channelIdOf(c.slug), c.slug]), +); + +interface LiveStreamingDetails { + scheduledStartTime?: string; + actualStartTime?: string; + actualEndTime?: string; +} + +interface FakeVideo { + id: string; + slug: string; + title: string; + liveStreamingDetails?: LiveStreamingDetails; +} + +/** One feed entry. The service fingerprints the feed as `videoId@updated`. */ +interface FakeFeedEntry { + id: string; + updated: string; +} + +const json = (payload: unknown): Response => + new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + +/** + * A channel feed. The channel's own <title> precedes the entries, mirroring + * YouTube; each entry then carries the *video's* title under the same tag. + */ +const atomFeed = (title: string, entries: FakeFeedEntry[]): string => + [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" xmlns="http://www.w3.org/2005/Atom">', + `<id>yt:channel:fake</id><title>${title}`, + ...entries.map( + ({ id, updated }) => + `${id}entry ${id}` + + `${updated}`, + ), + '', + ].join(''); + +/** + * Mutable stand-in for the two YouTube surfaces the service talks to: the + * Data API (channels.list / videos.list) and the free per-channel RSS feed. + * Tests mutate it between time advances to simulate a stream going live/ending. + */ +class FakeYouTube { + /** Entries each channel's RSS feed advertises, newest-first order. */ + private readonly feeds = new Map(); + private readonly videos = new Map(); + private readonly brokenFeeds = new Set(); + /** Channel-level feed overrides, e.g. after a rebrand. */ + private readonly feedTitles = new Map<string, string>(); + /** Bodies to serve with a 200 instead of the channel's Atom feed. */ + private readonly notFeedBodies = new Map<string, string>(); + private videosListBroken = false; + /** Bumped per generated <updated> value so each one is distinct. */ + private revision = 0; + + addStream( + slug: string, + id: string, + opts: { title?: string } & LiveStreamingDetails, + ): void { + const { title, ...details } = opts; + this.add(slug, id, title ?? `stream ${id}`, details); + } + + /** A regular upload: present in the feed but with no liveStreamingDetails. */ + addUpload(slug: string, id: string, title?: string): void { + this.add(slug, id, title ?? `upload ${id}`, undefined); + } + + /** Announce a video in the feed more than once (YouTube occasionally does). */ + repeatInFeed(slug: string, id: string): void { + const entry = this.feedFor(slug).find((e) => e.id === id); + this.feedFor(slug).push({ + id, + updated: entry?.updated ?? this.nextUpdated(), + }); + } + + /** + * Bump an entry's Atom <updated>, the way YouTube does when a video's + * metadata changes — the only signal that a cached feed needs re-classifying. + */ + bumpFeedUpdated(id: string, updated?: string): void { + const entries = this.entriesFor(id); + if (entries.length === 0) { + throw new Error(`fake video "${id}" is not in any feed`); + } + const value = updated ?? this.nextUpdated(); + for (const entry of entries) { + entry.updated = value; + } + } + + /** Retitle a video without touching the feed, as a stealth edit would. */ + setTitle(id: string, title: string): void { + this.videoFor(id).title = title; + } + + markLive(id: string, actualStartTime: string): void { + this.detailsFor(id).actualStartTime = actualStartTime; + } + + /** + * Delete a video the way YouTube does: videos.list stops returning it (no + * error, the ID is simply absent) and it drops out of the channel feed. + */ + deleteVideo(id: string): void { + const video = this.videos.get(id); + this.videos.delete(id); + if (video) { + const feed = this.feedFor(video.slug); + feed.splice(0, feed.length, ...feed.filter((entry) => entry.id !== id)); + } + } + + markEnded(id: string, actualEndTime: string): void { + this.detailsFor(id).actualEndTime = actualEndTime; + } + + breakFeed(slug: string): void { + this.brokenFeeds.add(slug); + } + + /** Rename the channel, the way a rebrand shows up in its feed <title>. */ + setFeedTitle(slug: string, title: string): void { + this.feedTitles.set(slug, title); + } + + /** + * Answer the feed request with a 200 carrying something that is not an Atom + * feed — a captive portal, a proxy interstitial, a YouTube error page. + */ + serveNotAFeed(slug: string, body: string): void { + this.notFeedBodies.set(slug, body); + } + + /** Make videos.list fail outright, as opposed to omitting an ID. */ + breakVideosList(): void { + this.videosListBroken = true; + } + + repairVideosList(): void { + this.videosListBroken = false; + } + + /** + * Push a video out of the feed's most-recent window while videos.list still + * serves it — what happens when a channel publishes past it. + */ + removeFromFeed(id: string): void { + const video = this.videoFor(id); + const feed = this.feedFor(video.slug); + feed.splice(0, feed.length, ...feed.filter((entry) => entry.id !== id)); + } + + readonly fetch = async (input: unknown): Promise<Response> => { + const url = new URL(String(input)); + if (url.pathname.endsWith('/youtube/v3/channels')) { + return this.channelsList(url); + } + if (url.pathname.endsWith('/youtube/v3/videos')) { + return this.videosList(url); + } + if (url.pathname.endsWith('/feeds/videos.xml')) { + return this.feed(url); + } + throw new Error(`unexpected fetch: ${url.toString()}`); + }; + + private channelsList(url: URL): Response { + const slug = slugByHandle.get(url.searchParams.get('forHandle') ?? ''); + return json({ items: slug ? [{ id: channelIdOf(slug) }] : [] }); + } + + private videosList(url: URL): Response { + if (this.videosListBroken) { + return new Response('quota exceeded', { status: 403 }); + } + const ids = (url.searchParams.get('id') ?? '').split(','); + const items = ids + .map((id) => this.videos.get(id)) + .filter((video): video is FakeVideo => Boolean(video)) + .map((video) => ({ + id: video.id, + snippet: { title: video.title, channelId: channelIdOf(video.slug) }, + ...(video.liveStreamingDetails + ? { liveStreamingDetails: video.liveStreamingDetails } + : {}), + })); + return json({ items }); + } + + private feed(url: URL): Response { + const slug = slugByChannelId.get(url.searchParams.get('channel_id') ?? ''); + if (!slug) { + return new Response('not found', { status: 404 }); + } + if (this.brokenFeeds.has(slug)) { + return new Response('boom', { status: 500 }); + } + const notAFeed = this.notFeedBodies.get(slug); + if (notAFeed !== undefined) { + return new Response(notAFeed, { + status: 200, + headers: { 'content-type': 'text/html' }, + }); + } + return new Response(atomFeed(this.titleFor(slug), this.feedFor(slug)), { + status: 200, + headers: { 'content-type': 'application/atom+xml' }, + }); + } + + private titleFor(slug: string): string { + return this.feedTitles.get(slug) ?? FEED_TITLES[slug] ?? slug; + } + + private add( + slug: string, + id: string, + title: string, + details: LiveStreamingDetails | undefined, + ): void { + this.videos.set(id, { + id, + slug, + title, + liveStreamingDetails: details, + }); + this.feedFor(slug).push({ id, updated: this.nextUpdated() }); + } + + private feedFor(slug: string): FakeFeedEntry[] { + const entries = this.feeds.get(slug) ?? []; + this.feeds.set(slug, entries); + return entries; + } + + private entriesFor(id: string): FakeFeedEntry[] { + return [...this.feeds.values()].flat().filter((entry) => entry.id === id); + } + + private nextUpdated(): string { + return new Date(Date.now() + ++this.revision).toISOString(); + } + + private videoFor(id: string): FakeVideo { + const video = this.videos.get(id); + if (!video) { + throw new Error(`fake video "${id}" does not exist`); + } + return video; + } + + private detailsFor(id: string): LiveStreamingDetails { + const video = this.videos.get(id); + if (!video?.liveStreamingDetails) { + throw new Error(`fake video "${id}" is not a livestream`); + } + return video.liveStreamingDetails; + } +} + +describe('LivestreamService', () => { + let yt: FakeYouTube; + let fetchMock: jest.Mock; + let originalFetch: typeof globalThis.fetch; + let services: LivestreamService[]; + /** Frozen wall clock for the test; all expected times derive from it. */ + let now: number; + + const iso = (offsetMs = 0): string => new Date(now + offsetMs).toISOString(); + + const createService = ( + env: Record<string, string | undefined> = { YOUTUBE_API_KEY: 'test-key' }, + ): LivestreamService => { + const config = { get: (key: string) => env[key] } as ConfigService; + const service = new LivestreamService(config, CHANNELS); + services.push(service); + return service; + }; + + /** onModuleInit kicks off discovery without awaiting it; let it finish. */ + const settle = async (): Promise<void> => { + for (let i = 0; i < 20; i++) { + await jest.advanceTimersByTimeAsync(0); + } + }; + + const start = async ( + service: LivestreamService = createService(), + ): Promise<LivestreamService> => { + // Synchronous: it starts the timers and kicks off discovery without + // awaiting it, so settle() is what lets that first sweep finish. + service.onModuleInit(); + await settle(); + return service; + }; + + const callsTo = (path: string): unknown[][] => + fetchMock.mock.calls.filter((call) => String(call[0]).includes(path)); + + const videosListCalls = () => callsTo('/youtube/v3/videos'); + + const idsRequestedIn = (call: unknown[]): string[] => + (new URL(String(call[0])).searchParams.get('id') ?? '').split(','); + + beforeEach(() => { + jest.useFakeTimers(); + now = Date.now(); + yt = new FakeYouTube(); + fetchMock = jest.fn(yt.fetch); + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock; + services = []; + // The service logs expected failures (bad feed, missing key) — keep the + // test output readable without swallowing real errors. + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + for (const service of services) { + service.onModuleDestroy(); + } + globalThis.fetch = originalFetch; + jest.clearAllTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('lists every configured channel in order, defaulting to "none" before any data arrives', () => { + const service = createService(); + + const all = service.getAll(); + + expect(all).toHaveLength(CHANNELS.length); + expect(all.map((info) => info.channel)).toEqual( + CHANNELS.map((c) => c.slug), + ); + // No feed has been read yet, so the names are not known. + expect(all.map((info) => info.channelName)).toEqual( + CHANNELS.map((c) => c.slug), + ); + for (const info of all) { + expect(info.status).toBe('none'); + expect(info.updatedAt).toBe(iso()); + expect(info.title).toBeUndefined(); + expect(info.url).toBeUndefined(); + expect(info.startTime).toBeUndefined(); + } + }); + + it('reports a scheduled stream as "upcoming" with its title, watch URL and start time', async () => { + yt.addStream('home-assistant', 'vid-upcoming', { + title: 'Release Party 2026.8', + scheduledStartTime: iso(2 * HOUR_MS), + }); + + const service = await start(); + + expect(service.getStatus('home-assistant')).toEqual({ + channel: 'home-assistant', + channelName: 'Home Assistant', + status: 'upcoming', + title: 'Release Party 2026.8', + url: 'https://www.youtube.com/watch?v=vid-upcoming', + startTime: iso(2 * HOUR_MS), + updatedAt: iso(), + }); + }); + + it('ignores a regular upload that is not a livestream', async () => { + yt.addUpload('esphome', 'vid-upload', 'How to flash an ESP32'); + + const service = await start(); + + const info = service.getStatus('esphome'); + expect(info.status).toBe('none'); + expect(info.title).toBeUndefined(); + expect(info.url).toBeUndefined(); + }); + + it('does not report a stream that ended more than 24 hours ago', async () => { + yt.addStream('esphome', 'vid-ancient', { + scheduledStartTime: iso(-26 * HOUR_MS), + actualStartTime: iso(-26 * HOUR_MS), + actualEndTime: iso(-25 * HOUR_MS), + }); + + const service = await start(); + + expect(service.getStatus('esphome').status).toBe('none'); + }); + + it('reports a stream that ended within the last 24 hours as "past"', async () => { + yt.addStream('music-assistant', 'vid-recent', { + title: 'Music Assistant 3.0 launch', + scheduledStartTime: iso(-3 * HOUR_MS), + actualStartTime: iso(-3 * HOUR_MS), + actualEndTime: iso(-2 * HOUR_MS), + }); + + const service = await start(); + + const info = service.getStatus('music-assistant'); + expect(info.status).toBe('past'); + expect(info.title).toBe('Music Assistant 3.0 launch'); + expect(info.url).toBe('https://www.youtube.com/watch?v=vid-recent'); + }); + + it('flips an imminent "upcoming" stream to "live" on the next reconcile tick', async () => { + yt.addStream('home-assistant', 'vid-soon', { + title: 'Live Q&A', + scheduledStartTime: iso(5 * MINUTE_MS), + }); + const service = await start(); + expect(service.getStatus('home-assistant').status).toBe('upcoming'); + + yt.markLive('vid-soon', iso(5 * MINUTE_MS)); + await jest.advanceTimersByTimeAsync(TICK_MS); + + const info = service.getStatus('home-assistant'); + expect(info.status).toBe('live'); + expect(info.title).toBe('Live Q&A'); + expect(info.url).toBe('https://www.youtube.com/watch?v=vid-soon'); + // Retained across the transition: a client watching a stream go live must + // not see startTime appear and then vanish. + expect(info.startTime).toBe(iso(5 * MINUTE_MS)); + }); + + it('reports no start time for a stream that went live without being scheduled', async () => { + yt.addStream('home-assistant', 'vid-impromptu', { + title: 'Impromptu stream', + actualStartTime: iso(-2 * MINUTE_MS), + }); + + const service = await start(); + + const info = service.getStatus('home-assistant'); + expect(info.status).toBe('live'); + expect(info.title).toBe('Impromptu stream'); + expect(info.startTime).toBeUndefined(); + }); + + it('flips a "live" stream to "past" once it ends', async () => { + yt.addStream('home-assistant', 'vid-live', { + title: 'Ongoing stream', + scheduledStartTime: iso(-30 * MINUTE_MS), + actualStartTime: iso(-30 * MINUTE_MS), + }); + const service = await start(); + expect(service.getStatus('home-assistant').status).toBe('live'); + + yt.markEnded('vid-live', iso()); + await jest.advanceTimersByTimeAsync(TICK_MS); + + const info = service.getStatus('home-assistant'); + expect(info.status).toBe('past'); + expect(info.url).toBe('https://www.youtube.com/watch?v=vid-live'); + }); + + it('reports the earliest upcoming stream when several are scheduled', async () => { + yt.addStream('open-home-foundation', 'vid-later', { + title: 'Later', + scheduledStartTime: iso(4 * HOUR_MS), + }); + yt.addStream('open-home-foundation', 'vid-earliest', { + title: 'Earliest', + scheduledStartTime: iso(1 * HOUR_MS), + }); + yt.addStream('open-home-foundation', 'vid-middle', { + title: 'Middle', + scheduledStartTime: iso(2 * HOUR_MS), + }); + + const service = await start(); + + const info = service.getStatus('open-home-foundation'); + expect(info.status).toBe('upcoming'); + expect(info.title).toBe('Earliest'); + expect(info.startTime).toBe(iso(1 * HOUR_MS)); + }); + + it('prefers a live stream over an upcoming one on the same channel', async () => { + yt.addStream('home-assistant', 'vid-next-week', { + title: 'Next release party', + scheduledStartTime: iso(3 * HOUR_MS), + }); + yt.addStream('home-assistant', 'vid-now', { + title: 'Streaming right now', + scheduledStartTime: iso(-10 * MINUTE_MS), + actualStartTime: iso(-10 * MINUTE_MS), + }); + + const service = await start(); + + const info = service.getStatus('home-assistant'); + expect(info.status).toBe('live'); + expect(info.title).toBe('Streaming right now'); + expect(info.url).toBe('https://www.youtube.com/watch?v=vid-now'); + }); + + it('spends no videos.list quota on ticks while the only upcoming stream is hours away', async () => { + yt.addStream('home-assistant', 'vid-far', { + scheduledStartTime: iso(3 * HOUR_MS), + }); + await start(); + const afterDiscovery = videosListCalls().length; + expect(afterDiscovery).toBe(1); + + // Every tick reconciles, but a stream this far out is not selected as + // active, so no request goes out and no extra quota is spent. + await jest.advanceTimersByTimeAsync(9 * TICK_MS); + + expect(videosListCalls()).toHaveLength(afterDiscovery); + }); + + it('re-queries videos.list on every 10s tick while a stream is live', async () => { + yt.addStream('home-assistant', 'vid-live', { + scheduledStartTime: iso(-5 * MINUTE_MS), + actualStartTime: iso(-5 * MINUTE_MS), + }); + await start(); + const afterDiscovery = videosListCalls().length; + + await jest.advanceTimersByTimeAsync(3 * TICK_MS); + + const calls = videosListCalls(); + expect(calls).toHaveLength(afterDiscovery + 3); + expect(idsRequestedIn(calls[calls.length - 1])).toEqual(['vid-live']); + }); + + it('starts polling videos.list once an upcoming stream comes within 15 minutes', async () => { + yt.addStream('home-assistant', 'vid-nearly', { + scheduledStartTime: iso(16 * MINUTE_MS), + }); + await start(); + const afterDiscovery = videosListCalls().length; + + // Still outside the 15-minute window: no polling. + await jest.advanceTimersByTimeAsync(5 * TICK_MS); + expect(videosListCalls()).toHaveLength(afterDiscovery); + + // Now inside it: every tick re-queries the stream. + await jest.advanceTimersByTimeAsync(3 * TICK_MS); + expect(videosListCalls()).toHaveLength(afterDiscovery + 3); + }); + + it('stops all polling after onModuleDestroy', async () => { + yt.addStream('home-assistant', 'vid-live', { + scheduledStartTime: iso(-5 * MINUTE_MS), + actualStartTime: iso(-5 * MINUTE_MS), + }); + const service = await start(); + const callsBefore = fetchMock.mock.calls.length; + + service.onModuleDestroy(); + // Well past both the 10s reconcile tick and the 5min discovery interval. + await jest.advanceTimersByTimeAsync(20 * MINUTE_MS); + + expect(fetchMock.mock.calls).toHaveLength(callsBefore); + }); + + it('serves a deterministic "none" for a channel whose feed fails, without affecting the others', async () => { + yt.breakFeed('esphome'); + yt.addStream('home-assistant', 'vid-ok', { + title: 'Unaffected', + scheduledStartTime: iso(2 * HOUR_MS), + }); + + const service = await start(); + + const broken = service.getStatus('esphome'); + expect(broken).toEqual({ + channel: 'esphome', + // The name lives in the feed we could not read, so the slug stands in. + channelName: 'esphome', + status: 'none', + updatedAt: iso(), + }); + expect(service.getStatus('home-assistant')).toMatchObject({ + status: 'upcoming', + title: 'Unaffected', + }); + // The failure is contained: every channel still has an entry. + expect(service.getAll()).toHaveLength(CHANNELS.length); + }); + + it('survives a missing YOUTUBE_API_KEY and still serves every channel', async () => { + const service = createService({}); + + await expect(start(service)).resolves.toBe(service); + await jest.advanceTimersByTimeAsync(TICK_MS); + + const all = service.getAll(); + expect(all).toHaveLength(CHANNELS.length); + expect(all.every((info) => info.status === 'none')).toBe(true); + // Without a key the API call throws before any request goes out. + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('resolves a known slug and rejects an unknown one', async () => { + yt.addStream('home-assistant', 'vid-known', { + title: 'Known channel stream', + scheduledStartTime: iso(2 * HOUR_MS), + }); + const service = await start(); + + expect(service.getStatus('home-assistant')).toMatchObject({ + channel: 'home-assistant', + channelName: 'Home Assistant', + title: 'Known channel stream', + }); + expect(() => service.getStatus('nope')).toThrow(NotFoundException); + expect(() => service.getStatus('nope')).toThrow('Unknown channel "nope"'); + }); + + it('batches videos.list in chunks of 50 and requests each video ID once', async () => { + const ids = Array.from({ length: 55 }, (_, i) => `vid-${i}`); + for (const id of ids) { + yt.addUpload('home-assistant', id); + } + // The feed repeats a few entries; they must not cost extra quota. + yt.repeatInFeed('home-assistant', 'vid-0'); + yt.repeatInFeed('home-assistant', 'vid-1'); + + await start(); + + const calls = videosListCalls(); + expect(calls).toHaveLength(2); + const batches = calls.map(idsRequestedIn); + expect(batches.map((batch) => batch.length)).toEqual([50, 5]); + const requested = batches.flat(); + expect(requested).toHaveLength(new Set(requested).size); + expect(new Set(requested)).toEqual(new Set(ids)); + // Feeds are free, so exactly one feed request per channel is expected. + expect(callsTo('/feeds/videos.xml')).toHaveLength(CHANNELS.length); + for (const call of callsTo('/feeds/videos.xml')) { + expect(String(call[0]).startsWith(feedUrl(''))).toBe(true); + } + }); + + describe('channel display names', () => { + it.each([ + ['an apostrophe', 'Nabu Casa's Channel', "Nabu Casa's Channel"], + ['an ampersand', 'Home & Away', 'Home & Away'], + ['a quote', '"Smart" Home', '"Smart" Home'], + ['angle brackets', '<Home>', '<Home>'], + ['a hex character reference', 'Café Assistant', 'Café Assistant'], + ['an escaped entity', 'Ampersand &amp; Co', 'Ampersand & Co'], + ])( + 'decodes %s in a feed title', + async (_description, encoded, expected) => { + // Atom escapes markup, so the raw match would otherwise reach clients. + yt.setFeedTitle('esphome', encoded); + const service = await start(); + + expect(service.getStatus('esphome').channelName).toBe(expected); + }, + ); + + it("reports the channel-level <title> of the channel's own feed", async () => { + const service = await start(); + + const esphome = service.getStatus('esphome'); + expect(esphome.channelName).toBe('ESPHome'); + expect(esphome.channelName).not.toBe(esphome.channel); + expect(service.getAll().map((info) => info.channelName)).toEqual( + CHANNELS.map((c) => FEED_TITLES[c.slug]), + ); + }); + + it('falls back to the slug for every channel until the first discovery sweep lands', () => { + const service = createService(); + + const all = service.getAll(); + + expect(all.map((info) => info.channelName)).toEqual( + CHANNELS.map((c) => c.slug), + ); + // Never blank: the field is served from the very first request onwards. + expect(all.every((info) => Boolean(info.channelName))).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('keeps the slug for a channel whose feed fetch fails, leaving the other names intact', async () => { + yt.breakFeed('music-assistant'); + + const service = await start(); + + expect(service.getStatus('music-assistant').channelName).toBe( + 'music-assistant', + ); + expect(service.getStatus('home-assistant').channelName).toBe( + 'Home Assistant', + ); + expect(service.getStatus('esphome').channelName).toBe('ESPHome'); + expect(service.getStatus('open-home-foundation').channelName).toBe( + 'Open Home Foundation', + ); + }); + + it('picks up a rebrand on the next discovery sweep', async () => { + yt.addStream('esphome', 'vid-upcoming', { + title: 'ESPHome 2026.8', + scheduledStartTime: iso(4 * HOUR_MS), + }); + const service = await start(); + expect(service.getStatus('esphome').channelName).toBe('ESPHome'); + + // Only the name changes, so the feed fingerprint is unchanged and no + // re-classification happens — the new name must land regardless. + yt.setFeedTitle('esphome', 'ESPHome by Open Home Foundation'); + await jest.advanceTimersByTimeAsync(DISCOVERY_MS); + + expect(service.getStatus('esphome')).toMatchObject({ + channelName: 'ESPHome by Open Home Foundation', + status: 'upcoming', + title: 'ESPHome 2026.8', + }); + }); + + it('ignores the title of a 200 response that is not an Atom feed, and keeps the tracked stream', async () => { + yt.addStream('home-assistant', 'vid-upcoming', { + title: 'Release Party 2026.8', + scheduledStartTime: iso(4 * HOUR_MS), + }); + const service = await start(); + const discoveredAt = service.getStatus('home-assistant').updatedAt; + + yt.serveNotAFeed( + 'home-assistant', + '<!DOCTYPE html><html><head><title>Error 404 (Not Found)' + + 'Sorry, that page does not exist.', + ); + await jest.advanceTimersByTimeAsync(DISCOVERY_MS); + + // A 200 carrying an error page is a failed fetch, not an empty channel: + // its is not the channel's name and nothing gets evicted. + expect(service.getStatus('home-assistant')).toEqual({ + channel: 'home-assistant', + channelName: 'Home Assistant', + status: 'upcoming', + title: 'Release Party 2026.8', + url: 'https://www.youtube.com/watch?v=vid-upcoming', + startTime: iso(4 * HOUR_MS), + updatedAt: discoveredAt, + }); + }); + }); + + describe('the feed fingerprint cache', () => { + it('spends no extra videos.list quota on a sweep over an unchanged feed', async () => { + yt.addStream('home-assistant', 'vid-upcoming', { + scheduledStartTime: iso(4 * HOUR_MS), + }); + await start(); + const classifications = videosListCalls().length; + const feedFetches = callsTo('/feeds/videos.xml').length; + expect(classifications).toBe(1); + + await jest.advanceTimersByTimeAsync(DISCOVERY_MS); + + expect(videosListCalls()).toHaveLength(classifications); + // The feed itself costs no quota, so it is still re-fetched every sweep. + expect(callsTo('/feeds/videos.xml')).toHaveLength( + feedFetches + CHANNELS.length, + ); + }); + + it('re-classifies when a new entry appears in the feed', async () => { + yt.addStream('home-assistant', 'vid-first', { + title: 'Already known', + scheduledStartTime: iso(4 * HOUR_MS), + }); + const service = await start(); + const classifications = videosListCalls().length; + + yt.addStream('home-assistant', 'vid-sooner', { + title: 'Newly scheduled', + scheduledStartTime: iso(1 * HOUR_MS), + }); + await jest.advanceTimersByTimeAsync(DISCOVERY_MS); + + expect(videosListCalls()).toHaveLength(classifications + 1); + expect(service.getStatus('home-assistant')).toMatchObject({ + status: 'upcoming', + title: 'Newly scheduled', + startTime: iso(1 * HOUR_MS), + }); + }); + + it("re-classifies when an entry's <updated> changes, catching a retitled stream", async () => { + yt.addStream('home-assistant', 'vid-upcoming', { + title: 'Release Party 2026.8', + scheduledStartTime: iso(4 * HOUR_MS), + }); + const service = await start(); + const classifications = videosListCalls().length; + + // A retitle alone is invisible to us: the feed fingerprint is unchanged, + // so the sweep is skipped and the stale title stands. + yt.setTitle('vid-upcoming', 'Release Party 2026.9'); + await jest.advanceTimersByTimeAsync(DISCOVERY_MS); + expect(videosListCalls()).toHaveLength(classifications); + expect(service.getStatus('home-assistant').title).toBe( + 'Release Party 2026.8', + ); + + // YouTube bumps <updated> for that edit, which is what we key on. + yt.bumpFeedUpdated('vid-upcoming'); + await jest.advanceTimersByTimeAsync(DISCOVERY_MS); + + expect(videosListCalls()).toHaveLength(classifications + 1); + expect(service.getStatus('home-assistant').title).toBe( + 'Release Party 2026.9', + ); + }); + + it('retries classification on the next sweep when videos.list failed', async () => { + yt.addStream('home-assistant', 'vid-upcoming', { + title: 'Release Party 2026.8', + scheduledStartTime: iso(4 * HOUR_MS), + }); + yt.breakVideosList(); + + const service = await start(); + const failedAttempts = videosListCalls().length; + expect(failedAttempts).toBe(1); + expect(service.getStatus('home-assistant').status).toBe('none'); + + // A failed classification must not be cached as "this feed is done". + yt.repairVideosList(); + await jest.advanceTimersByTimeAsync(DISCOVERY_MS); + + expect(videosListCalls()).toHaveLength(failedAttempts + 1); + expect(service.getStatus('home-assistant')).toMatchObject({ + status: 'upcoming', + title: 'Release Party 2026.8', + }); + }); + }); + + describe('updatedAt', () => { + it('does not advance while a live stream keeps reporting the same state', async () => { + yt.addStream('home-assistant', 'vid-live', { + title: 'Ongoing stream', + scheduledStartTime: iso(-30 * MINUTE_MS), + actualStartTime: iso(-30 * MINUTE_MS), + }); + const service = await start(); + const wentLiveAt = service.getStatus('home-assistant').updatedAt; + expect(wentLiveAt).toBe(iso()); + + // Each of these ticks re-queries the live stream and re-derives state. + await jest.advanceTimersByTimeAsync(6 * TICK_MS); + + expect(service.getStatus('home-assistant').updatedAt).toBe(wentLiveAt); + }); + + it('advances when the reported status changes', async () => { + yt.addStream('home-assistant', 'vid-soon', { + scheduledStartTime: iso(5 * MINUTE_MS), + }); + const service = await start(); + expect(service.getStatus('home-assistant').updatedAt).toBe(iso()); + + yt.markLive('vid-soon', iso(5 * MINUTE_MS)); + await jest.advanceTimersByTimeAsync(TICK_MS); + + const info = service.getStatus('home-assistant'); + expect(info.status).toBe('live'); + expect(info.updatedAt).toBe(iso(TICK_MS)); + }); + + it('stays stable across a discovery sweep that changes nothing', async () => { + yt.addStream('home-assistant', 'vid-upcoming', { + scheduledStartTime: iso(4 * HOUR_MS), + }); + const service = await start(); + const discoveredAt = service.getStatus('home-assistant').updatedAt; + + await jest.advanceTimersByTimeAsync(DISCOVERY_MS); + + expect(service.getStatus('home-assistant').updatedAt).toBe(discoveredAt); + }); + }); + + describe('a video that disappears from videos.list', () => { + /** Deleted, privated or made members-only: the API just omits the ID. */ + it('untracks a live stream that vanishes, clearing the channel status', async () => { + yt.addStream('home-assistant', 'vid-live', { + scheduledStartTime: iso(-5 * MINUTE_MS), + actualStartTime: iso(-5 * MINUTE_MS), + }); + const service = await start(); + expect(service.getStatus('home-assistant').status).toBe('live'); + + yt.deleteVideo('vid-live'); + await jest.advanceTimersByTimeAsync(TICK_MS); + + expect(service.getStatus('home-assistant').status).toBe('none'); + }); + + it('releases the fast poll cadence instead of polling forever', async () => { + yt.addStream('home-assistant', 'vid-live', { + scheduledStartTime: iso(-5 * MINUTE_MS), + actualStartTime: iso(-5 * MINUTE_MS), + }); + await start(); + + yt.deleteVideo('vid-live'); + // The tick that discovers the video is gone still spends one lookup. + await jest.advanceTimersByTimeAsync(TICK_MS); + const afterUntracking = videosListCalls().length; + + // With nothing tracked, later ticks must cost no quota at all. + await jest.advanceTimersByTimeAsync(12 * TICK_MS); + + expect(videosListCalls()).toHaveLength(afterUntracking); + }); + + it('falls back to another stream on the same channel', async () => { + yt.addStream('home-assistant', 'vid-live', { + scheduledStartTime: iso(-5 * MINUTE_MS), + actualStartTime: iso(-5 * MINUTE_MS), + }); + yt.addStream('home-assistant', 'vid-next', { + title: 'Next release party', + scheduledStartTime: iso(3 * HOUR_MS), + }); + const service = await start(); + expect(service.getStatus('home-assistant').status).toBe('live'); + + yt.deleteVideo('vid-live'); + await jest.advanceTimersByTimeAsync(TICK_MS); + + const info = service.getStatus('home-assistant'); + expect(info.status).toBe('upcoming'); + expect(info.title).toBe('Next release party'); + }); + + it('untracks a far-future upcoming stream on the next discovery sweep', async () => { + // Too far out for the reconcile poller to look at, so only discovery + // notices it is gone. + yt.addStream('esphome', 'vid-later', { + scheduledStartTime: iso(4 * HOUR_MS), + }); + const service = await start(); + expect(service.getStatus('esphome').status).toBe('upcoming'); + + yt.deleteVideo('vid-later'); + await jest.advanceTimersByTimeAsync(5 * MINUTE_MS); + + expect(service.getStatus('esphome').status).toBe('none'); + }); + + it('keeps a live stream that merely fell out of the feed window', async () => { + // Only "upcoming" is evicted for leaving the feed; a live stream is the + // reconcile poller's to own, since the API is authoritative about it. + yt.addStream('home-assistant', 'vid-live', { + scheduledStartTime: iso(-5 * MINUTE_MS), + actualStartTime: iso(-5 * MINUTE_MS), + }); + const service = await start(); + + yt.removeFromFeed('vid-live'); + await jest.advanceTimersByTimeAsync(5 * MINUTE_MS); + + expect(service.getStatus('home-assistant').status).toBe('live'); + }); + + it('keeps tracked state when the lookup fails rather than evicting on an error', async () => { + yt.addStream('home-assistant', 'vid-live', { + scheduledStartTime: iso(-5 * MINUTE_MS), + actualStartTime: iso(-5 * MINUTE_MS), + }); + const service = await start(); + + // A failing request must never be read as "the video is gone". + yt.breakVideosList(); + await jest.advanceTimersByTimeAsync(TICK_MS); + + expect(service.getStatus('home-assistant').status).toBe('live'); + }); + }); +}); diff --git a/src/livestream/livestream.service.ts b/src/livestream/livestream.service.ts new file mode 100644 index 0000000..33d0417 --- /dev/null +++ b/src/livestream/livestream.service.ts @@ -0,0 +1,673 @@ +import { + Inject, + Injectable, + Logger, + NotFoundException, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { Channel, LIVESTREAM_CHANNELS, feedUrl } from './livestream.channels'; + +export type LivestreamStatus = 'live' | 'upcoming' | 'past' | 'none'; + +export interface LivestreamInfo { + /** Channel slug, e.g. "home-assistant". */ + channel: string; + /** Human-friendly channel name. */ + channelName: string; + status: LivestreamStatus; + title?: string; + url?: string; + /** + * ISO 8601 scheduled start time; present for "upcoming" streams and retained + * for "live" ones that were scheduled ahead of time. + */ + startTime?: string; + /** ISO 8601 timestamp of when this channel's reported state last changed. */ + updatedAt: string; +} + +/** One entry from a channel's RSS feed. */ +interface FeedEntry { + videoId: string; + /** Atom <updated>; YouTube bumps it when a video's metadata changes. */ + updated: string; +} + +/** A channel's RSS feed: its display name plus its recent entries. */ +interface Feed { + /** Channel-level <title>, i.e. the channel's display name. */ + title: string; + entries: FeedEntry[]; +} + +/** + * The parts of a YouTube Data API list item this service reads. + * + * Only the fields we request a `part` for are modelled, and everything but `id` + * is optional: a video that is not a broadcast has no `liveStreamingDetails`, + * and a broadcast carries only the timestamps its lifecycle has reached. + */ +interface YouTubeItem { + id: string; + snippet?: { + title?: string; + channelId?: string; + }; + liveStreamingDetails?: { + scheduledStartTime?: string; + actualStartTime?: string; + actualEndTime?: string; + }; +} + +/** A YouTube Data API list response, pared down to what we read. */ +interface YouTubeListResponse { + items?: YouTubeItem[]; +} + +/** A livestream video we are tracking the state of. */ +interface TrackedVideo { + videoId: string; + title: string; + status: LivestreamStatus; + scheduledStartTime?: string; + actualEndTime?: string; +} + +const API_BASE = 'https://www.googleapis.com/youtube/v3'; +const PAST_WINDOW_MS = 24 * 60 * 60 * 1000; +/** Re-scan channels' RSS feeds to discover newly scheduled/published videos. */ +const DISCOVERY_INTERVAL_MS = 5 * 60 * 1000; +/** + * Transition polling cadence. Every tick re-queries only the videos that are + * live or imminent, so a tick with nothing active issues no requests at all and + * idle channels cost no quota. + */ +const RECONCILE_TICK_MS = 10 * 1000; +/** Treat an upcoming stream as "imminent" within this window of its start. */ +const SOON_WINDOW_MS = 15 * 60 * 1000; + +const watchUrl = (videoId: string) => + `https://www.youtube.com/watch?v=${videoId}`; + +const entryPattern = /<entry>([\s\S]*?)<\/entry>/g; +const videoIdPattern = /<yt:videoId>([^<]+)<\/yt:videoId>/; +const updatedPattern = /<updated>([^<]+)<\/updated>/; +const titlePattern = /<title>([^<]*)<\/title>/; + +const XML_ENTITIES: Record<string, string> = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", +}; + +/** + * Decode the XML entities an Atom element may contain, so a channel named + * "Nabu Casa's" is not served as "Nabu Casa's". Resolved in a single pass so + * an escaped entity ("&amp;") decodes to "&" rather than "&". + */ +const decodeXmlText = (value: string): string => + value.replace( + /&(#\d+|#[xX][0-9a-fA-F]+|[a-zA-Z]+);/g, + (match, ref: string) => { + if (!ref.startsWith('#')) { + return XML_ENTITIES[ref.toLowerCase()] ?? match; + } + const code = + ref[1] === 'x' || ref[1] === 'X' + ? parseInt(ref.slice(2), 16) + : parseInt(ref.slice(1), 10); + return Number.isInteger(code) && code >= 0 && code <= 0x10ffff + ? String.fromCodePoint(code) + : match; + }, + ); + +const stackOf = (err: unknown): string => + err instanceof Error ? (err.stack ?? err.message) : String(err); + +@Injectable() +export class LivestreamService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(LivestreamService.name); + private readonly channelsBySlug: Map<string, Channel>; + /** Derived, ready-to-serve status per channel slug. */ + private readonly state = new Map<string, LivestreamInfo>(); + /** Tracked livestream videos per channel slug. */ + private readonly tracked = new Map<string, Map<string, TrackedVideo>>(); + private readonly channelIds = new Map<string, string>(); + private readonly slugByChannelId = new Map<string, string>(); + /** In-flight channel-ID resolutions, to de-duplicate concurrent lookups. */ + private readonly channelIdPromises = new Map<string, Promise<string>>(); + /** Last seen feed fingerprint per slug, to skip redundant videos.list calls. */ + private readonly feedFingerprints = new Map<string, string>(); + /** Display name per slug, read from each channel's feed title. */ + private readonly channelNames = new Map<string, string>(); + /** Stable timestamp used for channels that have no state yet. */ + private readonly startedAt = new Date().toISOString(); + private reconcileTimer?: ReturnType<typeof setInterval>; + private discoveryTimer?: ReturnType<typeof setInterval>; + private discoveryRunning = false; + private reconcileRunning = false; + + constructor( + private readonly config: ConfigService, + @Inject(LIVESTREAM_CHANNELS) private readonly channels: readonly Channel[], + ) { + this.channelsBySlug = new Map(this.channels.map((c) => [c.slug, c])); + } + + onModuleInit(): void { + // Seed initial state from each channel's (free) RSS feed so the API is not + // blank until the first scheduled discovery sweep. Deliberately not awaited: + // startup must not block on YouTube being reachable. + void this.discovery(); + this.discoveryTimer = setInterval( + () => void this.discovery(), + DISCOVERY_INTERVAL_MS, + ); + this.reconcileTimer = setInterval( + () => void this.tick(), + RECONCILE_TICK_MS, + ); + } + + onModuleDestroy(): void { + if (this.discoveryTimer) { + clearInterval(this.discoveryTimer); + } + if (this.reconcileTimer) { + clearInterval(this.reconcileTimer); + } + } + + getAll(): LivestreamInfo[] { + return this.channels.map((channel) => this.readState(channel)); + } + + getStatus(slug: string): LivestreamInfo { + const channel = this.channelsBySlug.get(slug); + if (!channel) { + throw new NotFoundException(`Unknown channel "${slug}"`); + } + return this.readState(channel); + } + + /** Resolve a channel's YouTube channel ID (UC…), caching the result. */ + private async resolveChannelId(channel: Channel): Promise<string> { + const cached = this.channelIds.get(channel.slug); + if (cached) { + return cached; + } + // De-duplicate concurrent resolutions across a discovery sweep so they + // share a single channels.list request. + let pending = this.channelIdPromises.get(channel.slug); + if (!pending) { + pending = this.fetchChannelId(channel).finally(() => + this.channelIdPromises.delete(channel.slug), + ); + this.channelIdPromises.set(channel.slug, pending); + } + return pending; + } + + private async fetchChannelId(channel: Channel): Promise<string> { + const data = await this.apiGet('channels', { + part: 'id', + forHandle: channel.handle, + }); + const id: string | undefined = data.items?.[0]?.id; + if (!id) { + throw new Error(`Channel not found for handle @${channel.handle}`); + } + this.channelIds.set(channel.slug, id); + this.slugByChannelId.set(id, channel.slug); + return id; + } + + private readState(channel: Channel): LivestreamInfo { + return this.state.get(channel.slug) ?? this.defaultInfo(channel); + } + + private defaultInfo(channel: Channel): LivestreamInfo { + return { + channel: channel.slug, + channelName: this.displayName(channel), + status: 'none', + updatedAt: this.startedAt, + }; + } + + /** + * The channel's display name: its YouTube feed title once known, falling back + * to the slug so the field is always populated — including on the very first + * request, before the initial discovery sweep has landed. + */ + private displayName(channel: Channel): string { + return this.channelNames.get(channel.slug) ?? channel.slug; + } + + /** + * Discover videos from every channel's (free) RSS feed and classify them + * with a cheap videos.list lookup. Runs on startup and on a timer; this is + * the only way newly scheduled or published streams enter our state. + */ + private async discovery(): Promise<void> { + if (this.discoveryRunning) { + return; + } + this.discoveryRunning = true; + try { + await Promise.all( + this.channels.map((channel) => + this.discoverChannel(channel).catch((err) => { + this.logger.error( + `Discovery failed for ${channel.slug}`, + stackOf(err), + ); + // Ensure the channel still has a deterministic state entry so the + // API doesn't fall back to a fresh defaultInfo on every request. + if (!this.state.has(channel.slug)) { + this.state.set(channel.slug, this.defaultInfo(channel)); + } + }), + ), + ); + } finally { + this.discoveryRunning = false; + } + } + + private async discoverChannel(channel: Channel): Promise<void> { + const channelId = await this.resolveChannelId(channel); + const { title, entries } = await this.fetchFeed(channelId); + + // Display names come from the channel itself rather than config, so they + // stay right through a rebrand without a deploy. + if (title) { + this.channelNames.set(channel.slug, title); + } + + // The feed is free but videos.list is not, so only re-classify when the + // feed actually changed. YouTube bumps an entry's <updated> when its + // metadata changes, so this still catches retitled or rescheduled streams. + const fingerprint = entries + .map((entry) => `${entry.videoId}@${entry.updated}`) + .join(','); + if (fingerprint !== this.feedFingerprints.get(channel.slug)) { + const videoIds = [...new Set(entries.map((entry) => entry.videoId))]; + if (videoIds.length > 0) { + await this.refresh(new Map(videoIds.map((id) => [id, channel.slug]))); + } + this.forgetUnlistedUpcoming(channel.slug, new Set(videoIds)); + // Recorded only after a successful sweep: a thrown lookup must not leave + // us believing this feed is already classified. + this.feedFingerprints.set(channel.slug, fingerprint); + } + + this.recompute(channel.slug); + } + + /** + * Drop "upcoming" streams the channel feed no longer advertises. A scheduled + * stream that is deleted or unscheduled simply disappears from the feed, so + * videos.list is never asked about it again and refresh() cannot notice — + * without this the API would advertise a stream that no longer exists + * indefinitely. + * + * Deliberately limited to "upcoming". A live stream belongs to reconcile, + * which re-queries the API and is authoritative about it, and a past one ages + * out via pruneExpired; neither should be forgotten merely for falling out of + * the feed's most-recent-entries window. Reached only after a successful feed + * fetch, since a failure throws out of fetchFeed first. + */ + private forgetUnlistedUpcoming(slug: string, listed: Set<string>): void { + const videos = this.tracked.get(slug); + if (!videos) { + return; + } + for (const [videoId, v] of videos) { + if (v.status === 'upcoming' && !listed.has(videoId)) { + videos.delete(videoId); + this.logger.log(`Untracked ${videoId}: no longer listed in the feed`); + } + } + } + + /** + * Classify a batch of video IDs (keyed to the channel that owns them) and + * update their tracked state. + * + * videos.list silently omits IDs it will not serve — deleted, privated or + * made members-only — so anything we asked about and did not get back is + * gone and must be untracked. Skipping that leaves a stream pinned at its + * last known status forever: a "live" one would keep its channel reporting + * live and hold the fast reconcile cadence open indefinitely. + * + * Untracking is only safe because the caller reaches here on a successful + * response; a failed request throws out of videoDetails instead, leaving + * tracked state untouched rather than wrongly evicting live streams. + * + * Returns the slugs whose tracked videos changed. + */ + private async refresh( + slugByVideoId: Map<string, string>, + ): Promise<Set<string>> { + const touched = new Set<string>(); + const unreturned = new Map(slugByVideoId); + const items = await this.videoDetails([...slugByVideoId.keys()]); + + for (const item of items) { + unreturned.delete(item.id); + // Prefer the caller's mapping; fall back to the channel the API reports + // for IDs we did not ask about by channel. + const channelId = item.snippet?.channelId; + const slug = + slugByVideoId.get(item.id) ?? + (channelId ? this.slugByChannelId.get(channelId) : undefined); + if (slug) { + this.track(slug, item); + touched.add(slug); + } + } + + for (const [videoId, slug] of unreturned) { + if (this.tracked.get(slug)?.delete(videoId)) { + this.logger.log( + `Untracked ${videoId}: no longer served by videos.list`, + ); + touched.add(slug); + } + } + + return touched; + } + + /** + * Transition poller. Cheap when nothing is happening: reconcile() selects + * only live or imminent videos, so an idle tick issues no requests and spends + * no quota. + */ + private async tick(): Promise<void> { + // Evict streams whose past-window has elapsed, even when nothing is active. + this.pruneExpired(); + if (this.reconcileRunning) { + return; + } + this.reconcileRunning = true; + try { + await this.reconcile(); + } finally { + this.reconcileRunning = false; + } + } + + /** Drop tracked "past" streams older than the window and recompute state. */ + private pruneExpired(): void { + const now = Date.now(); + for (const [slug, videos] of this.tracked) { + let changed = false; + for (const [videoId, v] of videos) { + if ( + v.status === 'past' && + v.actualEndTime && + now - Date.parse(v.actualEndTime) > PAST_WINDOW_MS + ) { + videos.delete(videoId); + changed = true; + } + } + if (changed) { + this.recompute(slug); + } + } + } + + /** Re-check tracked upcoming/live videos to catch live/ended transitions. */ + private async reconcile(): Promise<void> { + const now = Date.now(); + const active = new Map<string, string>(); + for (const [slug, videos] of this.tracked) { + for (const v of videos.values()) { + if ( + v.status === 'live' || + (v.status === 'upcoming' && + v.scheduledStartTime && + Date.parse(v.scheduledStartTime) - now <= SOON_WINDOW_MS) + ) { + active.set(v.videoId, slug); + } + } + } + if (active.size === 0) { + return; + } + try { + for (const slug of await this.refresh(active)) { + this.recompute(slug); + } + } catch (err) { + this.logger.warn(`Reconcile failed: ${stackOf(err)}`); + } + } + + /** Record (or drop) a video's livestream state from a YouTube API item. */ + private track(slug: string, item: YouTubeItem): void { + const videoId = item.id; + const details = item.liveStreamingDetails; + const videos = this.tracked.get(slug) ?? new Map<string, TrackedVideo>(); + this.tracked.set(slug, videos); + + // Not a livestream (regular upload) — ignore. + if (!details) { + videos.delete(videoId); + return; + } + + let status: LivestreamStatus; + if (details.actualEndTime) { + status = 'past'; + } else if (details.actualStartTime) { + status = 'live'; + } else if (details.scheduledStartTime) { + status = 'upcoming'; + } else { + status = 'none'; + } + + // Drop streams that ended more than the past-window ago. An actualEndTime + // is what made the status 'past', so testing it again only narrows the type. + if ( + details.actualEndTime && + Date.now() - Date.parse(details.actualEndTime) > PAST_WINDOW_MS + ) { + videos.delete(videoId); + return; + } + if (status === 'none') { + videos.delete(videoId); + return; + } + + videos.set(videoId, { + videoId, + title: item.snippet?.title ?? '', + status, + scheduledStartTime: details.scheduledStartTime, + actualEndTime: details.actualEndTime, + }); + } + + /** + * Recompute the derived channel status from its tracked videos. + * + * updatedAt reports when the served state last *changed*, so clients can use + * it for caching and change detection. Re-deriving identical state therefore + * keeps the previous timestamp — otherwise it would advance on every 10s tick + * for the whole duration of a live stream. + */ + private recompute(slug: string): void { + const channel = this.channelsBySlug.get(slug); + if (!channel) { + return; + } + + const next = this.derive(channel); + const previous = this.state.get(slug); + const changed = + !previous || + previous.status !== next.status || + previous.title !== next.title || + previous.url !== next.url || + previous.startTime !== next.startTime || + // A rebrand changes what we serve too, so it has to move the timestamp + // or clients caching on updatedAt would keep the old name indefinitely. + previous.channelName !== next.channelName; + + this.state.set(slug, { + ...next, + updatedAt: changed ? new Date().toISOString() : previous.updatedAt, + }); + } + + /** Pick the channel's reportable stream: live first, else soonest upcoming, else latest recent past. */ + private derive(channel: Channel): Omit<LivestreamInfo, 'updatedAt'> { + const base = { + channel: channel.slug, + channelName: this.displayName(channel), + }; + const videos = [...(this.tracked.get(channel.slug)?.values() ?? [])]; + const now = Date.now(); + + const live = videos.find((v) => v.status === 'live'); + if (live) { + return { + ...base, + status: 'live', + title: live.title, + url: watchUrl(live.videoId), + // Kept once the stream starts: a client watching the transition should + // not see startTime appear and then vanish. + startTime: live.scheduledStartTime, + }; + } + + const upcoming = videos + .filter((v) => v.status === 'upcoming' && v.scheduledStartTime) + .sort( + (a, b) => + Date.parse(a.scheduledStartTime!) - Date.parse(b.scheduledStartTime!), + )[0]; + if (upcoming) { + return { + ...base, + status: 'upcoming', + title: upcoming.title, + url: watchUrl(upcoming.videoId), + startTime: upcoming.scheduledStartTime, + }; + } + + const past = videos + .filter( + (v) => + v.status === 'past' && + v.actualEndTime && + now - Date.parse(v.actualEndTime) <= PAST_WINDOW_MS, + ) + .sort( + (a, b) => Date.parse(b.actualEndTime!) - Date.parse(a.actualEndTime!), + )[0]; + if (past) { + return { + ...base, + status: 'past', + title: past.title, + url: watchUrl(past.videoId), + }; + } + + return { ...base, status: 'none' }; + } + + /** Fetch a channel's RSS feed: title and recent entries (free, no quota). */ + private async fetchFeed(channelId: string): Promise<Feed> { + const res = await fetch(feedUrl(channelId), { + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) { + throw new Error(`Feed request failed: ${res.status}`); + } + const xml = await res.text(); + // A 200 carrying something other than the feed (an error page, a proxy + // interstitial) would otherwise read as "this channel has no videos" and + // evict tracked streams, and its <title> would be served as the channel + // name. Treat it as a failed fetch so prior state survives instead. + if (!xml.includes('<feed')) { + throw new Error('Feed response was not an Atom feed'); + } + + const entries: FeedEntry[] = []; + for (const [, entry] of xml.matchAll(entryPattern)) { + const videoId = videoIdPattern.exec(entry)?.[1]; + if (videoId) { + entries.push({ + videoId, + updated: updatedPattern.exec(entry)?.[1] ?? '', + }); + } + } + + // The channel's own <title> precedes the entries; an entry's <title> is the + // video's, so only look before the first one. + const [head] = xml.split('<entry>'); + const title = titlePattern.exec(head)?.[1] ?? ''; + return { title: decodeXmlText(title).trim(), entries }; + } + + private async videoDetails(videoIds: string[]): Promise<YouTubeItem[]> { + const unique = [...new Set(videoIds)]; + if (unique.length === 0) { + return []; + } + const items: YouTubeItem[] = []; + for (let i = 0; i < unique.length; i += 50) { + const data = await this.apiGet('videos', { + part: 'snippet,liveStreamingDetails', + id: unique.slice(i, i + 50).join(','), + }); + items.push(...(data.items ?? [])); + } + return items; + } + + private async apiGet( + path: string, + params: Record<string, string>, + ): Promise<YouTubeListResponse> { + const key = this.config.get<string>('YOUTUBE_API_KEY'); + if (!key) { + throw new Error('YOUTUBE_API_KEY is not set'); + } + const url = new URL(`${API_BASE}/${path}`); + for (const [name, value] of Object.entries(params)) { + url.searchParams.set(name, value); + } + url.searchParams.set('key', key); + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error( + `YouTube API ${path} request failed: ${res.status} ${res.statusText}` + + (body ? ` - ${body}` : ''), + ); + } + // Asserted rather than validated: the response is untrusted JSON, and every + // field YouTubeListResponse declares is optional, so reads narrow anyway. + return (await res.json()) as YouTubeListResponse; + } +} diff --git a/src/main.ts b/src/main.ts index a09900f..bcbceba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,122 @@ +import { INestApplication, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import helmet from 'helmet'; import { AppModule } from './app.module'; +import { + ANY_ORIGIN, + CORS_ORIGINS_ENV, + corsOriginMatchers, + parseCorsOrigins, +} from './cors'; +import { getVersionInfo } from './health/version'; + +/** Where the Swagger UI is served from. */ +export const SWAGGER_PATH = 'docs'; +/** Where the generated OpenAPI document itself is served from. */ +export const SWAGGER_JSON_PATH = `${SWAGGER_PATH}-json`; + +/** + * Apply the standard security response headers. + * + * Helmet's defaults are taken unchanged, because they already fit what this app + * serves. The only HTML it returns is the Swagger UI, and that page satisfies + * the default Content-Security-Policy: every script it loads is external and + * same-origin, so `script-src 'self'` covers them, and its inline `<style>` + * blocks fall under a default `style-src` that includes `'unsafe-inline'`. + * test/security.e2e-spec.ts asserts that pairing, so a future Swagger release + * that inlines a script fails a test instead of silently breaking the UI. + * + * Must run before any route is registered: Express walks middleware and route + * handlers in registration order, so a route mounted earlier would answer + * without these headers. + */ +export function setupSecurity(app: INestApplication): void { + app.use(helmet()); +} + +/** + * Allow the origins named in CORS_ORIGINS to read this API from a browser. + * + * The consuming sites are deployed independently of this service, so who may + * call it is configuration rather than code: adding a site is an environment + * change. An entry may name one origin or, as `https://*.example.com`, every + * subdomain of a domain; `*` alone opts every origin in. + * + * With nothing configured, no cross-origin headers are sent at all — the API + * still answers, but a browser on another site will not hand the response to the + * page that asked for it. That is deliberate: a deployment that has not said who + * may read it should not be guessed at. + */ +export function setupCors( + app: INestApplication, + raw: string | undefined, +): void { + const logger = new Logger('Cors'); + const origins = parseCorsOrigins(raw); + + if (origins.length === 0) { + logger.warn( + `${CORS_ORIGINS_ENV} is not set — no origin can read this API from a browser`, + ); + return; + } + + const anyOrigin = origins[0] === ANY_ORIGIN; + app.enableCors({ + // A list is matched against the request's Origin and echoed back, which is + // why it cannot be collapsed into the "*" case: that sends a literal "*". + origin: anyOrigin ? ANY_ORIGIN : corsOriginMatchers(origins), + // Every endpoint here is a read, so no other method needs advertising. + methods: ['GET'], + }); + + logger.log( + anyOrigin + ? 'Cross-origin reads allowed from any origin' + : `Cross-origin reads allowed from ${origins.join(', ')}`, + ); +} + +/** + * Mount the OpenAPI document generated from the codebase, plus its UI. + * + * Served in every environment, including production: the API is public, so its + * documentation is too. + * + * The reported version is the running build's, so the spec a deployment serves + * always describes that deployment rather than a hardcoded number. + */ +export function setupSwagger(app: INestApplication): void { + const config = new DocumentBuilder() + .setTitle('Open Home Foundation Web API') + .setDescription( + 'Public web API for openhomefoundation.org. Serves the livestream status ' + + "of the foundation's YouTube channels, derived from their feeds and the " + + 'YouTube Data API.', + ) + .setVersion(getVersionInfo().version) + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup(SWAGGER_PATH, app, document, { + jsonDocumentUrl: SWAGGER_JSON_PATH, + }); +} async function bootstrap() { const app = await NestFactory.create(AppModule); + setupSecurity(app); + // Read through ConfigService so a value from .env is picked up like any other. + setupCors(app, app.get(ConfigService).get<string>(CORS_ORIGINS_ENV)); + setupSwagger(app); const port = Number(process.env.PORT ?? 3000); await app.listen(port, '0.0.0.0'); } -bootstrap(); + +// Guarded so importing this module for its Swagger setup (as the e2e spec does) +// does not boot a listening server. +if (require.main === module) { + void bootstrap(); +} diff --git a/test/cors.e2e-spec.ts b/test/cors.e2e-spec.ts new file mode 100644 index 0000000..9c23d54 --- /dev/null +++ b/test/cors.e2e-spec.ts @@ -0,0 +1,166 @@ +import { Server } from 'node:http'; + +import request from 'supertest'; + +import { TestApp, startTestApp } from './test-app.fixture'; + +const ALLOWED = 'https://www.home-assistant.io'; +const ALSO_ALLOWED = 'https://esphome.io'; +const DENIED = 'https://not-configured.example'; + +describe('CORS from CORS_ORIGINS (e2e)', () => { + describe('with a list of origins', () => { + let fixture: TestApp; + let server: Server; + + beforeAll(async () => { + fixture = await startTestApp({ + corsOrigins: `${ALLOWED},${ALSO_ALLOWED}`, + }); + server = fixture.server; + }, 30_000); + + afterAll(async () => { + await fixture?.close(); + }); + + it.each([ALLOWED, ALSO_ALLOWED])( + 'lets a browser on %s read the response', + async (origin) => { + const res = await request(server) + .get('/livestream') + .set('Origin', origin); + + expect(res.status).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe(origin); + }, + ); + + it('varies on Origin, so a cache cannot serve one site the headers meant for another', async () => { + const res = await request(server) + .get('/livestream') + .set('Origin', ALLOWED); + + expect(res.headers['vary']).toMatch(/\bOrigin\b/); + }); + + it('withholds the header from an origin that is not configured', async () => { + const res = await request(server) + .get('/livestream') + .set('Origin', DENIED); + + // The request still succeeds: CORS is the browser's rule, not the + // server's. Without this header, the browser refuses to hand the body to + // the page that asked for it. + expect(res.status).toBe(200); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + it('answers a preflight for an allowed origin, advertising reads only', async () => { + const res = await request(server) + .options('/livestream') + .set('Origin', ALLOWED) + .set('Access-Control-Request-Method', 'GET'); + + expect(res.status).toBeLessThan(300); + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED); + expect(res.headers['access-control-allow-methods']).toBe('GET'); + }); + + it('matches an origin however the configured entry was written', async () => { + // The configured value normalises to the header a browser sends, so a + // trailing slash or different case in config still matches. + const other = await startTestApp({ corsOrigins: 'HTTPS://ESPHome.IO/' }); + try { + const res = await request(other.server) + .get('/livestream') + .set('Origin', ALSO_ALLOWED); + + expect(res.headers['access-control-allow-origin']).toBe(ALSO_ALLOWED); + } finally { + await other.close(); + } + }, 30_000); + }); + + describe('with a domain and its subdomains', () => { + let fixture: TestApp; + + beforeAll(async () => { + fixture = await startTestApp({ + corsOrigins: 'https://home-assistant.io,https://*.home-assistant.io', + }); + }, 30_000); + + afterAll(async () => { + await fixture?.close(); + }); + + it.each([ + 'https://home-assistant.io', + 'https://www.home-assistant.io', + 'https://developers.home-assistant.io', + ])('lets %s read the response', async (origin) => { + const res = await request(fixture.server) + .get('/livestream') + .set('Origin', origin); + + expect(res.headers['access-control-allow-origin']).toBe(origin); + }); + + it.each([ + 'https://evil-home-assistant.io', + 'https://home-assistant.io.evil.example', + 'http://www.home-assistant.io', + ])('withholds the header from %s', async (origin) => { + const res = await request(fixture.server) + .get('/livestream') + .set('Origin', origin); + + expect(res.status).toBe(200); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + }); + + describe('with "*"', () => { + let fixture: TestApp; + + beforeAll(async () => { + fixture = await startTestApp({ corsOrigins: '*' }); + }, 30_000); + + afterAll(async () => { + await fixture?.close(); + }); + + it('lets any origin read the response', async () => { + const res = await request(fixture.server) + .get('/livestream') + .set('Origin', DENIED); + + expect(res.status).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe('*'); + }); + }); + + describe('with nothing configured', () => { + let fixture: TestApp; + + beforeAll(async () => { + fixture = await startTestApp(); + }, 30_000); + + afterAll(async () => { + await fixture?.close(); + }); + + it('serves the API but grants no origin access to it', async () => { + const res = await request(fixture.server) + .get('/livestream') + .set('Origin', ALLOWED); + + expect(res.status).toBe(200); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + }); +}); diff --git a/test/jest-e2e.json b/test/jest-e2e.json new file mode 100644 index 0000000..eada322 --- /dev/null +++ b/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "..", + "testEnvironment": "node", + "testRegex": "\\.e2e-spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/test/livestream.e2e-spec.ts b/test/livestream.e2e-spec.ts new file mode 100644 index 0000000..8a43888 --- /dev/null +++ b/test/livestream.e2e-spec.ts @@ -0,0 +1,732 @@ +import { Server } from 'node:http'; + +import { INestApplication } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; + +import { Channel } from '../src/livestream/livestream.channels'; +import { LivestreamModule } from '../src/livestream/livestream.module'; +import { LivestreamInfo } from '../src/livestream/livestream.service'; + +const HOUR_MS = 60 * 60 * 1000; + +/** + * A tracked channel plus the things only the *feed* knows about it. + * + * `Channel` is exactly what configuration can express — a handle and a slug. + * The display name is not configurable: the service reads it from the channel's + * feed title at runtime. So the fixtures carry the feed title separately, and + * every `channelName` assertion below is an assertion about the feed. + */ +interface ChannelFixture extends Channel { + /** Channel-level <title> this channel's feed serves. */ + feedTitle: string; + /** Serve a non-feed body for this channel, i.e. its feed cannot be read. */ + feedUnreadable?: boolean; +} + +/** + * The tracked channels come from the LIVESTREAM_CHANNELS environment variable, + * so this suite owns its channel list and hands it to the app through config. + * Every assertion below therefore describes these fixtures, never whatever the + * deployment (or the developer's .env) happens to be configured with. + * + * Each feed title is deliberately unlike its slug, so an implementation that + * fell back to the slug could not pass the display-name assertions by accident. + */ +const FIXTURE_CHANNELS: ChannelFixture[] = [ + { + slug: 'fixture-alpha', + handle: 'FixtureAlpha', + feedTitle: 'Fixture Alpha Live', + }, + { + slug: 'fixture-bravo', + handle: 'FixtureBravo', + feedTitle: 'Bravo Broadcasting Co.', + }, + { + slug: 'fixture-charlie', + handle: 'FixtureCharlie', + feedTitle: 'Charlie on YouTube', + }, +]; + +/** A deliberately different list, used to prove the channels come from config. */ +const SOLO_CHANNELS: ChannelFixture[] = [ + { + slug: 'solo-config-channel', + handle: 'SoloConfigChannel', + feedTitle: 'The Solo Config Show', + }, +]; + +/** One healthy channel and one whose feed cannot be read. */ +const MIXED_FEED_CHANNELS: ChannelFixture[] = [ + { + slug: 'readable-feed', + handle: 'ReadableFeed', + feedTitle: 'Readable Feed Network', + }, + { + slug: 'unreadable-feed', + handle: 'UnreadableFeed', + feedTitle: 'Never Reaches The API', + feedUnreadable: true, + }, +]; + +const READABLE_FEED_CHANNEL = MIXED_FEED_CHANNELS[0]; +const UNREADABLE_FEED_CHANNEL = MIXED_FEED_CHANNELS[1]; + +/** + * The service derives a channel's status from its tracked livestreams, so every + * channel gets a livestream fixture: that makes "discovery has settled" an + * observable condition over HTTP (no channel is left at the initial "none"). + */ +type Scenario = 'upcoming' | 'live' | 'past'; +const SCENARIOS: Scenario[] = ['upcoming', 'live', 'past']; + +interface Fixture extends ChannelFixture { + scenario: Scenario; + channelId: string; + videoId: string; + /** The video's title, deliberately unlike the channel's feed title. */ + title: string; + /** Atom <updated>; the service fingerprints the feed with it. */ + feedUpdated: string; +} + +const buildFixtures = (channels: ChannelFixture[], prefix: string): Fixture[] => + channels.map((channel, index) => ({ + ...channel, + scenario: SCENARIOS[index % SCENARIOS.length], + channelId: `UC${prefix}channel${index}`, + videoId: `${prefix}video${index}`, + title: `Test stream on ${channel.slug}`, + feedUpdated: new Date(Date.now() - (index + 1) * 60_000).toISOString(), + })); + +const FIXTURES = buildFixtures(FIXTURE_CHANNELS, 'test'); +const SOLO_FIXTURES = buildFixtures(SOLO_CHANNELS, 'solo'); +const MIXED_FEED_FIXTURES = buildFixtures(MIXED_FEED_CHANNELS, 'mixed'); + +/** + * The fixture list is built to contain one of each scenario, so a missing one is + * a broken fixture rather than a runtime possibility — fail loudly here instead + * of threading undefined through every assertion. + */ +const fixtureFor = (scenario: Scenario): Fixture => { + const fixture = FIXTURES.find((f) => f.scenario === scenario); + if (!fixture) { + throw new Error(`no "${scenario}" fixture configured`); + } + return fixture; +}; + +const UPCOMING = fixtureFor('upcoming'); +const LIVE = fixtureFor('live'); + +/** Scheduled far enough out that the service does not treat it as imminent. */ +const scheduledStartTime = new Date(Date.now() + 3 * HOUR_MS).toISOString(); +const startedLongAgo = new Date(Date.now() - 2 * HOUR_MS).toISOString(); +const endedRecently = new Date(Date.now() - HOUR_MS).toISOString(); + +const liveStreamingDetailsFor = (scenario: Scenario) => { + switch (scenario) { + case 'upcoming': + return { scheduledStartTime }; + case 'live': + return { + scheduledStartTime: startedLongAgo, + actualStartTime: startedLongAgo, + }; + case 'past': + return { + scheduledStartTime: startedLongAgo, + actualStartTime: startedLongAgo, + actualEndTime: endedRecently, + }; + } +}; + +/** + * LIVESTREAM_CHANNELS is a comma-separated list of `handle:slug` pairs, so the + * config string is derived from the fixture list rather than written out twice. + */ +const toChannelsConfig = (channels: Channel[]): string => + channels.map((channel) => `${channel.handle}:${channel.slug}`).join(','); + +const jsonResponse = (payload: unknown) => + new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + +/** + * Shaped like a real YouTube channel feed: + * + * - the channel-level <title> comes before the first <entry> and is the + * channel's display name, which is where the served channelName comes from; + * - each <entry> carries its own <title> (the video's) plus an <updated>, which + * the service fingerprints to decide whether the feed changed and a + * videos.list classification is worth spending quota on. + */ +const feedResponse = (channelTitle: string, fixtures: Fixture[]) => + new Response( + `<?xml version="1.0" encoding="UTF-8"?> +<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" xmlns="http://www.w3.org/2005/Atom"> + <title>${channelTitle} + +${fixtures + .map( + (fixture) => + ` ${fixture.videoId}` + + `${fixture.title}` + + `${fixture.feedUpdated}`, + ) + .join('\n')} +`, + { status: 200, headers: { 'content-type': 'application/atom+xml' } }, + ); + +/** The of the error page served for an unreadable feed. */ +const ERROR_PAGE_TITLE = 'Error 503 (Service Unavailable)!!1'; + +/** + * A 200 that is not a feed at all — the shape of a YouTube/proxy error page. + * The service rejects any body without `<feed`, so this must neither classify + * as "the channel has no videos" nor donate its <title> as a channel name. + */ +const errorPageResponse = () => + new Response( + `<!doctype html><html><head><title>${ERROR_PAGE_TITLE}` + + `

503. That's an error.

`, + { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } }, + ); + +const videoItem = (fixture: Fixture) => ({ + id: fixture.videoId, + snippet: { title: fixture.title, channelId: fixture.channelId }, + liveStreamingDetails: liveStreamingDetailsFor(fixture.scenario), +}); + +/** Serves the three URL shapes the service talks to; everything else is a hard error. */ +const fetchHandlerFor = + (fixtures: Fixture[]) => + async (input: unknown): Promise => { + const url = new URL(String(input)); + + if (url.hostname === 'www.googleapis.com') { + if (url.pathname === '/youtube/v3/channels') { + const handle = url.searchParams.get('forHandle'); + const fixture = fixtures.find((f) => f.handle === handle); + return jsonResponse({ + items: fixture ? [{ id: fixture.channelId }] : [], + }); + } + if (url.pathname === '/youtube/v3/videos') { + const ids = (url.searchParams.get('id') ?? '') + .split(',') + .filter(Boolean); + const items = fixtures + .filter((f) => ids.includes(f.videoId)) + .map(videoItem); + return jsonResponse({ items }); + } + } + + if ( + url.hostname === 'www.youtube.com' && + url.pathname === '/feeds/videos.xml' + ) { + const channelId = url.searchParams.get('channel_id'); + const owner = fixtures.find((f) => f.channelId === channelId); + if (!owner) { + throw new Error(`Unexpected feed request for channel ${channelId}`); + } + return owner.feedUnreadable + ? errorPageResponse() + : feedResponse( + owner.feedTitle, + fixtures.filter((f) => f.channelId === channelId), + ); + } + + throw new Error(`Unexpected outbound request to ${url.toString()}`); + }; + +/** Nest logs provider instantiation failures; keep the expected ones quiet. */ +const SILENT_LOGGER = { + log: () => {}, + error: () => {}, + warn: () => {}, + debug: () => {}, + verbose: () => {}, +}; + +/** + * Boot the module under test with an explicit LIVESTREAM_CHANNELS value. + * + * `ignoreEnvFile` keeps the developer's real .env out of the run, and the + * loaded factory wins over process.env in ConfigService, so the channel list is + * exactly the fixture list the caller passes in. + */ +const createApp = async ( + rawChannels: string, + silent = false, +): Promise => { + let builder = Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + ignoreEnvFile: true, + load: [ + () => ({ + YOUTUBE_API_KEY: 'test-key', + LIVESTREAM_CHANNELS: rawChannels, + }), + ], + }), + LivestreamModule, + ], + }); + if (silent) { + builder = builder.setLogger(SILENT_LOGGER); + } + const moduleRef = await builder.compile(); + + const app = moduleRef.createNestApplication({ logger: false }); + await app.init(); + return app; +}; + +const createAppForChannels = ( + channels: ChannelFixture[], +): Promise => createApp(toChannelsConfig(channels)); + +/** Every outbound RSS feed request the stub has seen. */ +const feedRequests = (fetchMock: jest.Mock): string[] => + fetchMock.mock.calls + .map(([input]) => String(input)) + .filter((url) => url.includes('youtube.com/feeds/videos.xml')); + +/** + * Startup discovery is kicked off without being awaited. Drive the event loop + * turn by turn until the collection satisfies `settled` instead of sleeping. + */ +const pollUntil = async ( + server: Server, + what: string, + settled: (entries: LivestreamInfo[]) => boolean, +): Promise => { + for (let turn = 0; turn < 200; turn++) { + const res = await request(server).get('/livestream'); + if (res.status === 200 && Array.isArray(res.body) && settled(res.body)) { + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error(`timed out waiting for ${what}`); +}; + +const waitForDiscovery = ( + server: Server, + expectedChannels: number, +): Promise => + pollUntil( + server, + 'startup discovery to populate every channel', + (entries) => + entries.length === expectedChannels && + entries.every((entry) => entry.status !== 'none'), + ); + +const DOCUMENTED_KEYS = [ + 'channel', + 'channelName', + 'status', + 'title', + 'url', + 'startTime', + 'updatedAt', +]; + +const isIsoTimestamp = (value: unknown): boolean => + typeof value === 'string' && + !Number.isNaN(Date.parse(value)) && + new Date(value).toISOString() === value; + +describe('Livestream (e2e)', () => { + let app: INestApplication; + let server: Server; + let fetchMock: jest.Mock; + const originalFetch = globalThis.fetch; + + beforeAll(async () => { + fetchMock = jest.fn(fetchHandlerFor(FIXTURES)); + // Stubbed before init() because the service calls the network on startup. + globalThis.fetch = fetchMock; + + app = await createAppForChannels(FIXTURE_CHANNELS); + server = app.getHttpServer(); + + await waitForDiscovery(server, FIXTURE_CHANNELS.length); + }, 30_000); + + afterAll(async () => { + // Clears the discovery/reconcile intervals so Jest can exit cleanly. + await app?.close(); + globalThis.fetch = originalFetch; + }); + + describe('GET /livestream', () => { + it('returns one entry per configured channel, in configured order', async () => { + const res = await request(server).get('/livestream'); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body).toHaveLength(FIXTURE_CHANNELS.length); + expect(res.body.map((entry: LivestreamInfo) => entry.channel)).toEqual( + FIXTURE_CHANNELS.map((channel) => channel.slug), + ); + }); + + it('describes every channel with the documented entry shape', async () => { + const res = await request(server).get('/livestream'); + + for (const [index, channel] of FIXTURE_CHANNELS.entries()) { + const entry = res.body[index]; + expect(entry.channel).toBe(channel.slug); + expect(entry.channelName).toBe(channel.feedTitle); + expect(['live', 'upcoming', 'past', 'none']).toContain(entry.status); + expect(isIsoTimestamp(entry.updatedAt)).toBe(true); + expect( + Object.keys(entry).filter((key) => !DOCUMENTED_KEYS.includes(key)), + ).toEqual([]); + if (entry.status !== 'none') { + expect(typeof entry.title).toBe('string'); + expect(entry.url).toMatch(/^https:\/\/www\.youtube\.com\/watch\?v=/); + } + } + }); + + it('names each channel with the title from its feed, not with its slug', async () => { + const res = await request(server).get('/livestream'); + + for (const [index, channel] of FIXTURE_CHANNELS.entries()) { + const entry = res.body[index]; + // The fixtures' feed titles are nothing like their slugs, so a service + // still serving the slug fallback cannot pass this. + expect(entry.channelName).toBe(channel.feedTitle); + expect(entry.channelName).not.toBe(channel.slug); + // The channel's is not the <title> of an entry in its feed. + expect(entry.channelName).not.toBe(entry.title); + } + }); + + it('serves JSON', async () => { + const res = await request(server).get('/livestream'); + + expect(res.headers['content-type']).toMatch(/application\/json/); + }); + + it('reports the same updatedAt while a channel state does not change', async () => { + const first = await request(server).get('/livestream'); + const second = await request(server).get('/livestream'); + + expect( + second.body.map((entry: LivestreamInfo) => entry.updatedAt), + ).toEqual(first.body.map((entry: LivestreamInfo) => entry.updatedAt)); + }); + }); + + describe('GET /livestream/:slug', () => { + it('returns the entry for a known channel, matching the collection', async () => { + const slug = FIXTURE_CHANNELS[0].slug; + + const all = await request(server).get('/livestream'); + const one = await request(server).get(`/livestream/${slug}`); + + expect(one.status).toBe(200); + expect(one.body.channel).toBe(slug); + expect(one.body).toEqual( + all.body.find((entry: LivestreamInfo) => entry.channel === slug), + ); + }); + + it('returns 404 naming the channel when the slug is unknown', async () => { + const res = await request(server).get('/livestream/not-a-real-channel'); + + expect(res.status).toBe(404); + expect(res.headers['content-type']).toMatch(/application\/json/); + expect(res.body.statusCode).toBe(404); + expect(res.body.message).toContain('not-a-real-channel'); + }); + + it('returns 404 for a slug containing path traversal characters', async () => { + const res = await request(server).get( + '/livestream/..%2F..%2Fetc%2Fpasswd', + ); + + expect(res.status).toBe(404); + // The decoded path is rejected as an unknown channel, so the request did + // reach the controller rather than dying in the routing/URL layer. + expect(res.body.message).toContain('../../etc/passwd'); + expect(res.text).not.toContain('root:'); + }); + }); + + describe('RSS-only discovery', () => { + it('reports a channel with a scheduled stream in its feed as upcoming', async () => { + expect(UPCOMING).toBeDefined(); + + const res = await request(server).get(`/livestream/${UPCOMING.slug}`); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('upcoming'); + expect(res.body.title).toBe(UPCOMING.title); + expect(res.body.url).toBe( + `https://www.youtube.com/watch?v=${UPCOMING.videoId}`, + ); + expect(res.body.startTime).toBe(scheduledStartTime); + expect(isIsoTimestamp(res.body.startTime)).toBe(true); + }); + + it('keeps the scheduled startTime once a stream has gone live', async () => { + expect(LIVE).toBeDefined(); + + const res = await request(server).get(`/livestream/${LIVE.slug}`); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('live'); + expect(res.body.url).toBe( + `https://www.youtube.com/watch?v=${LIVE.videoId}`, + ); + expect(res.body.startTime).toBe(startedLongAgo); + }); + + it('discovers streams by reading each channel RSS feed', async () => { + const feedUrls = feedRequests(fetchMock); + + expect(feedUrls.length).toBeGreaterThanOrEqual(FIXTURE_CHANNELS.length); + for (const fixture of FIXTURES) { + expect( + feedUrls.some((url) => + url.includes(`channel_id=${fixture.channelId}`), + ), + ).toBe(true); + } + }); + }); + + describe('the removed WebSub/PubSubHubbub webhook', () => { + it('is gone: GET /pubsub returns 404', async () => { + const res = await request(server).get('/pubsub'); + + expect(res.status).toBe(404); + }); + + it('is gone: POST /pubsub with an Atom notification body returns 404', async () => { + const res = await request(server) + .post('/pubsub') + .set('content-type', 'application/atom+xml') + .send( + '<?xml version="1.0" encoding="UTF-8"?>' + + '<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" xmlns="http://www.w3.org/2005/Atom">' + + '<entry><yt:videoId>injected</yt:videoId></entry></feed>', + ); + + expect(res.status).toBe(404); + }); + + it('never subscribes to the PubSubHubbub hub', async () => { + const outbound = fetchMock.mock.calls.map(([input]) => String(input)); + + expect(outbound.length).toBeGreaterThan(0); + expect( + outbound.filter((url) => url.includes('pubsubhubbub.appspot.com')), + ).toEqual([]); + }); + }); +}); + +describe('Livestream channel list from configuration (e2e)', () => { + let app: INestApplication; + let server: Server; + const originalFetch = globalThis.fetch; + + beforeAll(async () => { + globalThis.fetch = jest.fn(fetchHandlerFor(SOLO_FIXTURES)); + + app = await createAppForChannels(SOLO_CHANNELS); + server = app.getHttpServer(); + + await waitForDiscovery(server, SOLO_CHANNELS.length); + }, 30_000); + + afterAll(async () => { + await app?.close(); + globalThis.fetch = originalFetch; + }); + + it('tracks exactly the channels LIVESTREAM_CHANNELS names', async () => { + const res = await request(server).get('/livestream'); + + expect(res.status).toBe(200); + expect(res.body.map((entry: LivestreamInfo) => entry.channel)).toEqual([ + 'solo-config-channel', + ]); + expect(res.body[0].channelName).toBe(SOLO_CHANNELS[0].feedTitle); + }); + + it('serves the configured channel by its slug', async () => { + const res = await request(server).get('/livestream/solo-config-channel'); + + expect(res.status).toBe(200); + expect(res.body.channel).toBe('solo-config-channel'); + expect(res.body.status).toBe('upcoming'); + }); + + it('returns 404 for a channel this app was not configured with', async () => { + const res = await request(server).get( + `/livestream/${FIXTURE_CHANNELS[0].slug}`, + ); + + expect(res.status).toBe(404); + expect(res.body.message).toContain(FIXTURE_CHANNELS[0].slug); + }); +}); + +describe('Livestream with an unreadable channel feed (e2e)', () => { + let app: INestApplication; + let server: Server; + let fetchMock: jest.Mock; + const originalFetch = globalThis.fetch; + + beforeAll(async () => { + fetchMock = jest.fn(fetchHandlerFor(MIXED_FEED_FIXTURES)); + globalThis.fetch = fetchMock; + + app = await createApp(toChannelsConfig(MIXED_FEED_CHANNELS), true); + server = app.getHttpServer(); + + // The broken channel never leaves "none", so waiting for every channel to + // settle would never finish. Wait instead for the healthy channel to be + // classified *and* for the broken channel's feed to have been attempted. + await pollUntil( + server, + 'the healthy channel to settle and the broken feed to have been read', + (entries) => + entries.length === MIXED_FEED_CHANNELS.length && + entries.some( + (entry) => + entry.channel === READABLE_FEED_CHANNEL.slug && + entry.status !== 'none', + ) && + feedRequests(fetchMock).some((url) => + url.includes( + `channel_id=${ + MIXED_FEED_FIXTURES.find( + (f) => f.slug === UNREADABLE_FEED_CHANNEL.slug, + )?.channelId + }`, + ), + ), + ); + }, 30_000); + + afterAll(async () => { + await app?.close(); + globalThis.fetch = originalFetch; + }); + + it('still lists a channel whose feed cannot be read, in the documented shape', async () => { + const res = await request(server).get('/livestream'); + + expect(res.status).toBe(200); + expect(res.body.map((entry: LivestreamInfo) => entry.channel)).toEqual( + MIXED_FEED_CHANNELS.map((channel) => channel.slug), + ); + + const entry = res.body.find( + (candidate: LivestreamInfo) => + candidate.channel === UNREADABLE_FEED_CHANNEL.slug, + ); + expect(entry).toBeDefined(); + expect(entry.status).toBe('none'); + expect(isIsoTimestamp(entry.updatedAt)).toBe(true); + expect( + Object.keys(entry).filter((key) => !DOCUMENTED_KEYS.includes(key)), + ).toEqual([]); + // Nothing was discovered, so there is no stream to advertise. + expect(entry.title).toBeUndefined(); + expect(entry.url).toBeUndefined(); + expect(entry.startTime).toBeUndefined(); + }); + + it('falls back to the slug for the name of a channel whose feed cannot be read', async () => { + const res = await request(server).get( + `/livestream/${UNREADABLE_FEED_CHANNEL.slug}`, + ); + + expect(res.status).toBe(200); + expect(res.body.channelName).toBe(UNREADABLE_FEED_CHANNEL.slug); + // The non-feed body carries a <title> of its own; it is not a channel name. + expect(res.body.channelName).not.toBe(ERROR_PAGE_TITLE); + }); + + it('keeps serving the channels whose feeds are readable', async () => { + const res = await request(server).get( + `/livestream/${READABLE_FEED_CHANNEL.slug}`, + ); + + expect(res.status).toBe(200); + expect(res.body.channelName).toBe(READABLE_FEED_CHANNEL.feedTitle); + expect(res.body.status).toBe('upcoming'); + }); +}); + +describe('Livestream with a malformed LIVESTREAM_CHANNELS (e2e)', () => { + // Only set if a boot unexpectedly succeeds, so afterEach can clean it up. + let app: INestApplication | undefined; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + app = undefined; + // A boot that unexpectedly succeeds must not reach the network either. + globalThis.fetch = jest.fn(async (input: unknown) => { + throw new Error(`Unexpected outbound request to ${String(input)}`); + }); + }); + + afterEach(async () => { + // Nothing should be left running if the boot did not fail as expected. + await app?.close(); + globalThis.fetch = originalFetch; + }); + + /** Assigns `app` only if the boot unexpectedly succeeds, so afterEach can close it. */ + const boot = async (rawChannels: string): Promise<void> => { + app = await createApp(rawChannels, true); + }; + + it('fails the boot when an entry has no ":" separating handle from slug', async () => { + await expect(boot('FixtureAlpha')).rejects.toThrow( + /LIVESTREAM_CHANNELS\[0\] "FixtureAlpha" must be one handle and one slug separated by ":"/, + ); + }); + + it('fails the boot when the slug after the ":" is not URL-safe', async () => { + await expect(boot('FixtureAlpha:Not A Slug')).rejects.toThrow( + /LIVESTREAM_CHANNELS\[0\].*slug "Not A Slug" must be lowercase/, + ); + }); + + it('fails the boot rather than serving an empty channel list', async () => { + await expect(boot(',,')).rejects.toThrow( + /LIVESTREAM_CHANNELS.*lists no channels/, + ); + }); +}); diff --git a/test/security.e2e-spec.ts b/test/security.e2e-spec.ts new file mode 100644 index 0000000..c474b3d --- /dev/null +++ b/test/security.e2e-spec.ts @@ -0,0 +1,109 @@ +import { Server } from 'node:http'; + +import request from 'supertest'; + +import { SWAGGER_JSON_PATH, SWAGGER_PATH } from '../src/main'; +import { TestApp, startTestApp } from './test-app.fixture'; + +/** + * Read one directive out of a Content-Security-Policy header, e.g. + * `script-src 'self'`. Returns undefined when the policy does not set it. + */ +const directive = (policy: string, name: string): string | undefined => + policy + .split(';') + .map((part) => part.trim()) + .find((part) => part === name || part.startsWith(`${name} `)); + +/** Every `<script ...>` opening tag in a document, in order. */ +const scriptTags = (html: string): string[] => + html.match(/<script\b[^>]*>/g) ?? []; + +/** + * Endpoints that between them cover every kind of response the app serves: a + * JSON collection, a health probe, the generated spec, and the only HTML page. + */ +const PATHS = ['/livestream', '/__heartbeat__', `/${SWAGGER_JSON_PATH}`]; + +describe('Security headers (e2e)', () => { + let fixture: TestApp; + let server: Server; + + beforeAll(async () => { + fixture = await startTestApp(); + server = fixture.server; + }, 30_000); + + afterAll(async () => { + await fixture?.close(); + }); + + describe.each([...PATHS, `/${SWAGGER_PATH}`])('%s', (path) => { + it('is served with the security headers helmet applies', async () => { + const res = await request(server).get(path); + + expect(res.status).toBe(200); + expect(res.headers['content-security-policy']).toBeDefined(); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + expect(res.headers['x-frame-options']).toBe('SAMEORIGIN'); + expect(res.headers['referrer-policy']).toBe('no-referrer'); + expect(res.headers['strict-transport-security']).toBeDefined(); + }); + + it('does not advertise the framework it runs on', async () => { + const res = await request(server).get(path); + + expect(res.headers['x-powered-by']).toBeUndefined(); + }); + }); + + // A 404 is generated by Nest rather than a controller, so it exercises a + // different path through the stack than the routes above. + it('applies the headers to error responses too', async () => { + const res = await request(server).get('/livestream/not-a-real-channel'); + + expect(res.status).toBe(404); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + expect(res.headers['x-powered-by']).toBeUndefined(); + }); + + describe('the default policy against what the Swagger UI needs', () => { + let html: string; + let policy: string; + + beforeAll(async () => { + const res = await request(server).get(`/${SWAGGER_PATH}`); + html = res.text; + policy = res.headers['content-security-policy']; + }); + + it('locks scripts to this origin, and the UI loads none from anywhere else', () => { + expect(directive(policy, 'script-src')).toBe("script-src 'self'"); + + const tags = scriptTags(html); + expect(tags.length).toBeGreaterThan(0); + // Every script is external — an inline one would be blocked outright, + // which is what would break the UI under this policy. + expect(tags.filter((tag) => !/\ssrc=/.test(tag))).toEqual([]); + // ...and relative, so it resolves to this origin and 'self' allows it. + const sources = tags.map((tag) => /src=['"]?([^'">\s]+)/.exec(tag)?.[1]); + expect( + sources.filter((src) => !src || /^[a-z]+:|^\/\//.test(src)), + ).toEqual([]); + }); + + it('forbids inline event handlers, and the UI uses none', () => { + expect(directive(policy, 'script-src-attr')).toBe( + "script-src-attr 'none'", + ); + expect(/<[a-z][^>]*\son[a-z]+\s*=/i.test(html)).toBe(false); + }); + + it('allows the inline styles the UI ships with', () => { + // The page carries inline <style> blocks and a style attribute, so the + // policy has to permit them; dropping 'unsafe-inline' would break layout. + expect(html).toMatch(/<style>/); + expect(directive(policy, 'style-src')).toContain("'unsafe-inline'"); + }); + }); +}); diff --git a/test/swagger.e2e-spec.ts b/test/swagger.e2e-spec.ts new file mode 100644 index 0000000..eccc9ef --- /dev/null +++ b/test/swagger.e2e-spec.ts @@ -0,0 +1,218 @@ +import { Server } from 'node:http'; + +import request from 'supertest'; + +import { getVersionInfo } from '../src/health/version'; +import { SWAGGER_JSON_PATH, SWAGGER_PATH } from '../src/main'; +import { TestApp, startTestApp } from './test-app.fixture'; + +interface Schema { + type?: string; + format?: string; + description?: string; + enum?: string[]; + items?: Schema; + $ref?: string; + required?: string[]; + properties?: Record<string, Schema>; +} + +interface Operation { + tags?: string[]; + summary?: string; + description?: string; + parameters?: { + name: string; + in: string; + required?: boolean; + schema?: Schema; + }[]; + responses?: Record< + string, + { + description?: string; + content?: Record<string, { schema?: Schema }>; + } + >; +} + +interface OpenApiDocument { + openapi: string; + info: { title: string; description?: string; version: string }; + paths: Record<string, Record<string, Operation>>; + components?: { schemas?: Record<string, Schema> }; +} + +const RESPONSE_SCHEMA = 'LivestreamInfoResponse'; + +const okSchema = (operation: Operation): Schema | undefined => + operation.responses?.['200']?.content?.['application/json']?.schema; + +describe('Swagger (e2e)', () => { + let fixture: TestApp; + let server: Server; + let document: OpenApiDocument; + + beforeAll(async () => { + fixture = await startTestApp(); + server = fixture.server; + + const res = await request(server).get(`/${SWAGGER_JSON_PATH}`); + document = res.body; + }, 30_000); + + afterAll(async () => { + await fixture?.close(); + }); + + describe(`GET /${SWAGGER_JSON_PATH}`, () => { + it('serves an OpenAPI 3 document as JSON', async () => { + const res = await request(server).get(`/${SWAGGER_JSON_PATH}`); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/application\/json/); + expect(res.body.openapi).toMatch(/^3\./); + }); + + it('describes the service, at the version of the running build', () => { + expect(document.info.title).toBe('Open Home Foundation Web API'); + expect(document.info.description).toBeTruthy(); + expect(document.info.version).toBe(getVersionInfo().version); + }); + }); + + describe(`GET /${SWAGGER_PATH}`, () => { + it('serves the Swagger UI', async () => { + const res = await request(server).get(`/${SWAGGER_PATH}`); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/html/); + expect(res.text).toContain('swagger-ui'); + }); + }); + + describe('the documented livestream endpoints', () => { + it('documents GET /livestream as an array of the response schema', () => { + const operation = document.paths['/livestream']?.get; + + expect(operation).toBeDefined(); + expect(operation.tags).toContain('livestream'); + expect(operation.summary).toBeTruthy(); + expect(okSchema(operation)).toEqual({ + type: 'array', + items: { $ref: `#/components/schemas/${RESPONSE_SCHEMA}` }, + }); + }); + + it('documents GET /livestream/{slug} with its path parameter', () => { + const operation = document.paths['/livestream/{slug}']?.get; + + expect(operation).toBeDefined(); + expect(operation.tags).toContain('livestream'); + expect(operation.summary).toBeTruthy(); + + const slug = operation.parameters?.find((p) => p.name === 'slug'); + expect(slug).toMatchObject({ in: 'path', required: true }); + expect(slug?.schema?.type).toBe('string'); + }); + + it('documents the single-channel 200 as the response schema', () => { + const operation = document.paths['/livestream/{slug}'].get; + + expect(okSchema(operation)).toEqual({ + $ref: `#/components/schemas/${RESPONSE_SCHEMA}`, + }); + }); + + it('documents the 404 served for an unknown slug', () => { + const responses = document.paths['/livestream/{slug}'].get.responses; + + expect(responses?.['404']).toBeDefined(); + expect(responses?.['404'].description).toBeTruthy(); + }); + }); + + describe(`the ${RESPONSE_SCHEMA} schema`, () => { + let schema: Schema; + + beforeAll(() => { + const found = document.components?.schemas?.[RESPONSE_SCHEMA]; + if (!found) { + throw new Error(`${RESPONSE_SCHEMA} is missing from the document`); + } + schema = found; + }); + + it('documents exactly the fields the endpoints serve', () => { + expect(Object.keys(schema.properties ?? {}).sort()).toEqual( + [ + 'channel', + 'channelName', + 'startTime', + 'status', + 'title', + 'updatedAt', + 'url', + ].sort(), + ); + }); + + it('requires only the fields that are always served', () => { + expect([...(schema.required ?? [])].sort()).toEqual([ + 'channel', + 'channelName', + 'status', + 'updatedAt', + ]); + }); + + it('documents status as the enum the service can report', () => { + expect(schema.properties?.status.enum).toEqual([ + 'live', + 'upcoming', + 'past', + 'none', + ]); + }); + + it('describes every field', () => { + for (const [name, property] of Object.entries(schema.properties ?? {})) { + expect({ name, described: Boolean(property.description) }).toEqual({ + name, + described: true, + }); + } + }); + }); + + describe('the documented health endpoints', () => { + it.each(['/__lbheartbeat__', '/__heartbeat__', '/__version__'])( + 'documents GET %s', + (path) => { + const operation = document.paths[path]?.get; + + expect(operation).toBeDefined(); + expect(operation.tags).toContain('health'); + expect(operation.summary).toBeTruthy(); + }, + ); + + it('documents the version payload as a schema', () => { + const operation = document.paths['/__version__'].get; + + expect(okSchema(operation)).toEqual({ + $ref: '#/components/schemas/VersionResponse', + }); + expect( + Object.keys( + document.components?.schemas?.VersionResponse.properties ?? {}, + ), + ).toEqual(['version', 'hash']); + }); + }); + + it('leaves the template scaffolding endpoint out of the spec', () => { + // GET /test is excluded, but still routed. + expect(document.paths['/test']).toBeUndefined(); + }); +}); diff --git a/test/test-app.fixture.ts b/test/test-app.fixture.ts new file mode 100644 index 0000000..d0a8b46 --- /dev/null +++ b/test/test-app.fixture.ts @@ -0,0 +1,102 @@ +import { Server } from 'node:http'; + +import { INestApplication } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; + +import { AppController } from '../src/app.controller'; +import { HealthModule } from '../src/health'; +import { getVersionInfo } from '../src/health/version'; +import { LivestreamModule } from '../src/livestream'; +import { setupCors, setupSecurity, setupSwagger } from '../src/main'; + +/** + * A booted app, assembled the way bootstrap() assembles it, for the suites whose + * subject is the wiring around the endpoints — headers, CORS, the served spec — + * rather than livestream behaviour. Suites that need particular feed or API + * responses build their own app with their own stubs instead. + */ +export interface TestApp { + app: INestApplication; + server: Server; + /** Shuts the app down and restores the real fetch. */ + close: () => Promise<void>; +} + +export interface TestAppOptions { + /** Value for CORS_ORIGINS. Omitted leaves it unset, i.e. no CORS headers. */ + corsOrigins?: string; +} + +/** + * One channel is enough for every suite that uses this fixture: none of them + * assert on channel content, they just need a valid list so the app boots. + */ +const CHANNELS = 'FixtureAlpha:fixture-alpha'; + +/** + * Minimal stand-ins for the two upstreams the livestream service talks to on + * startup: enough for discovery to complete without reaching the network. + */ +const fetchStub = (input: unknown): Response => { + const url = new URL(String(input)); + if (url.hostname === 'www.googleapis.com') { + return new Response(JSON.stringify({ items: [{ id: 'UCfixture' }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response( + '<?xml version="1.0" encoding="UTF-8"?>' + + '<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" ' + + 'xmlns="http://www.w3.org/2005/Atom"><title>Fixture Alpha', + { status: 200, headers: { 'content-type': 'application/atom+xml' } }, + ); +}; + +export const startTestApp = async ({ + corsOrigins, +}: TestAppOptions = {}): Promise => { + const originalFetch = globalThis.fetch; + // Stubbed before init() because the livestream service calls out on startup. + globalThis.fetch = jest.fn((input: unknown) => + Promise.resolve(fetchStub(input)), + ); + + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + ignoreEnvFile: true, + load: [ + () => ({ + YOUTUBE_API_KEY: 'test-key', + LIVESTREAM_CHANNELS: CHANNELS, + }), + ], + }), + HealthModule.register({ version: getVersionInfo() }), + LivestreamModule, + ], + controllers: [AppController], + }).compile(); + + const app = moduleRef.createNestApplication({ logger: false }); + // Same order as bootstrap(), which matters: Express walks middleware and route + // handlers in registration order, so anything mounted before these would + // answer without their headers. + setupSecurity(app); + setupCors(app, corsOrigins); + setupSwagger(app); + await app.init(); + + return { + app, + server: app.getHttpServer(), + close: async () => { + // Clears the discovery/reconcile intervals so Jest can exit cleanly. + await app.close(); + globalThis.fetch = originalFetch; + }, + }; +}; diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..db41faf --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "coverage", "**/*.spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 5485d9d..6220a11 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,11 +12,9 @@ "baseUrl": "./", "incremental": true, "skipLibCheck": true, - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, "esModuleInterop": true } }