commit 594a9a878316f7818377e1f08157bd99cbc54edf Author: ssmithx Date: Fri Jul 10 19:01:38 2026 +0000 feat(server): podpuddle scaffold + Fastify backend (nostr auth, RSS, streams) - docker-compose stack: podpuddle + MediaMTX (RTMP/WHIP/HLS) + blossom-server - NIP-98 nostr-only login with session cookies, replay guard, clock-skew window - podcasts/episodes CRUD; episodes register browser-uploaded blossom blobs - RSS 2.0 + itunes + podcast namespace feeds with lnaddress value blocks (podcast:guid UUIDv5 verified against the spec vector) - streams API with hashed stream keys; MediaMTX http-auth webhook (query/password/bearer forms); API poller flips live/ended status - NIP-53 kind 30311 live events published with the server's nostr identity - recordings: ffmpeg remux + server-key blossom upload → podcast episode - 35 vitest tests green Co-Authored-By: Claude Fable 5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1f3d808 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Hostname or IP your users will reach this box on (no scheme, no port) +PUBLIC_HOST=localhost + +# Where the podpuddle UI/API is reachable from browsers +PUBLIC_URL=http://${PUBLIC_HOST}:8095 + +# Public endpoints handed out for streaming / playback / uploads +MEDIAMTX_RTMP_PUBLIC=rtmp://${PUBLIC_HOST}:1935 +MEDIAMTX_WHIP_PUBLIC=http://${PUBLIC_HOST}:8889 +MEDIAMTX_HLS_PUBLIC=http://${PUBLIC_HOST}:8890 +BLOSSOM_URL_DEFAULT=http://${PUBLIC_HOST}:8098 + +# Default nostr relays for NIP-53 live-event announcements (comma separated, +# changeable at runtime in Settings) +NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0b4100 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.env +data/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8ae1620 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# ---- frontend ---- +FROM node:22-bookworm-slim AS frontend-build +WORKDIR /build/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +# ---- server ---- +FROM node:22-bookworm-slim AS server-build +WORKDIR /build/server +COPY server/package*.json ./ +RUN npm ci +COPY server/ ./ +RUN npm run build && npm prune --omit=dev + +# ---- runtime ---- +FROM node:22-bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg curl \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=server-build /build/server/node_modules ./node_modules +COPY --from=server-build /build/server/package.json ./package.json +COPY --from=server-build /build/server/dist ./dist +COPY --from=frontend-build /build/frontend/dist ./public +# Named volumes inherit ownership from the image path: keep /data writable by node +RUN mkdir -p /data && chown node:node /data +USER node +ENV NODE_ENV=production \ + PORT=8095 \ + DATA_DIR=/data \ + STATIC_DIR=/app/public +EXPOSE 8095 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD curl -fsS http://localhost:8095/api/health || exit 1 +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..44833f4 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# podpuddle + +Self-hosted, nostr-native podcast publishing and livestreaming. + +- **Log in with nostr** — NIP-07 browser extension (Alby, nos2x, …); the server verifies + NIP-98 signed requests. No passwords, no email. +- **Upload an mp4** → it is stored on a [Blossom](https://github.com/hzrd149/blossom) server + (bundled, or point at any external one) and published in an RSS 2.0 podcast feed with + Podcasting 2.0 `` lightning-address payment info. +- **Go live** — stream from OBS (RTMP) or straight from the browser (WebRTC/WHIP) through + MediaMTX; viewers watch via HLS and the stream is announced on nostr as a NIP-53 + (kind 30311) live event. +- **Publish recordings** — live streams are recorded; one click remuxes and publishes a + recording as a podcast episode. + +## Quick start + +```sh +cp .env.example .env # edit PUBLIC_HOST if not localhost +docker compose up --build -d +``` + +Then open http://localhost:8095, log in with a NIP-07 extension, and follow the wizard. + +## Services / ports + +| Service | Host port | Purpose | +|---|---|---| +| podpuddle | 8095 | Web UI + API + RSS feeds | +| MediaMTX | 1935 | RTMP ingest (OBS) | +| MediaMTX | 8889 (+8189/udp) | WebRTC/WHIP ingest (browser) | +| MediaMTX | 8890 | HLS playback | +| blossom-server | 8098 | Media blob storage (sha256-addressed) | + +## Streaming with OBS + +Create a stream in the UI; it gives you: + +- Server: `rtmp://:1935/live` +- Stream key: `?key=` + +The HLS playback URL (`http://:8890/live//index.m3u8`) is public and never +contains the secret. + +## Development + +```sh +docker compose up mediamtx blossom -d # backends +cd server && npm install && npm run dev # API on :8095 +cd frontend && npm install && npm run dev # Vite on :5173, proxies /api + /feeds +``` + +Tests: `cd server && npm test`. + +## Data + +Everything lives in named docker volumes: `podpuddle-data` (SQLite, server nostr key, +covers), `mediamtx-recordings` (stream recordings, 7-day retention), `blossom-data` +(media blobs). diff --git a/blossom/config.yml b/blossom/config.yml new file mode 100644 index 0000000..c730aa2 --- /dev/null +++ b/blossom/config.yml @@ -0,0 +1,43 @@ +# blossom-server configuration for podpuddle. +# Uploads require a signed nostr auth event (BUD-01/BUD-02, kind 24242); +# reads are public so podcast apps can fetch enclosures. + +publicDomain: "" + +databasePath: data/sqlite.db + +dashboard: + enabled: false + +discovery: + nostr: + enabled: false + relays: [] + upstream: + enabled: false + domains: [] + +storage: + backend: local + local: + dir: ./data/blobs + removeWhenNoOwners: false + +upload: + enabled: true + requireAuth: true + requirePubkeyInRule: false + +media: + enabled: false + +list: + requireAuth: false + allowListOthers: true + +tor: + enabled: false + +rules: + - type: "*" + expiration: 10 years diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2e0e63a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +name: podpuddle + +services: + podpuddle: + build: . + container_name: podpuddle + restart: unless-stopped + ports: + - "8095:8095" + environment: + NODE_ENV: production + PORT: "8095" + DATA_DIR: /data + RECORDINGS_DIR: /recordings + PUBLIC_URL: ${PUBLIC_URL:-http://localhost:8095} + MEDIAMTX_API_URL: http://mediamtx:9997 + MEDIAMTX_RTMP_PUBLIC: ${MEDIAMTX_RTMP_PUBLIC:-rtmp://localhost:1935} + MEDIAMTX_WHIP_PUBLIC: ${MEDIAMTX_WHIP_PUBLIC:-http://localhost:8889} + MEDIAMTX_HLS_PUBLIC: ${MEDIAMTX_HLS_PUBLIC:-http://localhost:8890} + BLOSSOM_URL_DEFAULT: ${BLOSSOM_URL_DEFAULT:-http://localhost:8098} + NOSTR_RELAYS: ${NOSTR_RELAYS:-wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band} + volumes: + - podpuddle-data:/data + - mediamtx-recordings:/recordings:ro + depends_on: + - mediamtx + - blossom + + mediamtx: + image: bluenviron/mediamtx:1.19.2 + container_name: podpuddle-mediamtx + restart: unless-stopped + ports: + - "1935:1935" # RTMP ingest + - "8889:8889" # WebRTC / WHIP + - "8189:8189/udp" # WebRTC ICE + - "8890:8888" # HLS (host 8890; 8888 kept free for other apps) + environment: + # Browsers need a reachable ICE host candidate; set PUBLIC_HOST in .env + MTX_WEBRTCADDITIONALHOSTS: ${PUBLIC_HOST:-localhost} + volumes: + - ./mediamtx/mediamtx.yml:/mediamtx.yml:ro + - mediamtx-recordings:/recordings + + blossom: + image: ghcr.io/hzrd149/blossom-server:4 + container_name: podpuddle-blossom + restart: unless-stopped + ports: + - "8098:3000" + volumes: + - ./blossom/config.yml:/app/config.yml:ro + - blossom-data:/app/data + +volumes: + podpuddle-data: + mediamtx-recordings: + blossom-data: diff --git a/mediamtx/mediamtx.yml b/mediamtx/mediamtx.yml new file mode 100644 index 0000000..712e19d --- /dev/null +++ b/mediamtx/mediamtx.yml @@ -0,0 +1,48 @@ +# MediaMTX configuration for podpuddle. +# Ingest: RTMP (OBS) + WebRTC/WHIP (browser). Output: HLS. Publish auth is +# delegated to podpuddle via HTTP; stream status is polled from the API. + +logLevel: info + +api: yes +apiAddress: :9997 + +# ---- authentication ------------------------------------------------------ +authMethod: http +authHTTPAddress: http://podpuddle:8095/api/mediamtx/auth +authHTTPExclude: + - action: api + - action: metrics + - action: pprof + +# ---- protocols ----------------------------------------------------------- +rtsp: no +srt: no + +rtmp: yes +rtmpAddress: :1935 + +hls: yes +hlsAddress: :8888 +hlsVariant: lowLatency +hlsAlwaysRemux: yes +hlsAllowOrigin: "*" + +webrtc: yes +webrtcAddress: :8889 +webrtcLocalUDPAddress: :8189 +webrtcAllowOrigin: "*" + +# ---- recording ----------------------------------------------------------- +pathDefaults: + record: yes + recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S-%f + recordFormat: fmp4 + recordPartDuration: 1s + recordSegmentDuration: 1h + recordDeleteAfter: 168h + +paths: + # Streams live at live/; publish requires the stream secret, + # which podpuddle checks in the auth webhook. + "~^live/[A-Za-z0-9]+$": {} diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..e7f7c5c --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,3313 @@ +{ + "name": "podpuddle-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "podpuddle-server", + "version": "0.1.0", + "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/static": "^8.1.1", + "better-sqlite3": "^12.2.0", + "fastify": "^5.4.0", + "fastify-plugin": "^5.1.0", + "nostr-tools": "^2.15.0", + "ws": "^8.18.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22.15.0", + "@types/ws": "^8.18.0", + "tsx": "^4.20.0", + "typescript": "^5.9.0", + "vitest": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cookie": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.1.tgz", + "integrity": "sha512-sJ0NXzGVYjUB4OynPZRsIcQ1mKSP4rW45xLCN0aelRq5Vl37xVVbz5kJ6Y0a9m2T0mCUjYCuvlUA9QlTafrZWw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^2.0.0", + "fastify-plugin": "^6.0.0" + } + }, + "node_modules/@fastify/cookie/node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.3.0.tgz", + "integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^0.5.4", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^11.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.0.tgz", + "integrity": "sha512-ZodJ2cRiLVWGi9IgPb3mbgSqM4CD3LexCHkuv0FfBXHJI1ADfucTD06m6clO2Cy5RZYsw/SiCVl/dyrFI/SYWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "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", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nostr-tools": { + "version": "2.23.9", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.9.tgz", + "integrity": "sha512-PBN3TpGh+EszlTZWpQdvcqEMqhcKcY1vTw0Xkkccg+TyP7y/icu9/iXJw72aFZ/ETm35ehU0hneGTXLaTSkImw==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..44b128a --- /dev/null +++ b/server/package.json @@ -0,0 +1,31 @@ +{ + "name": "podpuddle-server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/static": "^8.1.1", + "better-sqlite3": "^12.2.0", + "fastify": "^5.4.0", + "fastify-plugin": "^5.1.0", + "nostr-tools": "^2.15.0", + "ws": "^8.18.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^22.15.0", + "@types/ws": "^8.18.0", + "tsx": "^4.20.0", + "typescript": "^5.9.0", + "vitest": "^3.2.0" + } +} diff --git a/server/src/app.test.ts b/server/src/app.test.ts new file mode 100644 index 0000000..322f989 --- /dev/null +++ b/server/src/app.test.ts @@ -0,0 +1,298 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from './app.js'; +import { loadConfig } from './config.js'; + +const sk = generateSecretKey(); +const pk = getPublicKey(sk); + +let app: FastifyInstance; +let dataDir: string; +let cookie: string; + +function nip98Header(url: string, method: string): string { + const event = finalizeEvent( + { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: '', + // nonce keeps event ids unique when tests sign several events in one second + tags: [['u', url], ['method', method], ['nonce', Math.random().toString(36).slice(2)]], + }, + sk, + ); + return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`; +} + +beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'podpuddle-test-')); + const config = loadConfig({ + DATA_DIR: dataDir, + PUBLIC_URL: 'http://localhost:8095', + NOSTR_RELAYS: '', // no relay publishing in tests + } as NodeJS.ProcessEnv); + app = await buildApp({ config, dbPath: ':memory:', logger: false }); +}); + +afterAll(async () => { + await app.close(); + rmSync(dataDir, { recursive: true, force: true }); +}); + +describe('auth', () => { + it('rejects unauthenticated /api/auth/me', async () => { + const res = await app.inject({ method: 'GET', url: '/api/auth/me' }); + expect(res.statusCode).toBe(401); + }); + + it('logs in with a NIP-98 header and sets a session cookie', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + headers: { authorization: nip98Header('http://localhost:8095/api/auth/login', 'POST') }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().pubkey).toBe(pk); + expect(res.json().isAdmin).toBe(true); // first login claims admin + const setCookie = res.headers['set-cookie'] as string; + expect(setCookie).toContain('podpuddle_session='); + cookie = setCookie.split(';')[0]; + }); + + it('rejects a replayed login header', async () => { + const header = nip98Header('http://localhost:8095/api/auth/login', 'POST'); + const first = await app.inject({ method: 'POST', url: '/api/auth/login', headers: { authorization: header } }); + expect(first.statusCode).toBe(200); + const second = await app.inject({ method: 'POST', url: '/api/auth/login', headers: { authorization: header } }); + expect(second.statusCode).toBe(401); + expect(second.json().error).toMatch(/already used/); + }); + + it('serves /api/auth/me with the session cookie', async () => { + const res = await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie } }); + expect(res.statusCode).toBe(200); + expect(res.json().pubkey).toBe(pk); + }); +}); + +describe('podcasts, episodes, feed', () => { + let podcastId: string; + const sha = 'c'.repeat(64); + + it('creates a podcast', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/podcasts', + headers: { cookie }, + payload: { + title: 'My Show', + description: 'About things', + author: 'Tester', + lightning_address: 'tester@getalby.com', + }, + }); + expect(res.statusCode).toBe(201); + podcastId = res.json().id; + expect(res.json().podcast_guid).toMatch(/^[0-9a-f-]{36}$/); + }); + + it('registers an episode after verifying the blob on blossom', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(null, { status: 200, headers: { 'content-length': '1000' } }), + ), + ); + try { + const res = await app.inject({ + method: 'POST', + url: `/api/podcasts/${podcastId}/episodes`, + headers: { cookie }, + payload: { title: 'Ep 1', sha256: sha, size: 1000, mime: 'video/mp4' }, + }); + expect(res.statusCode).toBe(201); + expect(res.json().enclosure_url).toContain(`${sha}.mp4`); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('rejects an episode whose blob size mismatches', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(null, { status: 200, headers: { 'content-length': '999' } }), + ), + ); + try { + const res = await app.inject({ + method: 'POST', + url: `/api/podcasts/${podcastId}/episodes`, + headers: { cookie }, + payload: { title: 'Bad', sha256: 'd'.repeat(64), size: 1000 }, + }); + expect(res.statusCode).toBe(422); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('serves a valid feed with the lnaddress value block', async () => { + const res = await app.inject({ method: 'GET', url: `/feeds/${podcastId}/feed.xml` }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toContain('application/rss+xml'); + expect(res.body).toContain('method="lnaddress"'); + expect(res.body).toContain('tester@getalby.com'); + expect(res.body).toContain(`${sha}.mp4`); + + const etag = res.headers.etag as string; + const cached = await app.inject({ + method: 'GET', + url: `/feeds/${podcastId}/feed.xml`, + headers: { 'if-none-match': etag }, + }); + expect(cached.statusCode).toBe(304); + }); +}); + +describe('streams + mediamtx auth webhook', () => { + let streamId: string; + let streamKey: string; + + it('creates a stream and returns the key once', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/streams', + headers: { cookie }, + payload: { title: 'Live Test', summary: 'hi', hashtags: ['podpuddle'] }, + }); + expect(res.statusCode).toBe(201); + const body = res.json(); + streamId = body.id; + streamKey = body.whipBearer; + expect(body.streamKey).toBe(`${streamId}?key=${streamKey}`); + expect(body.hlsUrl).toContain(`/live/${streamId}/index.m3u8`); + expect(body.stream_key_hash).toBeUndefined(); // never leak the hash + }); + + it('allows publish with the right key (query form, as OBS sends it)', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/mediamtx/auth', + payload: { action: 'publish', path: `live/${streamId}`, query: `key=${streamKey}`, protocol: 'rtmp' }, + }); + expect(res.statusCode).toBe(200); + }); + + it('allows publish with the key as WHIP bearer password', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/mediamtx/auth', + payload: { action: 'publish', path: `live/${streamId}`, password: streamKey, protocol: 'webrtc' }, + }); + expect(res.statusCode).toBe(200); + }); + + it('denies publish with a wrong key', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/mediamtx/auth', + payload: { action: 'publish', path: `live/${streamId}`, query: 'key=wrong', protocol: 'rtmp' }, + }); + expect(res.statusCode).toBe(401); + }); + + it('denies publish to an unknown path', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/mediamtx/auth', + payload: { action: 'publish', path: 'live/nosuchstream', query: 'key=x' }, + }); + expect(res.statusCode).toBe(401); + }); + + it('allows reads without a key', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/mediamtx/auth', + payload: { action: 'read', path: `live/${streamId}` }, + }); + expect(res.statusCode).toBe(200); + }); + + it('rotating the key invalidates the old one', async () => { + const rot = await app.inject({ + method: 'POST', + url: `/api/streams/${streamId}/rotate-key`, + headers: { cookie }, + }); + expect(rot.statusCode).toBe(200); + const oldKey = await app.inject({ + method: 'POST', + url: '/api/mediamtx/auth', + payload: { action: 'publish', path: `live/${streamId}`, query: `key=${streamKey}` }, + }); + expect(oldKey.statusCode).toBe(401); + const newKey = await app.inject({ + method: 'POST', + url: '/api/mediamtx/auth', + payload: { action: 'publish', path: `live/${streamId}`, query: `key=${rot.json().whipBearer}` }, + }); + expect(newKey.statusCode).toBe(200); + }); +}); + +describe('settings', () => { + it('exposes public settings without auth', async () => { + const res = await app.inject({ method: 'GET', url: '/api/settings/public' }); + expect(res.statusCode).toBe(200); + expect(res.json().blossomUrl).toBeTruthy(); + expect(res.json().serverPubkey).toMatch(/^[0-9a-f]{64}$/); + }); + + it('lets the admin switch to an external blossom server', async () => { + const res = await app.inject({ + method: 'PUT', + url: '/api/settings', + headers: { cookie }, + payload: { blossom_url: 'https://blossom.example.com' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().blossom_url).toBe('https://blossom.example.com'); + + const pub = await app.inject({ method: 'GET', url: '/api/settings/public' }); + expect(pub.json().blossomUrl).toBe('https://blossom.example.com'); + }); + + it('blocks settings changes from non-admin users', async () => { + const sk2 = generateSecretKey(); + const event = finalizeEvent( + { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: [['u', 'http://localhost:8095/api/auth/login'], ['method', 'POST']], + }, + sk2, + ); + const login = await app.inject({ + method: 'POST', + url: '/api/auth/login', + headers: { authorization: `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}` }, + }); + expect(login.statusCode).toBe(200); + expect(login.json().isAdmin).toBe(false); + const cookie2 = (login.headers['set-cookie'] as string).split(';')[0]; + const res = await app.inject({ + method: 'PUT', + url: '/api/settings', + headers: { cookie: cookie2 }, + payload: { blossom_url: 'https://evil.example.com' }, + }); + expect(res.statusCode).toBe(403); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..899b9c7 --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,91 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import cookie from '@fastify/cookie'; +import fastifyStatic from '@fastify/static'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Config } from './config.js'; +import { openDatabase, type DB } from './db/database.js'; +import nostrAuth from './plugins/nostr-auth.js'; +import { SettingsService } from './services/settings.js'; +import { NostrPublisher, loadServerKey } from './services/nostr.js'; +import { MediamtxClient } from './services/mediamtx.js'; +import authRoutes from './routes/auth.js'; +import settingsRoutes from './routes/settings.js'; +import podcastRoutes from './routes/podcasts.js'; +import feedRoutes from './routes/feeds.js'; +import streamRoutes from './routes/streams.js'; +import mediamtxRoutes from './routes/mediamtx.js'; + +export interface AppContext { + config: Config; + db: DB; + settings: SettingsService; + publisher: NostrPublisher; + mediamtx: MediamtxClient; + serverKey: Uint8Array; +} + +declare module 'fastify' { + interface FastifyInstance { + ctx: AppContext; + } +} + +export interface BuildAppOptions { + config: Config; + dbPath?: string; // override for tests (':memory:') + logger?: boolean; +} + +export async function buildApp(opts: BuildAppOptions): Promise { + const { config } = opts; + const db = openDatabase(opts.dbPath ?? join(config.DATA_DIR, 'podpuddle.sqlite3')); + const settings = new SettingsService(db, config); + const serverKey = loadServerKey(config.DATA_DIR); + + const app = Fastify({ logger: opts.logger ?? true, bodyLimit: 10 * 1024 * 1024 }); + + const ctx: AppContext = { + config, + db, + settings, + publisher: new NostrPublisher(serverKey, { + info: (m) => app.log.info(m), + warn: (m) => app.log.warn(m), + }), + mediamtx: new MediamtxClient(config.MEDIAMTX_API_URL), + serverKey, + }; + app.decorate('ctx', ctx); + + await app.register(cookie); + await app.register(nostrAuth, { db, config }); + + app.get('/api/health', async () => ({ status: 'ok' })); + + await app.register(authRoutes); + await app.register(settingsRoutes); + await app.register(podcastRoutes); + await app.register(feedRoutes); + await app.register(streamRoutes); + await app.register(mediamtxRoutes); + + // Serve the built frontend (SPA fallback for client-side routes). + const staticDir = config.STATIC_DIR; + if (staticDir && existsSync(staticDir)) { + await app.register(fastifyStatic, { root: staticDir }); + app.setNotFoundHandler((req, reply) => { + if (req.raw.url?.startsWith('/api/') || req.raw.url?.startsWith('/feeds/')) { + return reply.code(404).send({ error: 'not found' }); + } + return reply.sendFile('index.html'); + }); + } + + app.addHook('onClose', async () => { + ctx.publisher.close(settings.all().relays); + db.close(); + }); + + return app; +} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..addcba4 --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + PORT: z.coerce.number().default(8095), + HOST: z.string().default('0.0.0.0'), + DATA_DIR: z.string().default('./data'), + RECORDINGS_DIR: z.string().default('/recordings'), + STATIC_DIR: z.string().optional(), + PUBLIC_URL: z.string().url().default('http://localhost:8095'), + MEDIAMTX_API_URL: z.string().url().default('http://mediamtx:9997'), + MEDIAMTX_RTMP_PUBLIC: z.string().default('rtmp://localhost:1935'), + MEDIAMTX_WHIP_PUBLIC: z.string().url().default('http://localhost:8889'), + MEDIAMTX_HLS_PUBLIC: z.string().url().default('http://localhost:8890'), + BLOSSOM_URL_DEFAULT: z.string().url().default('http://localhost:8098'), + NOSTR_RELAYS: z.string().default('wss://relay.damus.io,wss://nos.lol'), + NIP98_MAX_SKEW_SECS: z.coerce.number().default(60), + SESSION_TTL_DAYS: z.coerce.number().default(30), + MEDIAMTX_POLL_INTERVAL_MS: z.coerce.number().default(3000), +}); + +export type Config = z.infer & { defaultRelays: string[] }; + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { + const parsed = envSchema.parse(env); + return { + ...parsed, + defaultRelays: parsed.NOSTR_RELAYS.split(',') + .map((r) => r.trim()) + .filter(Boolean), + }; +} diff --git a/server/src/db/database.ts b/server/src/db/database.ts new file mode 100644 index 0000000..f8600e6 --- /dev/null +++ b/server/src/db/database.ts @@ -0,0 +1,30 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { migrations } from './migrations.js'; + +export type DB = Database.Database; + +export function openDatabase(path: string): DB { + if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true }); + const db = new Database(path); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + migrate(db); + return db; +} + +function migrate(db: DB): void { + db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)'); + const applied = new Set( + db.prepare('SELECT id FROM schema_migrations').all().map((r) => (r as { id: number }).id), + ); + const record = db.prepare('INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)'); + for (const m of migrations) { + if (applied.has(m.id)) continue; + db.transaction(() => { + db.exec(m.sql); + record.run(m.id, Math.floor(Date.now() / 1000)); + })(); + } +} diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts new file mode 100644 index 0000000..712150c --- /dev/null +++ b/server/src/db/migrations.ts @@ -0,0 +1,95 @@ +export interface Migration { + id: number; + sql: string; +} + +export const migrations: Migration[] = [ + { + id: 1, + sql: ` +CREATE TABLE users ( + pubkey TEXT PRIMARY KEY, + display_name TEXT, + lud16 TEXT, + created_at INTEGER NOT NULL, + last_login_at INTEGER NOT NULL +); + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + pubkey TEXT NOT NULL REFERENCES users(pubkey), + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE podcasts ( + id TEXT PRIMARY KEY, + owner_pubkey TEXT NOT NULL REFERENCES users(pubkey), + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + author TEXT NOT NULL DEFAULT '', + image_url TEXT, + language TEXT NOT NULL DEFAULT 'en', + category TEXT NOT NULL DEFAULT 'Technology', + explicit INTEGER NOT NULL DEFAULT 0, + lightning_address TEXT, + keysend_node TEXT, + value_suggested TEXT, + podcast_guid TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE episodes ( + id TEXT PRIMARY KEY, + podcast_id TEXT NOT NULL REFERENCES podcasts(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + sha256 TEXT NOT NULL, + enclosure_url TEXT NOT NULL, + enclosure_length INTEGER NOT NULL, + enclosure_type TEXT NOT NULL DEFAULT 'video/mp4', + duration_secs INTEGER, + season INTEGER, + episode_no INTEGER, + source TEXT NOT NULL DEFAULT 'upload' CHECK (source IN ('upload','recording')), + pub_date INTEGER NOT NULL, + created_at INTEGER NOT NULL +); +CREATE INDEX idx_episodes_podcast ON episodes(podcast_id, pub_date DESC); + +CREATE TABLE streams ( + id TEXT PRIMARY KEY, + owner_pubkey TEXT NOT NULL REFERENCES users(pubkey), + title TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + image_url TEXT, + hashtags TEXT NOT NULL DEFAULT '[]', + stream_key_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('planned','live','ended')), + starts_at INTEGER, + ended_at INTEGER, + created_at INTEGER NOT NULL +); + +CREATE TABLE recordings ( + id TEXT PRIMARY KEY, + stream_id TEXT NOT NULL REFERENCES streams(id) ON DELETE CASCADE, + segment_path TEXT NOT NULL UNIQUE, + started_at INTEGER NOT NULL, + duration_secs INTEGER, + published_episode_id TEXT REFERENCES episodes(id) +); + +CREATE TABLE auth_events ( + event_id TEXT PRIMARY KEY, + seen_at INTEGER NOT NULL +); +`, + }, +]; diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..6a18126 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,50 @@ +import { loadConfig } from './config.js'; +import { buildApp } from './app.js'; +import { startStatusPoller } from './services/mediamtx.js'; +import type { Stream } from './types.js'; + +const config = loadConfig(); +const app = await buildApp({ config }); + +const { db, publisher, settings, mediamtx } = app.ctx; +const getStream = db.prepare('SELECT * FROM streams WHERE id = ?'); +const liveIds = db.prepare("SELECT id FROM streams WHERE status = 'live'"); + +const stopPoller = startStatusPoller( + mediamtx, + config.MEDIAMTX_POLL_INTERVAL_MS, + () => (liveIds.all() as { id: string }[]).map((r) => r.id), + ({ streamId, live }) => { + const stream = getStream.get(streamId) as Stream | undefined; + if (!stream) return; + const now = Math.floor(Date.now() / 1000); + if (live && stream.status !== 'live') { + db.prepare("UPDATE streams SET status = 'live', starts_at = COALESCE(starts_at, ?) WHERE id = ?") + .run(now, streamId); + } else if (!live && stream.status === 'live') { + db.prepare("UPDATE streams SET status = 'ended', ended_at = ? WHERE id = ?").run(now, streamId); + } else { + return; + } + const updated = getStream.get(streamId) as Stream; + const hlsUrl = `${config.MEDIAMTX_HLS_PUBLIC}/live/${streamId}/index.m3u8`; + void publisher + .publishLiveEvent(settings.all().relays, { + stream: updated, + status: live ? 'live' : 'ended', + hlsUrl, + }) + .catch((err) => app.log.warn(`30311 publish failed: ${err.message}`)); + app.log.info(`stream ${streamId} is now ${live ? 'live' : 'ended'}`); + }, + { warn: (m) => app.log.debug(m) }, +); + +app.addHook('onClose', async () => stopPoller()); + +try { + await app.listen({ port: config.PORT, host: config.HOST }); +} catch (err) { + app.log.error(err); + process.exit(1); +} diff --git a/server/src/plugins/nostr-auth.ts b/server/src/plugins/nostr-auth.ts new file mode 100644 index 0000000..9ae77c6 --- /dev/null +++ b/server/src/plugins/nostr-auth.ts @@ -0,0 +1,114 @@ +import { randomBytes } from 'node:crypto'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import fp from 'fastify-plugin'; +import type { DB } from '../db/database.js'; +import type { Config } from '../config.js'; +import { Nip98Error, verifyNip98 } from '../services/nip98.js'; + +export const SESSION_COOKIE = 'podpuddle_session'; + +declare module 'fastify' { + interface FastifyInstance { + requireAuth: (req: FastifyRequest, reply: FastifyReply) => Promise; + verifyNip98Request: (req: FastifyRequest) => string; + createSession: (pubkey: string, reply: FastifyReply) => void; + destroySession: (req: FastifyRequest, reply: FastifyReply) => void; + } +} + +function nowSecs(): number { + return Math.floor(Date.now() / 1000); +} + +export interface NostrAuthOptions { + db: DB; + config: Config; +} + +export default fp(async function nostrAuth(app: FastifyInstance, opts: NostrAuthOptions) { + const { db, config } = opts; + + const insertAuthEvent = db.prepare('INSERT OR IGNORE INTO auth_events (event_id, seen_at) VALUES (?, ?)'); + const pruneAuthEvents = db.prepare('DELETE FROM auth_events WHERE seen_at < ?'); + const insertSession = db.prepare('INSERT INTO sessions (id, pubkey, created_at, expires_at) VALUES (?, ?, ?, ?)'); + const selectSession = db.prepare('SELECT pubkey, expires_at FROM sessions WHERE id = ?'); + const deleteSession = db.prepare('DELETE FROM sessions WHERE id = ?'); + const pruneSessions = db.prepare('DELETE FROM sessions WHERE expires_at < ?'); + + app.decorateRequest('userPubkey', null); + + /** Candidate URLs the client may have signed: configured public URL + the Host header it used. */ + function candidateUrls(req: FastifyRequest): string[] { + const publicOrigin = new URL(config.PUBLIC_URL).origin; + const urls = [`${publicOrigin}${req.raw.url}`]; + const host = req.headers['x-forwarded-host'] ?? req.headers.host; + if (host) { + const proto = (req.headers['x-forwarded-proto'] as string | undefined) ?? 'http'; + urls.push(`${proto}://${host}${req.raw.url}`); + } + return urls; + } + + function verifyNip98Request(req: FastifyRequest): string { + const body = req.body != null && typeof req.body === 'object' + ? Buffer.from(JSON.stringify(req.body)) + : null; + const pubkey = verifyNip98(req.headers.authorization, { + allowedUrls: candidateUrls(req), + method: req.method, + body, + maxSkewSecs: config.NIP98_MAX_SKEW_SECS, + isReplay: (eventId) => { + pruneAuthEvents.run(nowSecs() - config.NIP98_MAX_SKEW_SECS * 4); + const inserted = insertAuthEvent.run(eventId, nowSecs()).changes; + return inserted === 0; + }, + }); + return pubkey; + } + + app.decorate('verifyNip98Request', verifyNip98Request); + + app.decorate('createSession', (pubkey: string, reply: FastifyReply) => { + pruneSessions.run(nowSecs()); + const id = randomBytes(32).toString('hex'); + insertSession.run(id, pubkey, nowSecs(), nowSecs() + config.SESSION_TTL_DAYS * 86400); + reply.setCookie(SESSION_COOKIE, id, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: config.SESSION_TTL_DAYS * 86400, + }); + }); + + app.decorate('destroySession', (req: FastifyRequest, reply: FastifyReply) => { + const id = req.cookies[SESSION_COOKIE]; + if (id) deleteSession.run(id); + reply.clearCookie(SESSION_COOKIE, { path: '/' }); + }); + + app.decorate('requireAuth', async (req: FastifyRequest, reply: FastifyReply) => { + // Session cookie first (the normal browser path)… + const sessionId = req.cookies[SESSION_COOKIE]; + if (sessionId) { + const row = selectSession.get(sessionId) as { pubkey: string; expires_at: number } | undefined; + if (row && row.expires_at > nowSecs()) { + req.userPubkey = row.pubkey; + return; + } + } + // …then a NIP-98 header (CLI / scripting path). + if (req.headers.authorization?.startsWith('Nostr ')) { + try { + req.userPubkey = verifyNip98Request(req); + return; + } catch (err) { + if (err instanceof Nip98Error) { + return reply.code(401).send({ error: err.message }); + } + throw err; + } + } + return reply.code(401).send({ error: 'not authenticated' }); + }); +}); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..7c702de --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,59 @@ +import type { FastifyInstance } from 'fastify'; +import { Nip98Error } from '../services/nip98.js'; +import type { User } from '../types.js'; + +function nowSecs(): number { + return Math.floor(Date.now() / 1000); +} + +export default async function authRoutes(app: FastifyInstance) { + const { db, settings } = app.ctx; + + const upsertUser = db.prepare(` + INSERT INTO users (pubkey, created_at, last_login_at) VALUES (?, ?, ?) + ON CONFLICT(pubkey) DO UPDATE SET last_login_at = excluded.last_login_at + `); + const selectUser = db.prepare('SELECT * FROM users WHERE pubkey = ?'); + const updateProfile = db.prepare('UPDATE users SET display_name = ?, lud16 = ? WHERE pubkey = ?'); + + app.post('/api/auth/login', async (req, reply) => { + let pubkey: string; + try { + pubkey = app.verifyNip98Request(req); + } catch (err) { + if (err instanceof Nip98Error) return reply.code(401).send({ error: err.message }); + throw err; + } + upsertUser.run(pubkey, nowSecs(), nowSecs()); + settings.claimAdminIfUnset(pubkey); + + // Optional profile hints from the client (it has the user's kind-0 metadata). + const body = req.body as { displayName?: string; lud16?: string } | null; + if (body && (body.displayName || body.lud16)) { + const existing = selectUser.get(pubkey) as User; + updateProfile.run( + body.displayName ?? existing.display_name, + body.lud16 ?? existing.lud16, + pubkey, + ); + } + + app.createSession(pubkey, reply); + return { pubkey, isAdmin: settings.isAdmin(pubkey) }; + }); + + app.post('/api/auth/logout', async (req, reply) => { + app.destroySession(req, reply); + return { ok: true }; + }); + + app.get('/api/auth/me', { preHandler: app.requireAuth }, async (req) => { + const user = selectUser.get(req.userPubkey) as User | undefined; + return { + pubkey: req.userPubkey, + displayName: user?.display_name ?? null, + lud16: user?.lud16 ?? null, + isAdmin: settings.isAdmin(req.userPubkey!), + }; + }); +} diff --git a/server/src/routes/feeds.ts b/server/src/routes/feeds.ts new file mode 100644 index 0000000..f515e8a --- /dev/null +++ b/server/src/routes/feeds.ts @@ -0,0 +1,28 @@ +import { createHash } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import { buildFeedXml } from '../services/rss.js'; +import type { Episode, Podcast } from '../types.js'; + +export default async function feedRoutes(app: FastifyInstance) { + const { db, settings } = app.ctx; + + const getPodcast = db.prepare('SELECT * FROM podcasts WHERE id = ?'); + const listEpisodes = db.prepare('SELECT * FROM episodes WHERE podcast_id = ? ORDER BY pub_date DESC'); + + app.get('/feeds/:id/feed.xml', async (req, reply) => { + const { id } = req.params as { id: string }; + const podcast = getPodcast.get(id) as Podcast | undefined; + if (!podcast) return reply.code(404).send({ error: 'feed not found' }); + + const episodes = listEpisodes.all(id) as Episode[]; + const xml = buildFeedXml(podcast, episodes, { publicUrl: settings.all().public_url }); + const etag = `"${createHash('sha256').update(xml).digest('hex').slice(0, 16)}"`; + + if (req.headers['if-none-match'] === etag) return reply.code(304).send(); + return reply + .header('content-type', 'application/rss+xml; charset=utf-8') + .header('etag', etag) + .header('cache-control', 'public, max-age=60') + .send(xml); + }); +} diff --git a/server/src/routes/mediamtx.ts b/server/src/routes/mediamtx.ts new file mode 100644 index 0000000..3e6c285 --- /dev/null +++ b/server/src/routes/mediamtx.ts @@ -0,0 +1,51 @@ +import type { FastifyInstance } from 'fastify'; +import { verifyStreamKey } from '../services/streamkeys.js'; +import type { Stream } from '../types.js'; + +interface AuthRequest { + user?: string; + password?: string; + token?: string; + ip?: string; + action?: string; + path?: string; + protocol?: string; + query?: string; +} + +/** + * MediaMTX `authMethod: http` webhook. 2xx allows the connection, anything else denies. + * Publishing to live/ requires the stream secret (query `?key=`, password, + * or WHIP bearer token); reading (HLS/WebRTC playback) is public. + */ +export default async function mediamtxRoutes(app: FastifyInstance) { + const { db } = app.ctx; + const getStream = db.prepare('SELECT * FROM streams WHERE id = ?'); + + app.post('/api/mediamtx/auth', async (req, reply) => { + const body = (req.body ?? {}) as AuthRequest; + + if (body.action !== 'publish') return reply.code(200).send({}); + + const match = body.path?.match(/^live\/([A-Za-z0-9]+)$/); + if (!match) { + app.log.warn(`mediamtx publish denied: unexpected path ${body.path}`); + return reply.code(401).send({ error: 'unknown path' }); + } + const stream = getStream.get(match[1]) as Stream | undefined; + if (!stream) { + app.log.warn(`mediamtx publish denied: no stream ${match[1]}`); + return reply.code(401).send({ error: 'unknown stream' }); + } + + const queryKey = new URLSearchParams(body.query ?? '').get('key'); + const candidates = [queryKey, body.password, body.token, body.user].filter( + (k): k is string => !!k, + ); + if (candidates.some((k) => verifyStreamKey(k, stream.stream_key_hash))) { + return reply.code(200).send({}); + } + app.log.warn(`mediamtx publish denied: bad key for stream ${match[1]} from ${body.ip}`); + return reply.code(401).send({ error: 'invalid stream key' }); + }); +} diff --git a/server/src/routes/podcasts.ts b/server/src/routes/podcasts.ts new file mode 100644 index 0000000..38c6844 --- /dev/null +++ b/server/src/routes/podcasts.ts @@ -0,0 +1,169 @@ +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { podcastGuidForFeedUrl } from '../services/rss.js'; +import { checkBlob } from '../services/blossom.js'; +import type { Episode, Podcast } from '../types.js'; + +function nowSecs(): number { + return Math.floor(Date.now() / 1000); +} + +const podcastSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(5000).default(''), + author: z.string().max(200).default(''), + image_url: z.string().url().nullish(), + language: z.string().max(10).default('en'), + category: z.string().max(100).default('Technology'), + explicit: z.boolean().default(false), + lightning_address: z.string().regex(/^[\w.+-]+@[\w.-]+$/, 'expected name@domain').nullish(), + keysend_node: z.string().regex(/^[0-9a-f]{66}$/i, 'expected 33-byte hex node pubkey').nullish(), + value_suggested: z.string().max(20).nullish(), +}); + +const episodeSchema = z.object({ + title: z.string().min(1).max(300), + description: z.string().max(10000).default(''), + sha256: z.string().regex(/^[0-9a-f]{64}$/), + size: z.number().int().positive(), + mime: z.string().default('video/mp4'), + blossomUrl: z.string().url().optional(), + duration_secs: z.number().int().positive().nullish(), + season: z.number().int().positive().nullish(), + episode_no: z.number().int().positive().nullish(), + pub_date: z.number().int().positive().optional(), +}); + +export default async function podcastRoutes(app: FastifyInstance) { + const { db, settings } = app.ctx; + + const listPodcasts = db.prepare('SELECT * FROM podcasts WHERE owner_pubkey = ? ORDER BY created_at'); + const getPodcast = db.prepare('SELECT * FROM podcasts WHERE id = ?'); + const listEpisodes = db.prepare('SELECT * FROM episodes WHERE podcast_id = ? ORDER BY pub_date DESC'); + const getEpisode = db.prepare('SELECT * FROM episodes WHERE id = ? AND podcast_id = ?'); + + function ownedPodcast(id: string, pubkey: string): Podcast | null { + const p = getPodcast.get(id) as Podcast | undefined; + return p && p.owner_pubkey === pubkey ? p : null; + } + + app.get('/api/podcasts', { preHandler: app.requireAuth }, async (req) => { + const podcasts = listPodcasts.all(req.userPubkey) as Podcast[]; + return podcasts.map((p) => ({ + ...p, + feed_url: `${settings.all().public_url}/feeds/${p.id}/feed.xml`, + })); + }); + + app.post('/api/podcasts', { preHandler: app.requireAuth }, async (req, reply) => { + const parsed = podcastSchema.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: parsed.error.message }); + const d = parsed.data; + const id = randomUUID(); + const feedUrl = `${settings.all().public_url}/feeds/${id}/feed.xml`; + db.prepare(` + INSERT INTO podcasts (id, owner_pubkey, title, description, author, image_url, language, + category, explicit, lightning_address, keysend_node, value_suggested, podcast_guid, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, req.userPubkey, d.title, d.description, d.author, d.image_url ?? null, d.language, + d.category, d.explicit ? 1 : 0, d.lightning_address ?? null, d.keysend_node ?? null, + d.value_suggested ?? null, podcastGuidForFeedUrl(feedUrl), nowSecs(), nowSecs(), + ); + return reply.code(201).send({ ...(getPodcast.get(id) as Podcast), feed_url: feedUrl }); + }); + + app.get('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => { + const { id } = req.params as { id: string }; + const p = ownedPodcast(id, req.userPubkey!); + if (!p) return reply.code(404).send({ error: 'podcast not found' }); + return { + ...p, + feed_url: `${settings.all().public_url}/feeds/${p.id}/feed.xml`, + episodes: listEpisodes.all(id) as Episode[], + }; + }); + + app.put('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => { + const { id } = req.params as { id: string }; + const p = ownedPodcast(id, req.userPubkey!); + if (!p) return reply.code(404).send({ error: 'podcast not found' }); + const parsed = podcastSchema.partial().safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: parsed.error.message }); + const d = { ...p, ...parsed.data, explicit: (parsed.data.explicit ?? !!p.explicit) ? 1 : 0 }; + db.prepare(` + UPDATE podcasts SET title=?, description=?, author=?, image_url=?, language=?, category=?, + explicit=?, lightning_address=?, keysend_node=?, value_suggested=?, updated_at=? + WHERE id=? + `).run( + d.title, d.description, d.author, d.image_url ?? null, d.language, d.category, d.explicit, + d.lightning_address ?? null, d.keysend_node ?? null, d.value_suggested ?? null, nowSecs(), id, + ); + return getPodcast.get(id) as Podcast; + }); + + app.delete('/api/podcasts/:id', { preHandler: app.requireAuth }, async (req, reply) => { + const { id } = req.params as { id: string }; + if (!ownedPodcast(id, req.userPubkey!)) return reply.code(404).send({ error: 'podcast not found' }); + db.prepare('DELETE FROM podcasts WHERE id = ?').run(id); + return { ok: true }; + }); + + // Register a blob the browser already uploaded to blossom as a new episode. + app.post('/api/podcasts/:id/episodes', { preHandler: app.requireAuth }, async (req, reply) => { + const { id } = req.params as { id: string }; + if (!ownedPodcast(id, req.userPubkey!)) return reply.code(404).send({ error: 'podcast not found' }); + const parsed = episodeSchema.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: parsed.error.message }); + const d = parsed.data; + + const blossomUrl = d.blossomUrl ?? settings.all().blossom_url; + const blob = await checkBlob(blossomUrl, d.sha256); + if (!blob.exists) { + return reply.code(422).send({ error: `blob ${d.sha256} not found on ${blossomUrl}` }); + } + if (blob.size != null && blob.size !== d.size) { + return reply.code(422).send({ error: `blob size mismatch (server has ${blob.size}, claimed ${d.size})` }); + } + + const ext = d.mime === 'audio/mpeg' ? 'mp3' : d.mime === 'audio/mp4' ? 'm4a' : 'mp4'; + const enclosureUrl = `${blossomUrl.replace(/\/+$/, '')}/${d.sha256}.${ext}`; + const eid = randomUUID(); + db.prepare(` + INSERT INTO episodes (id, podcast_id, title, description, sha256, enclosure_url, + enclosure_length, enclosure_type, duration_secs, season, episode_no, source, pub_date, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?) + `).run( + eid, id, d.title, d.description, d.sha256, enclosureUrl, d.size, d.mime, + d.duration_secs ?? null, d.season ?? null, d.episode_no ?? null, + d.pub_date ?? nowSecs(), nowSecs(), + ); + db.prepare('UPDATE podcasts SET updated_at = ? WHERE id = ?').run(nowSecs(), id); + return reply.code(201).send(getEpisode.get(eid, id) as Episode); + }); + + app.put('/api/podcasts/:id/episodes/:eid', { preHandler: app.requireAuth }, async (req, reply) => { + const { id, eid } = req.params as { id: string; eid: string }; + if (!ownedPodcast(id, req.userPubkey!)) return reply.code(404).send({ error: 'podcast not found' }); + const episode = getEpisode.get(eid, id) as Episode | undefined; + if (!episode) return reply.code(404).send({ error: 'episode not found' }); + const parsed = episodeSchema.partial().safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: parsed.error.message }); + const d = { ...episode, ...parsed.data }; + db.prepare(` + UPDATE episodes SET title=?, description=?, duration_secs=?, season=?, episode_no=?, pub_date=? + WHERE id=? + `).run(d.title, d.description, d.duration_secs ?? null, d.season ?? null, + d.episode_no ?? null, d.pub_date, eid); + return getEpisode.get(eid, id) as Episode; + }); + + app.delete('/api/podcasts/:id/episodes/:eid', { preHandler: app.requireAuth }, async (req, reply) => { + const { id, eid } = req.params as { id: string; eid: string }; + if (!ownedPodcast(id, req.userPubkey!)) return reply.code(404).send({ error: 'podcast not found' }); + db.prepare('DELETE FROM episodes WHERE id = ? AND podcast_id = ?').run(eid, id); + return { ok: true }; + }); +} diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts new file mode 100644 index 0000000..59b9501 --- /dev/null +++ b/server/src/routes/settings.ts @@ -0,0 +1,41 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; + +const updateSchema = z.object({ + blossom_url: z.string().url().optional(), + relays: z.array(z.string().regex(/^wss?:\/\//)).optional(), + public_url: z.string().url().optional(), +}); + +export default async function settingsRoutes(app: FastifyInstance) { + const { settings, config, publisher } = app.ctx; + + // Unauthenticated bootstrap info the wizard needs before/at login. + app.get('/api/settings/public', async () => { + const s = settings.all(); + return { + blossomUrl: s.blossom_url, + relays: s.relays, + publicUrl: s.public_url, + serverPubkey: publisher.pubkey, + rtmpPublic: config.MEDIAMTX_RTMP_PUBLIC, + whipPublic: config.MEDIAMTX_WHIP_PUBLIC, + hlsPublic: config.MEDIAMTX_HLS_PUBLIC, + }; + }); + + app.get('/api/settings', { preHandler: app.requireAuth }, async () => settings.all()); + + app.put('/api/settings', { preHandler: app.requireAuth }, async (req, reply) => { + if (!settings.isAdmin(req.userPubkey!)) { + return reply.code(403).send({ error: 'only the admin can change settings' }); + } + const parsed = updateSchema.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: parsed.error.message }); + const { blossom_url, relays, public_url } = parsed.data; + if (blossom_url !== undefined) settings.set('blossom_url', blossom_url); + if (relays !== undefined) settings.set('relays', JSON.stringify(relays)); + if (public_url !== undefined) settings.set('public_url', public_url); + return settings.all(); + }); +} diff --git a/server/src/routes/streams.ts b/server/src/routes/streams.ts new file mode 100644 index 0000000..c59235d --- /dev/null +++ b/server/src/routes/streams.ts @@ -0,0 +1,184 @@ +import { randomUUID } from 'node:crypto'; +import { readdir, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { generateStreamId, generateStreamKey, hashStreamKey } from '../services/streamkeys.js'; +import { remuxToMp4, probeDurationSecs } from '../services/ffmpeg.js'; +import { uploadFile } from '../services/blossom.js'; +import type { Episode, Podcast, Stream } from '../types.js'; + +function nowSecs(): number { + return Math.floor(Date.now() / 1000); +} + +const streamSchema = z.object({ + title: z.string().min(1).max(300), + summary: z.string().max(5000).default(''), + image_url: z.string().url().nullish(), + hashtags: z.array(z.string().max(50)).max(20).default([]), +}); + +const publishRecordingSchema = z.object({ + file: z.string().min(1), + podcast_id: z.string().uuid(), + title: z.string().min(1).max(300), + description: z.string().max(10000).default(''), +}); + +export default async function streamRoutes(app: FastifyInstance) { + const { db, config, settings, publisher } = app.ctx; + + const getStream = db.prepare('SELECT * FROM streams WHERE id = ?'); + const listStreams = db.prepare('SELECT * FROM streams WHERE owner_pubkey = ? ORDER BY created_at DESC'); + const getPodcast = db.prepare('SELECT * FROM podcasts WHERE id = ?'); + + function ownedStream(id: string, pubkey: string): Stream | null { + const s = getStream.get(id) as Stream | undefined; + return s && s.owner_pubkey === pubkey ? s : null; + } + + function streamUrls(id: string) { + return { + rtmpUrl: `${config.MEDIAMTX_RTMP_PUBLIC}/live`, + whipUrl: `${config.MEDIAMTX_WHIP_PUBLIC}/live/${id}/whip`, + hlsUrl: `${config.MEDIAMTX_HLS_PUBLIC}/live/${id}/index.m3u8`, + }; + } + + function publicStream(s: Stream) { + const { stream_key_hash: _, ...rest } = s; + return { ...rest, hashtags: JSON.parse(s.hashtags), ...streamUrls(s.id) }; + } + + app.post('/api/streams', { preHandler: app.requireAuth }, async (req, reply) => { + const parsed = streamSchema.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: parsed.error.message }); + const d = parsed.data; + const id = generateStreamId(); + const key = generateStreamKey(); + db.prepare(` + INSERT INTO streams (id, owner_pubkey, title, summary, image_url, hashtags, stream_key_hash, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, req.userPubkey, d.title, d.summary, d.image_url ?? null, + JSON.stringify(d.hashtags), hashStreamKey(key), nowSecs()); + const s = getStream.get(id) as Stream; + return reply.code(201).send({ + ...publicStream(s), + // Shown once — OBS wants "streamId?key=secret" as the stream key. + streamKey: `${id}?key=${key}`, + whipBearer: key, + }); + }); + + app.get('/api/streams', { preHandler: app.requireAuth }, async (req) => { + return (listStreams.all(req.userPubkey) as Stream[]).map(publicStream); + }); + + app.get('/api/streams/:id', { preHandler: app.requireAuth }, async (req, reply) => { + const s = ownedStream((req.params as { id: string }).id, req.userPubkey!); + if (!s) return reply.code(404).send({ error: 'stream not found' }); + return publicStream(s); + }); + + app.put('/api/streams/:id', { preHandler: app.requireAuth }, async (req, reply) => { + const { id } = req.params as { id: string }; + const s = ownedStream(id, req.userPubkey!); + if (!s) return reply.code(404).send({ error: 'stream not found' }); + const parsed = streamSchema.partial().safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: parsed.error.message }); + const d = { ...s, ...parsed.data, hashtags: JSON.stringify(parsed.data.hashtags ?? JSON.parse(s.hashtags)) }; + db.prepare('UPDATE streams SET title=?, summary=?, image_url=?, hashtags=? WHERE id=?') + .run(d.title, d.summary, d.image_url ?? null, d.hashtags, id); + const updated = getStream.get(id) as Stream; + if (updated.status === 'live') { + await publisher.publishLiveEvent(settings.all().relays, { + stream: updated, status: 'live', hlsUrl: streamUrls(id).hlsUrl, + }); + } + return publicStream(updated); + }); + + app.post('/api/streams/:id/rotate-key', { preHandler: app.requireAuth }, async (req, reply) => { + const { id } = req.params as { id: string }; + if (!ownedStream(id, req.userPubkey!)) return reply.code(404).send({ error: 'stream not found' }); + const key = generateStreamKey(); + db.prepare('UPDATE streams SET stream_key_hash = ? WHERE id = ?').run(hashStreamKey(key), id); + return { streamKey: `${id}?key=${key}`, whipBearer: key }; + }); + + app.post('/api/streams/:id/end', { preHandler: app.requireAuth }, async (req, reply) => { + const { id } = req.params as { id: string }; + const s = ownedStream(id, req.userPubkey!); + if (!s) return reply.code(404).send({ error: 'stream not found' }); + db.prepare("UPDATE streams SET status = 'ended', ended_at = ? WHERE id = ?").run(nowSecs(), id); + const updated = getStream.get(id) as Stream; + await publisher.publishLiveEvent(settings.all().relays, { + stream: updated, status: 'ended', hlsUrl: streamUrls(id).hlsUrl, + }); + return publicStream(updated); + }); + + app.delete('/api/streams/:id', { preHandler: app.requireAuth }, async (req, reply) => { + const { id } = req.params as { id: string }; + if (!ownedStream(id, req.userPubkey!)) return reply.code(404).send({ error: 'stream not found' }); + db.prepare('DELETE FROM streams WHERE id = ?').run(id); + return { ok: true }; + }); + + // Recorded segments for a stream, straight from the shared recordings volume. + app.get('/api/streams/:id/recordings', { preHandler: app.requireAuth }, async (req, reply) => { + const { id } = req.params as { id: string }; + if (!ownedStream(id, req.userPubkey!)) return reply.code(404).send({ error: 'stream not found' }); + const dir = join(config.RECORDINGS_DIR, 'live', id); + let files: string[] = []; + try { + files = (await readdir(dir)).filter((f) => f.endsWith('.mp4')).sort(); + } catch { + // no recordings yet + } + return files.map((f) => ({ file: f, path: `live/${id}/${f}` })); + }); + + // Remux a recording, upload it to blossom with the server's key, publish as an episode. + app.post('/api/streams/:id/recordings/publish', { preHandler: app.requireAuth }, async (req, reply) => { + const { id } = req.params as { id: string }; + if (!ownedStream(id, req.userPubkey!)) return reply.code(404).send({ error: 'stream not found' }); + const parsed = publishRecordingSchema.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: parsed.error.message }); + const d = parsed.data; + + const podcast = getPodcast.get(d.podcast_id) as Podcast | undefined; + if (!podcast || podcast.owner_pubkey !== req.userPubkey) { + return reply.code(404).send({ error: 'podcast not found' }); + } + if (d.file.includes('/') || d.file.includes('..')) { + return reply.code(400).send({ error: 'invalid file name' }); + } + const source = join(config.RECORDINGS_DIR, 'live', id, d.file); + + const tmpDir = join(config.DATA_DIR, 'tmp'); + await mkdir(tmpDir, { recursive: true }); + const tmpOut = join(tmpDir, `${randomUUID()}.mp4`); + try { + await remuxToMp4(source, tmpOut); + const duration = await probeDurationSecs(tmpOut).catch(() => null); + const blossomUrl = settings.all().blossom_url; + const uploaded = await uploadFile(blossomUrl, tmpOut, 'video/mp4', app.ctx.serverKey); + + const eid = randomUUID(); + db.prepare(` + INSERT INTO episodes (id, podcast_id, title, description, sha256, enclosure_url, + enclosure_length, enclosure_type, duration_secs, source, pub_date, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'video/mp4', ?, 'recording', ?, ?) + `).run(eid, d.podcast_id, d.title, d.description, uploaded.sha256, + `${uploaded.url}.mp4`, uploaded.size, duration, nowSecs(), nowSecs()); + db.prepare('UPDATE podcasts SET updated_at = ? WHERE id = ?').run(nowSecs(), d.podcast_id); + return reply.code(201).send( + db.prepare('SELECT * FROM episodes WHERE id = ?').get(eid) as Episode, + ); + } finally { + await rm(tmpOut, { force: true }); + } + }); +} diff --git a/server/src/services/blossom.ts b/server/src/services/blossom.ts new file mode 100644 index 0000000..127b892 --- /dev/null +++ b/server/src/services/blossom.ts @@ -0,0 +1,70 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { finalizeEvent } from 'nostr-tools/pure'; + +export interface BlobCheck { + exists: boolean; + size: number | null; +} + +/** HEAD a blob on a blossom server to confirm it exists and matches the claimed size. */ +export async function checkBlob(blossomUrl: string, sha256: string): Promise { + const res = await fetch(`${blossomUrl.replace(/\/+$/, '')}/${sha256}`, { method: 'HEAD' }); + if (!res.ok) return { exists: false, size: null }; + const len = res.headers.get('content-length'); + return { exists: true, size: len ? Number(len) : null }; +} + +/** Build a BUD-02 upload authorization event (kind 24242) signed with the given secret key. */ +export function buildUploadAuth(secretKey: Uint8Array, sha256: string, description: string) { + const now = Math.floor(Date.now() / 1000); + return finalizeEvent( + { + kind: 24242, + created_at: now, + content: description, + tags: [ + ['t', 'upload'], + ['x', sha256], + ['expiration', String(now + 600)], + ], + }, + secretKey, + ); +} + +export interface UploadResult { + sha256: string; + size: number; + url: string; +} + +/** + * Server-side blossom upload (used for publishing stream recordings, which live on + * the server and are signed with the server's own nostr key). + */ +export async function uploadFile( + blossomUrl: string, + filePath: string, + mime: string, + secretKey: Uint8Array, +): Promise { + const data = await readFile(filePath); + const sha256 = createHash('sha256').update(data).digest('hex'); + const auth = buildUploadAuth(secretKey, sha256, `Upload ${sha256}`); + const base = blossomUrl.replace(/\/+$/, ''); + const res = await fetch(`${base}/upload`, { + method: 'PUT', + headers: { + authorization: `Nostr ${Buffer.from(JSON.stringify(auth)).toString('base64')}`, + 'content-type': mime, + 'content-length': String(data.length), + }, + body: data, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`blossom upload failed: ${res.status} ${text.slice(0, 200)}`); + } + return { sha256, size: data.length, url: `${base}/${sha256}` }; +} diff --git a/server/src/services/ffmpeg.ts b/server/src/services/ffmpeg.ts new file mode 100644 index 0000000..695a96e --- /dev/null +++ b/server/src/services/ffmpeg.ts @@ -0,0 +1,20 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const run = promisify(execFile); + +/** Remux an fmp4 recording segment into a plain faststart mp4 (no re-encode). */ +export async function remuxToMp4(input: string, output: string): Promise { + await run('ffmpeg', ['-y', '-i', input, '-c', 'copy', '-movflags', '+faststart', output], { + timeout: 10 * 60 * 1000, + }); +} + +export async function probeDurationSecs(file: string): Promise { + const { stdout } = await run('ffprobe', [ + '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file, + ]); + const secs = Number.parseFloat(stdout.trim()); + if (!Number.isFinite(secs)) throw new Error(`could not probe duration of ${file}`); + return Math.round(secs); +} diff --git a/server/src/services/mediamtx.ts b/server/src/services/mediamtx.ts new file mode 100644 index 0000000..677c1d6 --- /dev/null +++ b/server/src/services/mediamtx.ts @@ -0,0 +1,86 @@ +export interface MediamtxPath { + name: string; + ready: boolean; +} + +export interface RecordingSegment { + start: string; // ISO timestamp +} + +export interface RecordingEntry { + name: string; // path name, e.g. live/abc123 + segments: RecordingSegment[]; +} + +export class MediamtxClient { + constructor(private apiUrl: string) {} + + private async get(path: string): Promise { + const res = await fetch(`${this.apiUrl}${path}`); + if (!res.ok) throw new Error(`mediamtx api ${path}: ${res.status}`); + return (await res.json()) as T; + } + + /** Names of paths that currently have a publisher connected and ready. */ + async readyPaths(): Promise> { + const data = await this.get<{ items: MediamtxPath[] }>('/v3/paths/list?itemsPerPage=500'); + return new Set(data.items.filter((p) => p.ready).map((p) => p.name)); + } + + async recordings(pathName: string): Promise { + try { + return await this.get(`/v3/recordings/get/${encodeURIComponent(pathName)}`); + } catch { + return null; + } + } +} + +export type StatusChange = { streamId: string; live: boolean }; + +/** + * Polls the MediaMTX API and reports live/ended transitions for `live/` paths. + * (The stock MediaMTX image has no shell, so webhook-style runOn* hooks are not an + * option — polling the API is the reliable path.) + */ +export function startStatusPoller( + client: MediamtxClient, + intervalMs: number, + getLiveStreamIds: () => string[], + onChange: (change: StatusChange) => void, + log: { warn: (msg: string) => void }, +): () => void { + let stopped = false; + const known = new Set(); // stream ids we've seen ready + + async function tick() { + if (stopped) return; + try { + const ready = await client.readyPaths(); + const readyIds = new Set( + [...ready].filter((n) => n.startsWith('live/')).map((n) => n.slice('live/'.length)), + ); + for (const id of readyIds) { + if (!known.has(id)) { + known.add(id); + onChange({ streamId: id, live: true }); + } + } + for (const id of [...known, ...getLiveStreamIds()]) { + if (!readyIds.has(id)) { + known.delete(id); + onChange({ streamId: id, live: false }); + } + } + } catch (err) { + log.warn(`mediamtx poll failed: ${(err as Error).message}`); + } + if (!stopped) timer = setTimeout(tick, intervalMs); + } + + let timer = setTimeout(tick, intervalMs); + return () => { + stopped = true; + clearTimeout(timer); + }; +} diff --git a/server/src/services/nip98.test.ts b/server/src/services/nip98.test.ts new file mode 100644 index 0000000..34a701f --- /dev/null +++ b/server/src/services/nip98.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { verifyNip98, NIP98_KIND, Nip98Error } from './nip98.js'; + +const sk = generateSecretKey(); +const pk = getPublicKey(sk); +const URL_ = 'http://localhost:8095/api/auth/login'; + +function makeHeader(overrides: Partial<{ kind: number; created_at: number; url: string; method: string }> = {}) { + const event = finalizeEvent( + { + kind: overrides.kind ?? NIP98_KIND, + created_at: overrides.created_at ?? Math.floor(Date.now() / 1000), + content: '', + tags: [ + ['u', overrides.url ?? URL_], + ['method', overrides.method ?? 'POST'], + ], + }, + sk, + ); + return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`; +} + +const baseOpts = { allowedUrls: [URL_], method: 'POST', maxSkewSecs: 60 }; + +describe('verifyNip98', () => { + it('accepts a valid header and returns the pubkey', () => { + expect(verifyNip98(makeHeader(), baseOpts)).toBe(pk); + }); + + it('rejects missing header', () => { + expect(() => verifyNip98(undefined, baseOpts)).toThrow(Nip98Error); + }); + + it('rejects wrong kind', () => { + expect(() => verifyNip98(makeHeader({ kind: 1 }), baseOpts)).toThrow(/kind/); + }); + + it('rejects clock skew beyond the window', () => { + const old = Math.floor(Date.now() / 1000) - 120; + expect(() => verifyNip98(makeHeader({ created_at: old }), baseOpts)).toThrow(/clock/); + }); + + it('rejects a mismatched URL', () => { + expect(() => + verifyNip98(makeHeader({ url: 'http://evil.example/api/auth/login' }), baseOpts), + ).toThrow(/u tag/); + }); + + it('accepts equivalent URLs with trailing slash differences', () => { + expect(verifyNip98(makeHeader({ url: URL_ + '/' }), baseOpts)).toBe(pk); + }); + + it('rejects a mismatched method', () => { + expect(() => verifyNip98(makeHeader({ method: 'GET' }), baseOpts)).toThrow(/method/); + }); + + it('rejects tampered events (bad signature)', () => { + const event = JSON.parse( + Buffer.from(makeHeader().slice(6), 'base64').toString('utf8'), + ); + event.tags.push(['t', 'tampered']); + const header = `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`; + expect(() => verifyNip98(header, baseOpts)).toThrow(/signature|u tag|method/); + }); + + it('rejects replayed events', () => { + const header = makeHeader(); + const seen = new Set(); + const opts = { + ...baseOpts, + isReplay: (id: string) => { + if (seen.has(id)) return true; + seen.add(id); + return false; + }, + }; + expect(verifyNip98(header, opts)).toBe(pk); + expect(() => verifyNip98(header, opts)).toThrow(/already used/); + }); + + it('rejects payload hash mismatch', () => { + const now = Math.floor(Date.now() / 1000); + const event = finalizeEvent( + { + kind: NIP98_KIND, + created_at: now, + content: '', + tags: [['u', URL_], ['method', 'POST'], ['payload', 'deadbeef']], + }, + sk, + ); + const header = `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`; + expect(() => verifyNip98(header, { ...baseOpts, body: Buffer.from('{"a":1}') })).toThrow(/payload/); + }); +}); diff --git a/server/src/services/nip98.ts b/server/src/services/nip98.ts new file mode 100644 index 0000000..43d5bac --- /dev/null +++ b/server/src/services/nip98.ts @@ -0,0 +1,72 @@ +import { createHash } from 'node:crypto'; +import { verifyEvent, type Event } from 'nostr-tools/pure'; + +export const NIP98_KIND = 27235; + +export class Nip98Error extends Error {} + +export interface Nip98Options { + /** Full URLs the signed `u` tag is allowed to match (same request via different hosts). */ + allowedUrls: string[]; + method: string; + body?: Buffer | null; + maxSkewSecs: number; + now?: number; + /** Returns true if the event id was already used (replay). */ + isReplay?: (eventId: string) => boolean; +} + +function tag(event: Event, name: string): string | undefined { + return event.tags.find((t) => t[0] === name)?.[1]; +} + +function normalizeUrl(u: string): string { + try { + const url = new URL(u); + // Normalise default ports and trailing slashes so signers and verifiers agree. + return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, '') || '/'}${url.search}`; + } catch { + return u; + } +} + +/** Verify a NIP-98 `Authorization: Nostr ` header. Returns the signer pubkey. */ +export function verifyNip98(header: string | undefined, opts: Nip98Options): string { + if (!header?.startsWith('Nostr ')) throw new Nip98Error('missing Nostr authorization header'); + + let event: Event; + try { + event = JSON.parse(Buffer.from(header.slice(6).trim(), 'base64').toString('utf8')); + } catch { + throw new Nip98Error('malformed authorization event'); + } + + if (event.kind !== NIP98_KIND) throw new Nip98Error(`wrong event kind (expected ${NIP98_KIND})`); + + const now = opts.now ?? Math.floor(Date.now() / 1000); + if (Math.abs(now - event.created_at) > opts.maxSkewSecs) { + throw new Nip98Error('authorization event expired or clock skew too large — check your clock'); + } + + const u = tag(event, 'u'); + if (!u || !opts.allowedUrls.some((a) => normalizeUrl(a) === normalizeUrl(u))) { + throw new Nip98Error('u tag does not match the request URL'); + } + + const method = tag(event, 'method'); + if (!method || method.toUpperCase() !== opts.method.toUpperCase()) { + throw new Nip98Error('method tag does not match the request method'); + } + + if (opts.body && opts.body.length > 0) { + const payload = tag(event, 'payload'); + const digest = createHash('sha256').update(opts.body).digest('hex'); + if (payload && payload !== digest) throw new Nip98Error('payload hash mismatch'); + } + + if (!verifyEvent(event)) throw new Nip98Error('invalid event signature'); + + if (opts.isReplay?.(event.id)) throw new Nip98Error('authorization event already used'); + + return event.pubkey; +} diff --git a/server/src/services/nostr.ts b/server/src/services/nostr.ts new file mode 100644 index 0000000..69205e4 --- /dev/null +++ b/server/src/services/nostr.ts @@ -0,0 +1,77 @@ +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from 'nostr-tools/pure'; +import { SimplePool, useWebSocketImplementation } from 'nostr-tools/pool'; +import { hexToBytes, bytesToHex } from 'nostr-tools/utils'; +import WebSocket from 'ws'; +import type { Stream } from '../types.js'; + +useWebSocketImplementation(WebSocket); + +/** Load the server's nostr identity, generating one on first boot. */ +export function loadServerKey(dataDir: string): Uint8Array { + const dir = join(dataDir, 'config'); + const file = join(dir, 'server-nostr-key'); + if (existsSync(file)) { + return hexToBytes(readFileSync(file, 'utf8').trim()); + } + mkdirSync(dir, { recursive: true }); + const key = generateSecretKey(); + writeFileSync(file, bytesToHex(key), { mode: 0o600 }); + return key; +} + +export interface LiveEventInput { + stream: Stream; + status: 'planned' | 'live' | 'ended'; + hlsUrl: string; +} + +/** Build the NIP-53 kind 30311 live event, signed by the server's key. */ +export function buildLiveEvent(secretKey: Uint8Array, input: LiveEventInput): Event { + const { stream, status, hlsUrl } = input; + const tags: string[][] = [ + ['d', stream.id], + ['title', stream.title], + ['summary', stream.summary], + ['streaming', hlsUrl], + ['status', status], + ['p', stream.owner_pubkey, '', 'host'], + ]; + if (stream.image_url) tags.push(['image', stream.image_url]); + if (stream.starts_at) tags.push(['starts', String(stream.starts_at)]); + if (status === 'ended' && stream.ended_at) tags.push(['ends', String(stream.ended_at)]); + for (const t of JSON.parse(stream.hashtags) as string[]) tags.push(['t', t]); + return finalizeEvent( + { kind: 30311, created_at: Math.floor(Date.now() / 1000), content: '', tags }, + secretKey, + ); +} + +export class NostrPublisher { + private pool = new SimplePool(); + + constructor(private secretKey: Uint8Array, private log: { info: (m: string) => void; warn: (m: string) => void }) {} + + get pubkey(): string { + return getPublicKey(this.secretKey); + } + + /** Publish (or update — 30311 is addressable/replaceable) the live event. Never throws. */ + async publishLiveEvent(relays: string[], input: LiveEventInput): Promise { + const event = buildLiveEvent(this.secretKey, input); + if (relays.length === 0) return event; + const results = await Promise.allSettled(this.pool.publish(relays, event)); + const ok = results.filter((r) => r.status === 'fulfilled').length; + if (ok === 0) { + this.log.warn(`30311 (${input.status}) for ${input.stream.id}: no relay accepted the event`); + } else { + this.log.info(`30311 (${input.status}) for ${input.stream.id}: accepted by ${ok}/${relays.length} relays`); + } + return event; + } + + close(relays: string[]): void { + this.pool.close(relays); + } +} diff --git a/server/src/services/rss.test.ts b/server/src/services/rss.test.ts new file mode 100644 index 0000000..9e20718 --- /dev/null +++ b/server/src/services/rss.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { buildFeedXml, podcastGuidForFeedUrl, xmlEscape } from './rss.js'; +import type { Episode, Podcast } from '../types.js'; + +const podcast: Podcast = { + id: 'p1', + owner_pubkey: 'abc', + title: 'Test & Show', + description: 'A show', + author: 'Alice', + image_url: 'http://host/cover.jpg', + language: 'en', + category: 'Technology', + explicit: 0, + lightning_address: 'alice@getalby.com', + keysend_node: null, + value_suggested: '0.00000005000', + podcast_guid: '917393e3-1b1e-5cef-ace4-edaa54e1f810', + created_at: 1700000000, + updated_at: 1700000000, +}; + +const episode: Episode = { + id: 'e1', + podcast_id: 'p1', + title: 'Episode "One"', + description: 'First!', + sha256: 'a'.repeat(64), + enclosure_url: `http://host:8098/${'a'.repeat(64)}.mp4`, + enclosure_length: 12345, + enclosure_type: 'video/mp4', + duration_secs: 3725, + season: null, + episode_no: 1, + source: 'upload', + pub_date: 1700000100, + created_at: 1700000100, +}; + +describe('podcastGuidForFeedUrl', () => { + it('matches the podcast-namespace spec vector', () => { + // From the podcast:guid spec: feed url mp3s.nashownotes.com/pc20rss.xml + expect(podcastGuidForFeedUrl('https://mp3s.nashownotes.com/pc20rss.xml')).toBe( + '917393e3-1b1e-5cef-ace4-edaa54e1f810', + ); + }); +}); + +describe('xmlEscape', () => { + it('escapes all XML special characters', () => { + expect(xmlEscape(`&'`)).toBe( + '<a href="x">&'</a>', + ); + }); +}); + +describe('buildFeedXml', () => { + const xml = buildFeedXml(podcast, [episode], { publicUrl: 'http://host:8095' }); + + it('produces an RSS 2.0 document with the podcast namespace', () => { + expect(xml).toContain('917393e3-1b1e-5cef-ace4-edaa54e1f810'); + }); + + it('emits an lnaddress value block', () => { + expect(xml).toContain(''); + expect(xml).toContain('type="lnaddress" address="alice@getalby.com" split="100"'); + }); + + it('falls back to a keysend block when only a node pubkey is set', () => { + const p2 = { ...podcast, lightning_address: null, keysend_node: '02' + 'b'.repeat(64) }; + const xml2 = buildFeedXml(p2, [], { publicUrl: 'http://host:8095' }); + expect(xml2).toContain('method="keysend"'); + expect(xml2).toContain(`type="node" address="02${'b'.repeat(64)}"`); + }); + + it('omits the value block when no payment info is set', () => { + const p3 = { ...podcast, lightning_address: null, keysend_node: null }; + expect(buildFeedXml(p3, [], { publicUrl: 'http://host:8095' })).not.toContain(' { + expect(xml).toContain('Test & Show'); + expect(xml).toContain('Episode "One"'); + expect(xml).toContain(``); + expect(xml).toContain('01:02:05'); + expect(xml).toContain(`${'a'.repeat(64)}`); + }); +}); diff --git a/server/src/services/rss.ts b/server/src/services/rss.ts new file mode 100644 index 0000000..a685094 --- /dev/null +++ b/server/src/services/rss.ts @@ -0,0 +1,130 @@ +import { createHash } from 'node:crypto'; +import type { Episode, Podcast } from '../types.js'; + +/** Namespace UUID for podcast:guid, fixed by the podcast-namespace spec. */ +const PODCAST_GUID_NAMESPACE = 'ead4c236-bf58-58c6-a2c6-a6b28d128cb6'; + +export function xmlEscape(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** RFC 4122 UUIDv5 (sha1) — used for podcast:guid over the feed URL. */ +export function uuidv5(name: string, namespace: string = PODCAST_GUID_NAMESPACE): string { + const ns = Buffer.from(namespace.replace(/-/g, ''), 'hex'); + const hash = createHash('sha1').update(ns).update(name, 'utf8').digest(); + hash[6] = (hash[6] & 0x0f) | 0x50; // version 5 + hash[8] = (hash[8] & 0x3f) | 0x80; // variant + const hex = hash.subarray(0, 16).toString('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +/** podcast:guid is the UUIDv5 of the feed URL with protocol scheme and trailing slashes removed. */ +export function podcastGuidForFeedUrl(feedUrl: string): string { + const stripped = feedUrl.replace(/^[a-z]+:\/\//i, '').replace(/\/+$/, ''); + return uuidv5(stripped); +} + +function rfc2822(epochSecs: number): string { + return new Date(epochSecs * 1000).toUTCString(); +} + +function itunesDuration(secs: number): string { + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + const s = secs % 60; + return [h, m, s].map((n) => String(n).padStart(2, '0')).join(':'); +} + +function valueBlock(p: Podcast): string { + const recipients: string[] = []; + if (p.lightning_address) { + recipients.push( + ` `, + ); + } + if (recipients.length === 0 && p.keysend_node) { + recipients.push( + ` `, + ); + } + if (recipients.length === 0) return ''; + const method = p.lightning_address ? 'lnaddress' : 'keysend'; + const suggested = p.value_suggested ? ` suggested="${xmlEscape(p.value_suggested)}"` : ''; + return [ + ` `, + ...recipients, + ' ', + ].join('\n'); +} + +export interface FeedContext { + publicUrl: string; // e.g. http://host:8095 +} + +export function buildFeedXml(podcast: Podcast, episodes: Episode[], ctx: FeedContext): string { + const feedUrl = `${ctx.publicUrl}/feeds/${podcast.id}/feed.xml`; + const link = `${ctx.publicUrl}/podcasts/${podcast.id}`; + const lastPub = episodes[0]?.pub_date ?? podcast.updated_at; + + const items = episodes.map((e) => { + const lines = [ + ' ', + ` ${xmlEscape(e.title)}`, + ` ${xmlEscape(e.description)}`, + ` ${xmlEscape(e.sha256)}`, + ` ${rfc2822(e.pub_date)}`, + ` `, + ]; + if (e.duration_secs != null) lines.push(` ${itunesDuration(e.duration_secs)}`); + if (e.season != null) lines.push(` ${e.season}`); + if (e.episode_no != null) lines.push(` ${e.episode_no}`); + lines.push(' '); + return lines.join('\n'); + }); + + const channel = [ + ` ${xmlEscape(podcast.title)}`, + ` ${xmlEscape(link)}`, + ` ${xmlEscape(podcast.description)}`, + ` ${xmlEscape(podcast.language)}`, + ` podpuddle`, + ` ${rfc2822(lastPub)}`, + ` `, + ` ${podcast.podcast_guid}`, + ` podcast`, + ` ${xmlEscape(podcast.author || podcast.title)}`, + ` ${podcast.explicit ? 'true' : 'false'}`, + ` `, + ]; + if (podcast.image_url) { + channel.push(` `); + channel.push( + ' ', + ` ${xmlEscape(podcast.image_url)}`, + ` ${xmlEscape(podcast.title)}`, + ` ${xmlEscape(link)}`, + ' ', + ); + } + const value = valueBlock(podcast); + if (value) channel.push(value); + + return [ + '', + '', + ' ', + ...channel, + ...items, + ' ', + '', + '', + ].join('\n'); +} diff --git a/server/src/services/settings.ts b/server/src/services/settings.ts new file mode 100644 index 0000000..1e6029e --- /dev/null +++ b/server/src/services/settings.ts @@ -0,0 +1,44 @@ +import type { DB } from '../db/database.js'; +import type { Config } from '../config.js'; + +export interface Settings { + blossom_url: string; + relays: string[]; + public_url: string; + admin_pubkey: string | null; +} + +export class SettingsService { + constructor(private db: DB, private config: Config) {} + + get(key: string): string | null { + const row = this.db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as + | { value: string } + | undefined; + return row?.value ?? null; + } + + set(key: string, value: string): void { + this.db + .prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') + .run(key, value); + } + + all(): Settings { + return { + blossom_url: this.get('blossom_url') ?? this.config.BLOSSOM_URL_DEFAULT, + relays: JSON.parse(this.get('relays') ?? 'null') ?? this.config.defaultRelays, + public_url: this.get('public_url') ?? this.config.PUBLIC_URL, + admin_pubkey: this.get('admin_pubkey'), + }; + } + + /** First pubkey to ever log in becomes the admin. */ + claimAdminIfUnset(pubkey: string): void { + if (!this.get('admin_pubkey')) this.set('admin_pubkey', pubkey); + } + + isAdmin(pubkey: string): boolean { + return this.get('admin_pubkey') === pubkey; + } +} diff --git a/server/src/services/streamkeys.ts b/server/src/services/streamkeys.ts new file mode 100644 index 0000000..8301b41 --- /dev/null +++ b/server/src/services/streamkeys.ts @@ -0,0 +1,21 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; + +/** Public stream id — safe to appear in HLS URLs and nostr events. */ +export function generateStreamId(): string { + return randomBytes(9).toString('base64url').replace(/[-_]/g, 'a').toLowerCase().slice(0, 12); +} + +/** Secret publish key — only ever shown once, stored hashed. */ +export function generateStreamKey(): string { + return randomBytes(24).toString('hex'); +} + +export function hashStreamKey(key: string): string { + return createHash('sha256').update(key).digest('hex'); +} + +export function verifyStreamKey(key: string, hash: string): boolean { + const a = Buffer.from(hashStreamKey(key), 'hex'); + const b = Buffer.from(hash, 'hex'); + return a.length === b.length && timingSafeEqual(a, b); +} diff --git a/server/src/types.ts b/server/src/types.ts new file mode 100644 index 0000000..e2c56bd --- /dev/null +++ b/server/src/types.ts @@ -0,0 +1,71 @@ +export interface User { + pubkey: string; + display_name: string | null; + lud16: string | null; + created_at: number; + last_login_at: number; +} + +export interface Podcast { + id: string; + owner_pubkey: string; + title: string; + description: string; + author: string; + image_url: string | null; + language: string; + category: string; + explicit: number; + lightning_address: string | null; + keysend_node: string | null; + value_suggested: string | null; + podcast_guid: string; + created_at: number; + updated_at: number; +} + +export interface Episode { + id: string; + podcast_id: string; + title: string; + description: string; + sha256: string; + enclosure_url: string; + enclosure_length: number; + enclosure_type: string; + duration_secs: number | null; + season: number | null; + episode_no: number | null; + source: 'upload' | 'recording'; + pub_date: number; + created_at: number; +} + +export interface Stream { + id: string; + owner_pubkey: string; + title: string; + summary: string; + image_url: string | null; + hashtags: string; // JSON array + stream_key_hash: string; + status: 'planned' | 'live' | 'ended'; + starts_at: number | null; + ended_at: number | null; + created_at: number; +} + +export interface Recording { + id: string; + stream_id: string; + segment_path: string; + started_at: number; + duration_secs: number | null; + published_episode_id: string | null; +} + +declare module 'fastify' { + interface FastifyRequest { + userPubkey: string | null; + } +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..bf88bf3 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": false + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/server/vitest.config.ts b/server/vitest.config.ts new file mode 100644 index 0000000..96eb6ab --- /dev/null +++ b/server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, +});